diff --git a/.config/hypr/keybindings.conf b/.config/hypr/keybindings.conf index 3f71e56..5342154 100644 --- a/.config/hypr/keybindings.conf +++ b/.config/hypr/keybindings.conf @@ -139,3 +139,5 @@ bind = $mainMod, code:112, submap, reset submap = reset bind = SUPER, l, exec, hyprlock + +bind = $mainMod SHIFT, a, exec, ~/.config/rofi/scripts/rofi-anki-script.sh diff --git a/.config/rofi/scripts/rofi-docs.sh b/.config/rofi/scripts/rofi-docs.sh index b307bd7..589c01c 100755 --- a/.config/rofi/scripts/rofi-docs.sh +++ b/.config/rofi/scripts/rofi-docs.sh @@ -1,9 +1,12 @@ #!/usr/bin/env bash +SCRIPT_DIR="$(cd -- "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/rofi-menu-helpers.sh" + BROWSER=/usr/bin/zen-browser -OPTIONS=( - "Arch Linux (btw)" - "Hyprland" +DOC_GROUPS=( + "Arch Linux (btw)|ARCH" + "Hyprland|HYPRLAND" ) ARCH=( "Archlinux Wiki|https://wiki.archlinux.org/title/Main_page" @@ -13,56 +16,37 @@ HYPRLAND=( "Hyprland Window Rules|https://wiki.hypr.land/Configuring/Window-Rules/" ) -get_url() { - urls=("$@") - display_urls=() - declare -A url_map - for url in "${urls[@]}"; do - display_urls+=("${url%%|*}") - label="${url%%|*}" - url_map["$label"]="${url##*|}" - done - display_urls+=("Back") - url_map["Back"]="Back" - - selection="$(printf "%s\n" "${display_urls[@]}" | rofi -theme-str 'window {width: 25%;} listview {columns: 1; lines: 10;}' -theme ~/.config/rofi/launchers/type-2/style-2.rasi -dmenu -l 5 -i -p "Select Documentation")" - url="${url_map[$selection]}" - - if [ -z "$url" ]; then - exit 0 - fi - - printf "%s\n" "$url" +select_group() { + rofi_select_label_value "Select Documentation Group" DOC_GROUPS } -get_docs_list() { - selection="$(printf "%s\n" "${OPTIONS[@]}" | rofi -theme-str 'window {width: 25%;} listview {columns: 1; lines: 10;}' -theme ~/.config/rofi/launchers/type-2/style-2.rasi -dmenu -l 5 -i -p "Select Documentation Group")" - case "$selection" in - "Arch Linux (btw)") - urls=("${ARCH[@]}") - ;; - "Hyprland") - urls=("${HYPRLAND[@]}") - ;; - *) - exit 0 - ;; - esac - - printf "%s\n" "${urls[@]}" +select_url() { + local urls_array="$1" + rofi_select_label_value "Select Documentation" "$urls_array" "Back" } main() { - mapfile -t urls < <(get_docs_list) - url="$(get_url "${urls[@]}")" - if [ -z "$url" ]; then - printf "No URL selected.\n" + while true; do + group_key="$(select_group)" || exit 0 + case "$group_key" in + ARCH) + urls_ref=ARCH + ;; + HYPRLAND) + urls_ref=HYPRLAND + ;; + *) + exit 0 + ;; + esac + + selection="$(select_url "$urls_ref")" || exit 0 + if [[ "$selection" == "Back" ]]; then + continue + fi + $BROWSER "$selection" &>/dev/null & exit 0 - elif [ "$url" == "Back" ]; then - main - exit 0 - fi - $BROWSER "$url" &>/dev/null & + done } main diff --git a/.config/rofi/scripts/rofi-menu-helpers.sh b/.config/rofi/scripts/rofi-menu-helpers.sh new file mode 100644 index 0000000..80c6f5b --- /dev/null +++ b/.config/rofi/scripts/rofi-menu-helpers.sh @@ -0,0 +1,68 @@ +#!/usr/bin/env bash + +# Lightweight helpers to build rofi menus with label/value pairs. +# Intended to be sourced from other scripts. + +# Allow callers to override theme/args without touching code. +: "${ROFI_THEME:=$HOME/.config/rofi/launchers/type-2/style-2.rasi}" +: "${ROFI_THEME_STR:="window {width: 25%;} listview {columns: 1; lines: 10;}"}" +: "${ROFI_DMENU_ARGS:=-i -l 5}" + +# rofi_menu prompt option... +# Prints the selected option to stdout and propagates the rofi exit code +# (1 when the user cancels). +rofi_menu() { + local prompt="$1" + shift + local -a options=("$@") + + local selection + selection="$(printf "%s\n" "${options[@]}" | rofi -dmenu $ROFI_DMENU_ARGS \ + ${ROFI_THEME:+-theme "$ROFI_THEME"} \ + ${ROFI_THEME_STR:+-theme-str "$ROFI_THEME_STR"} \ + -p "$prompt")" + local status=$? + [[ $status -ne 0 ]] && return "$status" + printf "%s\n" "$selection" +} + +# rofi_select_label_value prompt array_name [back_label] +# array_name should contain entries shaped as "Label|Value". +# Prints the mapped value (or the back label when chosen). Returns 1 on cancel. +rofi_select_label_value() { + local prompt="$1" + local array_name="$2" + local back_label="${3:-}" + + # Access caller's array by name + local -n kv_source="$array_name" + local -A kv_map=() + local -a display=() + + for entry in "${kv_source[@]}"; do + local label="${entry%%|*}" + local value="${entry#*|}" + kv_map["$label"]="$value" + display+=("$label") + done + + if [[ -n "$back_label" ]]; then + kv_map["$back_label"]="$back_label" + display+=("$back_label") + fi + + local selection + selection="$(rofi_menu "$prompt" "${display[@]}")" || return "$?" + [[ -z "$selection" ]] && return 1 + printf "%s\n" "${kv_map[$selection]}" +} + +# rofi_select_list prompt array_name +# Convenience wrapper for plain lists (no label/value mapping). +rofi_select_list() { + local prompt="$1" + local array_name="$2" + local -n list_source="$array_name" + rofi_menu "$prompt" "${list_source[@]}" +} + diff --git a/projects/scripts/record-audio.sh b/projects/scripts/record-audio.sh old mode 100644 new mode 100755 index 30947fc..66898d8 --- a/projects/scripts/record-audio.sh +++ b/projects/scripts/record-audio.sh @@ -1,93 +1,145 @@ -#!/bin/sh +#!/usr/bin/env bash -# Version 1.2 -# shoutout to https://gist.github.com/Cephian/f849e326e3522be9a4386b60b85f2f23 for the original script, -# https://github.com/xythh/ added the ankiConnect functionality -# toggle record computer audio (run once to start, run again to stop) -# dependencies: ffmpeg, pulseaudio, curl +# Toggle desktop audio recording and attach the result to the newest Anki note +# (as tagged by Yomichan). Run once to start recording, run again to stop. +# Dependencies: jq, curl, ffmpeg/ffprobe, pulseaudio (parec+pactl), bc, notify-send -# where recording gets saved, gets deleted after being imported to anki -DIRECTORY="$HOME/.cache/" -FORMAT="mp3" # ogg or mp3 -# cut file since it glitches a bit at the end sometimes -CUT_DURATION="0.1" -#port used by ankiconnect -ankiConnectPort="8765" -# gets the newest created card, so make sure to create the card first with yomichan -newestNoteId=$(curl -s localhost:$ankiConnectPort -X POST -d '{"action": "findNotes", "version": 6, "params": { "query": "is:new"}}' | jq '.result[-1]') -#Audio field name -audioFieldName="SentenceAudio" +set -euo pipefail -#if there is no newest note, you either have a complete empty anki or ankiconnect isn't running -if [ "$newestNoteId" = "" ]; then - notify-send "anki connect not found" - exit 1 -fi +ANKI_CONNECT_PORT="${ANKI_CONNECT_PORT:-8765}" +AUDIO_FIELD_NAME="${AUDIO_FIELD_NAME:-SentenceAudio}" +FORMAT="${FORMAT:-mp3}" # mp3 or ogg +CUT_DURATION="${CUT_DURATION:-0.1}" +CACHE_DIR="${XDG_CACHE_HOME:-$HOME/.cache}/record-audio" +RECORD_TIMEOUT="${RECORD_TIMEOUT:-60}" +ANKI_URL="http://localhost:${ANKI_CONNECT_PORT}" -if pgrep -f "parec"; then - pkill -f "parec" -else - time=$(date +%s) - name="$DIRECTORY/$time" - wav_file="$name.wav" - out_file="$name.$FORMAT" - - if ! [ -d "$DIRECTORY" ]; then - mkdir "$DIRECTORY" - fi - notify-send -t 1000 "Audio recording started" - #timeout 1m arecord -t wav -f cd "$wav_file" - - # just grabs last running source... may not always work if your pulseaudio setup is complicated - if ! timeout 1m parec -d"$(pactl list sinks | grep -B1 'State: RUNNING' | sed -nE 's/Sink #(.*)/\1/p' | tail -n 1)" --file-format=wav "$wav_file"; then - - notify-send "Error recording " "most likely no audio playing" - rm "$wav_file" - exit 1 - fi - - input_duration=$(ffprobe -v error -select_streams a:0 -show_entries stream=duration -of default=noprint_wrappers=1:nokey=1 "$wav_file") - output_duration=$(echo "$input_duration"-"$CUT_DURATION" | bc) - - # encode file and delete OG - if [ $FORMAT = "ogg" ]; then - ffmpeg -i "$wav_file" -vn -codec:a libvorbis -b:a 64k -t "$output_duration" "$out_file" - elif [ $FORMAT = "mp3" ]; then - ffmpeg -i "$wav_file" -vn -codec:a libmp3lame -qscale:a 1 -t "$output_duration" "$out_file" - else - notify-send "Record Error" "Unknown format $FORMAT" - fi - rm "$wav_file" - - # Update newest note with recorded audio - curl -s localhost:$ankiConnectPort -X POST -d '{ - - "action": "updateNoteFields", - "version": 6, - "params": { - "note": { - "id": '"$newestNoteId"', - "fields": { - "'$audioFieldName'": "" - }, - "audio": [{ - "path": "'"$out_file"'", - "filename": "'"$time"'.'$FORMAT'", - "fields": [ - "'$audioFieldName'" - ] - }] +require_cmd() { + command -v "$1" >/dev/null 2>&1 || { + echo "Missing dependency: $1" >&2 + exit 1 } } -}' - # opens changed note, comment if you don't want it. - curl -s localhost:$ankiConnectPort -X POST -d '{ - "action": "guiBrowse", - "version": 6, - "params": { - "query": "nid:'"$newestNoteId"'" - } -}' - notify-send -t 1000 "Audio recording copied" - rm "$out_file" -fi + +notify() { + # Best-effort notification; keep script running if notify-send is missing. + if command -v notify-send >/dev/null 2>&1; then + notify-send -t 1000 "$@" + fi +} + +get_active_sink() { + pactl list sinks short 2>/dev/null | awk '$6=="RUNNING"{print $1; exit 0}' +} + +get_newest_note_id() { + local response + response=$(curl -sS "$ANKI_URL" -X POST -H 'Content-Type: application/json' \ + -d '{"action":"findNotes","version":6,"params":{"query":"is:new"}}') + jq -r '.result[-1] // empty' <<<"$response" +} + +update_anki_note() { + local note_id="$1" audio_path="$2" filename="$3" + + local payload + payload=$(jq -n --argjson noteId "$note_id" --arg field "$AUDIO_FIELD_NAME" \ + --arg path "$audio_path" --arg filename "$filename" ' + {action:"updateNoteFields",version:6, + params:{note:{id:$noteId,fields:{($field):""}, + audio:[{path:$path,filename:$filename,fields:[$field]}]}}}') + + curl -sS "$ANKI_URL" -X POST -H 'Content-Type: application/json' -d "$payload" >/dev/null +} + +open_note_in_browser() { + local note_id="$1" + local payload + payload=$(jq -n --argjson noteId "$note_id" ' + {action:"guiBrowse",version:6,params:{query:("nid:" + ($noteId|tostring))}}') + curl -sS "$ANKI_URL" -X POST -H 'Content-Type: application/json' -d "$payload" >/dev/null +} + +record_audio() { + local note_id="$1" + local sink + sink=$(get_active_sink) || true + + if [[ -z "$sink" ]]; then + notify "Record Error" "No running PulseAudio sink found" + exit 1 + fi + + mkdir -p "$CACHE_DIR" + + local timestamp wav_file out_file + timestamp=$(date +%s) + wav_file="$CACHE_DIR/$timestamp.wav" + out_file="$CACHE_DIR/$timestamp.$FORMAT" + + notify "Audio recording started" + + if ! timeout "$RECORD_TIMEOUT" parec -d"$sink" --file-format=wav "$wav_file"; then + notify "Record Error" "No audio captured (timeout or sink issue)" + rm -f "$wav_file" + exit 1 + fi + + local input_duration output_duration + input_duration=$(ffprobe -v error -select_streams a:0 \ + -show_entries stream=duration -of default=noprint_wrappers=1:nokey=1 "$wav_file") + output_duration=$(echo "$input_duration - $CUT_DURATION" | bc -l) + + # Guard against negative durations + if [[ $(echo "$output_duration < 0" | bc -l) -eq 1 ]]; then + output_duration="0" + fi + + case "$FORMAT" in + ogg) + ffmpeg -nostdin -y -i "$wav_file" -vn -codec:a libvorbis -b:a 64k \ + -t "$output_duration" "$out_file" + ;; + mp3) + ffmpeg -nostdin -y -i "$wav_file" -vn -codec:a libmp3lame -qscale:a 1 \ + -t "$output_duration" "$out_file" + ;; + *) + notify "Record Error" "Unknown format: $FORMAT" + rm -f "$wav_file" + exit 1 + ;; + esac + + rm -f "$wav_file" + + update_anki_note "$note_id" "$out_file" "$timestamp.$FORMAT" + open_note_in_browser "$note_id" + + notify "Audio recording copied" + rm -f "$out_file" +} + +main() { + for cmd in curl jq ffmpeg ffprobe parec pactl bc; do + require_cmd "$cmd" + done + + if pgrep -x parec >/dev/null 2>&1; then + pkill -x parec + notify "Audio recording stopped" + exit 0 + fi + + local newest_note + newest_note=$(get_newest_note_id) + + if [[ -z "$newest_note" ]]; then + notify "Anki Connect" "No new notes found or AnkiConnect unavailable" + exit 1 + fi + + record_audio "$newest_note" +} + +main "$@" diff --git a/projects/scripts/screenshot-anki.sh b/projects/scripts/screenshot-anki.sh old mode 100644 new mode 100755 index 464bbc0..87a7212 --- a/projects/scripts/screenshot-anki.sh +++ b/projects/scripts/screenshot-anki.sh @@ -1,75 +1,112 @@ -#!/bin/sh +#!/usr/bin/env bash -# Version 1.2 -# click and drag to screenshot dragged portion -# click on specific window to screenshot window area -# dependencies: imagemagick, xclip,curl maybe xdotool (see comment below) -# shoutout to https://gist.github.com/Cephian/f849e326e3522be9a4386b60b85f2f23 for the original script, -# https://github.com/xythh/ added the ankiConnect functionality -# if anki is running the image is added to your latest note as a jpg, if anki is not running it's added to your clipboard as a png -time=$(date +%s) -tmp_file="$HOME/.cache/$time" -ankiConnectPort="8765" -pictureField="Picture" -quality="90" +# Capture a region with slurp+grim. If AnkiConnect is available, attach the +# JPEG to the newest note; otherwise copy a PNG to the clipboard. -# This gets your notes marked as new and returns the newest one. -newestNoteId=$(curl -s localhost:$ankiConnectPort -X POST -d '{"action": "findNotes", "version": 6, "params": { "query": "is:new"}}' | jq '.result[-1]') +set -euo pipefail -# you can remove these two lines if you don't have software which -# makes your mouse disappear when you use the keyboard (e.g. xbanish, unclutter) -# https://github.com/ImageMagick/ImageMagick/issues/1745#issuecomment-777747494 -xdotool mousemove_relative 1 1 -xdotool mousemove_relative -- -1 -1 +ANKI_CONNECT_PORT="${ANKI_CONNECT_PORT:-8765}" +PICTURE_FIELD="${PICTURE_FIELD:-Picture}" +QUALITY="${QUALITY:-90}" +CACHE_DIR="${XDG_CACHE_HOME:-$HOME/.cache}/screenshot-anki" +ANKI_URL="http://localhost:${ANKI_CONNECT_PORT}" +REQUIREMENTS=(slurp grim wl-copy xdotool curl jq) -# if anki connect is running it will return your latest note id, and the following code will run, if anki connect is not running nothing is return. -if [ "$newestNoteId" != "" ]; then - if ! import -quality $quality "$tmp_file.jpg"; then - # most likley reason this returns a error, is for fullscreen applications that take full control which does not allowing imagemagick to select the area, use windowed fullscreen or if running wine use a virtual desktop to avoid this. - notify-send "Error screenshoting " "most likely unable to find selection" - exit 1 - fi +notify() { + if command -v notify-send >/dev/null 2>&1; then + notify-send "$@" + fi +} - curl -s localhost:$ankiConnectPort -X POST -d '{ - "action": "updateNoteFields", - "version": 6, - "params": { - "note": { - "id": '"$newestNoteId"', - "fields": { - "'$pictureField'": "" - }, - "picture": [{ - "path": "'"$tmp_file"'.jpg", - "filename": "paste-'"$time"'.jpg", - "fields": [ - "'$pictureField'" - ] - }] - } +require_cmd() { + command -v "$1" >/dev/null 2>&1 || { + notify "Missing dependency" "$1 is required" + exit 1 } -}' +} - #remove if you don't want anki to show you the card you just edited - curl -s localhost:$ankiConnectPort -X POST -d '{ - "action": "guiBrowse", - "version": 6, - "params": { - "query": "nid:'"$newestNoteId"'" - } -}' +wiggle_mouse() { + # Avoid disappearing cursor on some compositors + xdotool mousemove_relative 1 1 + xdotool mousemove_relative -- -1 -1 +} - #you can comment this if you do not use notifcations. - notify-send "Screenshot Taken" "Added to note" - rm "$tmp_file.jpg" -else - if ! import -quality $quality "$tmp_file.png"; then - notify-send "Error screenshoting " "most likely unable to find selection" - exit 1 - fi - # we use pngs when copying to clipboard because they have greater support when pasting. - xclip -selection clipboard -target image/png -i "$tmp_file.png" - rm "$tmp_file.png" - #you can comment this if you do not use notifcations. - notify-send "Screenshot Taken" "Copied to clipboard" -fi +capture_region() { + local fmt="$1" quality="$2" output="$3" + local geometry + geometry=$(slurp) + if [[ -z "$geometry" ]]; then + notify "Screenshot cancelled" "No region selected" + exit 1 + fi + if [[ "$fmt" == "jpeg" ]]; then + grim -g "$geometry" -t jpeg -q "$quality" "$output" + else + grim -g "$geometry" -t png "$output" + fi +} + +copy_to_clipboard() { + local file="$1" + if ! wl-copy <"$file"; then + notify "Error copying screenshot" "wl-copy failed" + exit 1 + fi +} + +get_newest_note_id() { + local response + response=$(curl -sS "$ANKI_URL" -X POST -H 'Content-Type: application/json' \ + -d '{"action":"findNotes","version":6,"params":{"query":"is:new"}}') + jq -r '.result[-1] // empty' <<<"$response" +} + +update_note_with_image() { + local note_id="$1" image_path="$2" filename="$3" + local payload + payload=$(jq -n --argjson noteId "$note_id" --arg field "$PICTURE_FIELD" \ + --arg path "$image_path" --arg filename "$filename" ' + {action:"updateNoteFields",version:6, + params:{note:{id:$noteId,fields:{($field):""}, + picture:[{path:$path,filename:$filename,fields:[$field]}]}}}') + curl -sS "$ANKI_URL" -X POST -H 'Content-Type: application/json' -d "$payload" >/dev/null +} + +open_note_in_browser() { + local note_id="$1" + local payload + payload=$(jq -n --argjson noteId "$note_id" ' + {action:"guiBrowse",version:6,params:{query:("nid:" + ($noteId|tostring))}}') + curl -sS "$ANKI_URL" -X POST -H 'Content-Type: application/json' -d "$payload" >/dev/null +} + +main() { + for cmd in "${REQUIREMENTS[@]}"; do + require_cmd "$cmd" + done + + mkdir -p "$CACHE_DIR" + local timestamp base newest_note image_path + timestamp=$(date +%s) + base="$CACHE_DIR/$timestamp" + + wiggle_mouse + newest_note=$(get_newest_note_id) + + if [[ -n "$newest_note" ]]; then + image_path="$base.jpg" + capture_region "jpeg" "$QUALITY" "$image_path" + update_note_with_image "$newest_note" "$image_path" "paste-$timestamp.jpg" + open_note_in_browser "$newest_note" + notify -i "$image_path" "Screenshot Taken" "Added to Anki note" + rm -f "$image_path" + else + image_path="$base.png" + capture_region "png" "" "$image_path" + copy_to_clipboard "$image_path" + notify -i "$image_path" "Screenshot Taken" "Copied to clipboard" + rm -f "$image_path" + fi +} + +main "$@"