#!/usr/bin/env bash
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see .
#
set -euo pipefail
AUDIO_FIELD="audio"
SCREENSHOT_FIELD="image"
SENTENCE_FIELD="Sentence"
# leave OUTPUT_MONITOR blank to autoselect a monitor.
OUTPUT_MONITOR=""
AUDIO_BITRATE="64k"
AUDIO_FORMAT="opus"
AUDIO_VOLUME="1"
MINIMUM_DURATION="0"
IMAGE_FORMAT="webp"
# -2 to calculate dimension while preserving aspect ratio.
IMAGE_WIDTH="-2"
IMAGE_HEIGHT="300"
# the config is sourced at the bottom of this file to overwrite functions.
CONFIG_FILE_PATH="$HOME/.config/ames/config"
usage() {
# display help
echo "-h: display this help message"
echo "-r: record audio toggle"
echo "-s: interactive screenshot"
echo "-a: screenshot same region again (defaults to -s if no region)"
echo "-w: screenshot currently active window (xdotool)"
echo "-c: export copied text (contents of the CLIPBOARD selection)"
}
notify_message() {
# send a notification with a message to the user.
# $1 is the string containing the message text.
#
# notifies both the console and with libnotify.
echo "$1"
notify-send --hint=int:transient:1 -t 500 -u normal "$1"
}
check_response() {
# check the JSON response of a request to Anki.
# $1 is the response from ankiconnect_request().
local -r get_error='s/.*"error"[[:space:]]*:[[:space:]]*\([^,}]*\).*/\1/p'
local -r strip_whitespace='s/[[:space:]]*$//'
# if the error string itself contains "," or "}" this will end early
local -r error="$(echo "$1" \
| sed --posix -n "$get_error" \
| sed --posix "$strip_whitespace")"
if [[ "$error" != null ]]; then
notify_message "${error:1:-1}"
exit 1
fi
}
notify_screenshot_add() {
# notify the user that a screenshot was added.
if [[ "$LANG" == en* ]]; then
notify_message "Screenshot added"
fi
if [[ "$LANG" == ja* ]]; then
notify_message "スクリーンショット付けました"
fi
}
notify_record_start() {
# notify the user that a recording started.
if [[ "$LANG" == en* ]]; then
notify_message "Recording started..."
fi
if [[ "$LANG" == ja* ]]; then
notify_message "録音しています..."
fi
}
notify_record_stop() {
# notify the user that a recording stopped.
if [[ "$LANG" == en* ]]; then
notify_message "Recording added"
fi
if [[ "$LANG" == ja* ]]; then
notify_message "録音付けました"
fi
}
notify_sentence_add() {
# notify the user that a sentence was added.
if [[ "$LANG" == en* ]]; then
notify_message "Sentence added"
fi
if [[ "$LANG" == ja* ]]; then
notify_message "例文付けました"
fi
}
maxn() {
# compute the max element of a list.
tr -d ' ' | tr ',' '\n' | awk '
BEGIN {
max = 0
}
{
if ($0 > max) {
max = $0
}
}
END {
print max
}
'
}
escape() {
# serialize an arbitrary string for use in JSON.
# $1 is the string to serialize.
local escaped="${1//\\/\\\\}"
escaped="${escaped//\"/\\\"}"
local -r newline="
"
escaped="${escaped//$newline/\\n}"
echo -n "$escaped"
}
get_last_id() {
# get the id of the last card added to Anki.
# result is stored in the global variable newest_card_id.
local -r new_card_request='{
"action": "findNotes",
"version": 6,
"params": {
"query": "added:1"
}
}'
local new_card_response list
new_card_response="$(ankiconnect_request "$new_card_request")"
check_response "$new_card_response"
list="$(echo "$new_card_response" | cut -d "[" -f2 | cut -d "]" -f1)"
newest_card_id="$(echo "$list" | maxn)"
}
store_file() {
# store a media file.
local -r dir="${1:?}"
local -r name="$(basename -- "$dir")"
local request='{
"action": "storeMediaFile",
"version": 6,
"params": {
"filename": "",
"path": ""
}
}'
request="${request///$name}"
request="${request//$dir}"
check_response "$(ankiconnect_request "$request")"
}
gui_browse() {
# open the gui card browser and point the modified card.
local -r query="${1:-nid:1}"
local request='{
"action": "guiBrowse",
"version": 6,
"params": {
"query": ""
}
}'
request="${request//$query}"
check_response "$(ankiconnect_request "$request")"
}
ankiconnect_request() {
# send data to Anki through a HTTP request to AnkiConnect.
# $1 is the data to send.
curl --silent localhost:8765 -X POST -d "${1:?}" || \
echo '{"error": "Empty response from AnkiConnect. Is Anki running?"}'
}
safe_request() {
# only send requests after opening the gui browser.
gui_browse "nid:1"
check_response "$(ankiconnect_request "${1:?}")"
gui_browse "nid:${newest_card_id:?Newest card is not known.}"
}
update_sentence() {
# update card with sentence.
# $1 is the sentence.
get_last_id
local update_request='{
"action": "updateNoteFields",
"version": 6,
"params": {
"note": {
"id": ,
"fields": { "": "" }
}
}
}'
update_request="${update_request//$newest_card_id}"
update_request="${update_request//$SENTENCE_FIELD}"
local -r sentence="$(escape "$1")"
update_request="${update_request//$sentence}"
safe_request "$update_request"
}
update_img() {
# update card with image.
# $1 is the path to the image.
get_last_id
local update_request='{
"action": "updateNoteFields",
"version": 6,
"params": {
"note": {
"id": ,
"fields": { "": "\">" }
}
}
}'
update_request="${update_request//$newest_card_id}"
update_request="${update_request//$SCREENSHOT_FIELD}"
update_request="${update_request//$1}"
safe_request "$update_request"
}
update_sound() {
# update card with sound, given by an audio file.
# $1 is the path to the audio file.
get_last_id
local update_request='{
"action": "updateNoteFields",
"version": 6,
"params": {
"note": {
"id": ,
"fields": {
"":"[sound:]"
}
}
}
}'
update_request="${update_request//$newest_card_id}"
update_request="${update_request//$AUDIO_FIELD}"
update_request="${update_request//$1}"
safe_request "$update_request"
}
encode_img() {
# use ffmpeg to encode an image to some desired format.
local -r source_path="$1"
local -r dest_path="$2"
ffmpeg -nostdin \
-hide_banner \
-loglevel error \
-i "$source_path" \
-vf scale="$IMAGE_WIDTH:$IMAGE_HEIGHT" \
"$dest_path"
}
get_selection() {
# get a region of the screen for future screenshotting.
slop
}
take_screenshot_region() {
# function to take a screenshot of a given screen region.
# $1 is the geometry of the region from get_selection().
# $2 is the output file name.
local -r geom="$1"
local -r path="$2"
maim --hidecursor "$path" -g "$geom"
}
take_screenshot_window() {
# function to take a screenshot of the current window.
# $1 is the output file name.
local -r path="$1"
maim --hidecursor "$path" -i "$(xdotool getactivewindow)"
}
screenshot() {
# take a screenshot by prompting the user for a selection
# and then add this image to the last Anki card.
local -r geom="$(get_selection)"
local -r path="$(mktemp /tmp/maim-screenshot.XXXXXX.png)"
local -r base_path="$(basename -- "$path" | cut -d "." -f-2)"
local -r converted_path="/tmp/$base_path.$IMAGE_FORMAT"
take_screenshot_region "$geom" "$path"
encode_img "$path" "$converted_path"
rm "$path"
echo "$geom" >/tmp/previous-maim-screenshot
store_file "$converted_path"
update_img "$(basename -- "$converted_path")"
notify_screenshot_add
}
again() {
# if screenshot() has been called, then repeat take another screenshot
# with the same dimensions as last time and add to the last Anki card.
# otherwise, call screenshot().
local -r path="$(mktemp /tmp/maim-screenshot.XXXXXX.png)"
local -r base_path="$(basename -- "$path" | cut -d "." -f-2)"
local -r converted_path="/tmp/$base_path.$IMAGE_FORMAT"
if [[ -f /tmp/previous-maim-screenshot ]]; then
take_screenshot_region "$(cat /tmp/previous-maim-screenshot)" "$path"
encode_img "$path" "$converted_path"
rm "$path"
store_file "$converted_path"
get_last_id
update_img "$(basename -- "$converted_path")"
notify_screenshot_add
else
screenshot
fi
}
screenshot_window() {
# take a screenshot of the active window and add to the last Anki card.
local -r path="$(mktemp /tmp/maim-screenshot.XXXXXX.png)"
local -r base_path="$(basename -- "$path" | cut -d "." -f-2)"
local -r converted_path="/tmp/$base_path.$IMAGE_FORMAT"
take_screenshot_window "$path"
encode_img "$path" "$converted_path"
rm "$path"
store_file "$converted_path"
update_img "$(basename -- "$converted_path")"
notify_screenshot_add
}
current_time() {
# current time as an integer number of milliseconds since the epoch.
echo "$(date '+%s')$(date '+%N' | awk '{ print substr($1, 0, 3) }')"
}
record_function() {
# function to record desktop audio.
# $1 is the name of the output monitor.
# $2 is the output file name.
# the last function called here MUST be the call to
# ffmpeg or some other program that does recording.
# when -r is called again, the pid of the last function call is killed.
local -r output="$1"
local -r audio_file="$2"
ffmpeg -nostdin \
-y \
-loglevel error \
-f pulse \
-i "$output" \
-ac 2 \
-af "volume=${AUDIO_VOLUME},silenceremove=1:0:-50dB" \
-ab "$AUDIO_BITRATE" \
"$audio_file" 1> /dev/null &
}
record_start() {
# begin recording audio.
local -r audio_file="$(mktemp \
"/tmp/ffmpeg-recording.XXXXXX.$AUDIO_FORMAT")"
echo "$audio_file" >"$recording_toggle"
if [ "$OUTPUT_MONITOR" == "" ]; then
local -r output="$(pactl info \
| grep 'Default Sink' \
| awk '{print $NF ".monitor"}')"
else
local -r output="$OUTPUT_MONITOR"
fi
record_function "$output" "$audio_file"
echo "$!" >> "$recording_toggle"
current_time >> "$recording_toggle"
notify_record_start
}
record_end() {
# end recording.
local -r audio_file="$(sed -n "1p" "$recording_toggle")"
local -r pid="$(sed -n "2p" "$recording_toggle")"
local -r start_time="$(sed -n "3p" "$recording_toggle")"
local -r duration="$(($(current_time) - start_time))"
if [ "$duration" -le "$MINIMUM_DURATION" ]; then
sleep "$((MINIMUM_DURATION - duration))e-3"
fi
rm "$recording_toggle"
kill -15 "$pid"
while [ "$(du "$audio_file" | awk '{ print $1 }')" -eq 0 ]; do
true
done
local -r audio_backup="/tmp/ffmpeg-recording-audio-backup.$AUDIO_FORMAT"
cp "$audio_file" "$audio_backup"
ffmpeg -nostdin \
-y \
-loglevel error \
-i "$audio_backup" \
-c copy \
-to "${duration}ms" \
"$audio_file" 1> /dev/null
store_file "${audio_file}"
update_sound "$(basename -- "$audio_file")"
notify_record_stop
}
record() {
# this section is a heavily modified version of the linux audio
# script written by salamander on qm's animecards.
recording_toggle="/tmp/ffmpeg-recording-audio"
if [[ ! -f /tmp/ffmpeg-recording-audio ]]; then
record_start
else
record_end
fi
}
copied_text() {
# get the contents of the clipboard.
if command -v xclip &> /dev/null
then
xclip -o -selection clipboard
elif command -v xsel &> /dev/null
then
xsel -b
else
echo "Couldn't find xclip or xsel." >&2
exit 1
fi
}
clipboard() {
# get the current clipboard, and add this text to the last Anki card.
local -r sentence="$(copied_text)"
update_sentence "${sentence}"
notify_sentence_add
}
if [[ -f "$CONFIG_FILE_PATH" ]]; then
# shellcheck disable=SC1090
source "$CONFIG_FILE_PATH"
fi
if [[ -z "${1-}" ]]; then
usage
exit 1
fi
while getopts 'hrsawc' flag; do
case "${flag}" in
h) usage ;;
r) record ;;
s) screenshot ;;
a) again ;;
w) screenshot_window ;;
c) clipboard ;;
*) ;;
esac
done