summaryrefslogtreecommitdiff
path: root/scripts
diff options
context:
space:
mode:
Diffstat (limited to 'scripts')
-rwxr-xr-xscripts/immich-upload262
-rw-r--r--scripts/random-wallpaper.sh51
-rwxr-xr-xscripts/tmux-cycle-a-session29
-rwxr-xr-xscripts/usbimport477
-rwxr-xr-xscripts/wol-f3s71
5 files changed, 889 insertions, 1 deletions
diff --git a/scripts/immich-upload b/scripts/immich-upload
new file mode 100755
index 0000000..82e9c3a
--- /dev/null
+++ b/scripts/immich-upload
@@ -0,0 +1,262 @@
+#!/usr/bin/env bash
+# Upload images to Immich, skipping duplicates via SHA1 checksum.
+# Usage: immich-upload <file_or_directory>
+#
+# If <file_or_directory> is a single file, upload just that file.
+# If it is a directory, recursively find all image files and upload them.
+# Duplicates are detected via the bulk-upload-check API before uploading.
+
+set -euo pipefail
+
+IMMICH_URL="http://immich.f3s.lan.buetow.org"
+API_KEY_FILE="$HOME/.immich_paul_key"
+
+# Supported image extensions (case-insensitive)
+IMAGE_EXTENSIONS='\.(jpg|jpeg|png|gif|webp|heic|heif|raw|cr2|nef|arw|dng|tiff|tif|bmp)$'
+
+# ------------------------------------------------------------------
+# Helpers
+# ------------------------------------------------------------------
+
+die() { echo "ERROR: $1" >&2; exit 1; }
+
+warn() { echo "WARN: $1" >&2; }
+
+info() { echo "==> $1"; }
+
+# Compute SHA1 (hex, lowercase) for a file.
+file_sha1() {
+ sha1sum "$1" | awk '{print $1}'
+}
+
+# Extract fileCreatedAt and fileModifiedAt from exiftool if available,
+# otherwise fall back to filesystem timestamps.
+# Output is ISO 8601 with milliseconds, e.g. 2024-01-15T10:30:00.000Z
+file_timestamps() {
+ local path="$1"
+ local created modified
+
+ if command -v exiftool &>/dev/null; then
+ # Try to get DateTimeOriginal first, then CreateDate, then FileModifyDate
+ created=$(exiftool -s3 -DateTimeOriginal "$path" 2>/dev/null || true)
+ if [[ -z "$created" ]]; then
+ created=$(exiftool -s3 -CreateDate "$path" 2>/dev/null || true)
+ fi
+ if [[ -z "$created" ]]; then
+ created=$(exiftool -s3 -FileModifyDate "$path" 2>/dev/null || true)
+ fi
+ # exiftool returns something like "2024:01:15 10:30:00" or
+ # "2024:01:15 10:30:00+03:00" with timezone offset.
+ if [[ -n "$created" ]]; then
+ # Strip timezone offset if present, replace colons with dashes for date part,
+ # replace space with T, append .000Z
+ created=$(echo "$created" | sed -E 's/([0-9]{4}):([0-9]{2}):([0-9]{2})/\1-\2-\3/; s/ /T/; s/[+-][0-9]{2}:[0-9]{2}$//; s/$/.000Z/')
+ fi
+ fi
+
+ if [[ -z "$created" ]]; then
+ created=$(date -r "$path" -u '+%Y-%m-%dT%H:%M:%S.000Z')
+ fi
+
+ modified=$(date -r "$path" -u '+%Y-%m-%dT%H:%M:%S.000Z')
+
+ echo "$created"
+ echo "$modified"
+}
+
+# ------------------------------------------------------------------
+# Upload a single file
+# ------------------------------------------------------------------
+
+upload_file() {
+ local file="$1"
+ local api_key="$2"
+ local filename
+ filename=$(basename "$file")
+ local checksum
+ checksum=$(file_sha1 "$file")
+
+ local fileCreatedAt fileModifiedAt
+ local timestamps
+ timestamps=$(file_timestamps "$file")
+ fileCreatedAt=$(echo "$timestamps" | sed -n '1p')
+ fileModifiedAt=$(echo "$timestamps" | sed -n '2p')
+
+ info "Uploading: $filename"
+
+ local response tmpfile
+ tmpfile=$(mktemp)
+ trap "rm -f $tmpfile" RETURN
+
+ local http_code
+ http_code=$(curl -s -o "$tmpfile" -w '%{http_code}' -X POST \
+ -H "x-api-key: $api_key" \
+ -H "x-immich-checksum: $checksum" \
+ -F "assetData=@$file" \
+ -F "fileCreatedAt=$fileCreatedAt" \
+ -F "fileModifiedAt=$fileModifiedAt" \
+ -F "filename=$filename" \
+ -F "deviceAssetId=$checksum" \
+ -F "deviceId=immich-upload-cli" \
+ "$IMMICH_URL/api/assets" 2>/dev/null) || { http_code="000"; }
+
+ if [[ "$http_code" == "200" ]]; then
+ info " Skipped (duplicate): $filename"
+ elif [[ "$http_code" == "201" ]]; then
+ info " Uploaded: $filename"
+ elif [[ "$http_code" == "000" ]]; then
+ warn " Failed to upload: $filename (curl error)"
+ else
+ warn " Unexpected response $http_code for: $filename"
+ cat "$tmpfile" >&2 || true
+ fi
+
+ rm -f "$tmpfile"
+}
+
+# ------------------------------------------------------------------
+# Bulk upload with duplicate checking
+# ------------------------------------------------------------------
+
+bulk_upload() {
+ local api_key="$1"
+ local files_list="$2"
+
+ local total missing_count
+ total=$(wc -l < "$files_list")
+
+ # Compute SHA1 for each file and build the bulk check JSON
+ local check_json tmp_json tmp_missing
+ tmp_json=$(mktemp)
+ tmp_missing=$(mktemp)
+
+ info "Computing SHA1 checksums for $total file(s)..."
+ while IFS= read -r file; do
+ local checksum
+ checksum=$(file_sha1 "$file")
+ printf '%s\t%s\n' "$checksum" "$file" >> "$tmp_json"
+ done < "$files_list"
+
+ # Build the bulk-upload-check JSON
+ {
+ echo '{"assets":['
+ local first=1
+ while IFS=$'\t' read -r checksum file; do
+ [[ "$first" -eq 1 ]] || echo ','
+ printf '{"id":"%s","checksum":"%s"}' "$file" "$checksum"
+ first=0
+ done < "$tmp_json"
+ echo ']}'
+ } > "$tmp_json.json"
+
+ info "Checking for duplicates on server..."
+ local response
+ response=$(curl -sf -X POST \
+ -H "x-api-key: $api_key" \
+ -H "Content-Type: application/json" \
+ -d @"$tmp_json.json" \
+ "$IMMICH_URL/api/assets/bulk-upload-check" 2>/dev/null) || die "bulk-upload-check failed"
+
+ # Parse the response to find which files are duplicates.
+ # Response: { "results": [{ "id": "...", "action": "reject", "reason": "duplicate", ... }] }
+ local resp_file
+ resp_file=$(mktemp)
+ echo "$response" > "$resp_file"
+ python3 -c "
+import json, sys
+try:
+ with open('$resp_file') as f:
+ d = json.load(f)
+ for r in d.get('results', []):
+ if r.get('action') == 'reject' and r.get('reason') == 'duplicate':
+ print(r['id'])
+except Exception:
+ pass
+" > "$tmp_missing.exists"
+ rm -f "$resp_file"
+
+ # Build the missing files list
+ while IFS=$'\t' read -r checksum file; do
+ if ! grep -Fxq "$file" "$tmp_missing.exists"; then
+ echo "$file" >> "$tmp_missing"
+ fi
+ done < "$tmp_json"
+
+ missing_count=$(wc -l < "$tmp_missing" | awk '{print $1}')
+ local dup_count=$(( total - missing_count ))
+ info "Found $dup_count duplicate(s), $missing_count file(s) to upload"
+
+ if [[ "$missing_count" -eq 0 ]]; then
+ info "Nothing to upload."
+ rm -f "$tmp_json" "$tmp_json.json" "$tmp_missing" "$tmp_missing.exists"
+ return
+ fi
+
+ # Upload missing files
+ local n=0
+ while IFS= read -r file; do
+ ((n++)) || true
+ echo "[$n/$missing_count] $file"
+ upload_file "$file" "$api_key"
+ done < "$tmp_missing"
+
+ rm -f "$tmp_json" "$tmp_json.json" "$tmp_missing" "$tmp_missing.exists"
+}
+
+# ------------------------------------------------------------------
+# Main
+# ------------------------------------------------------------------
+
+main() {
+ if [[ $# -ne 1 ]]; then
+ die "Usage: $(basename "$0") <file_or_directory>"
+ fi
+
+ local src="$1"
+
+ if [[ ! -e "$src" ]]; then
+ die "Path does not exist: $src"
+ fi
+
+ if [[ ! -r "$src" ]]; then
+ die "Path is not readable: $src"
+ fi
+
+ if [[ ! -f "$API_KEY_FILE" ]]; then
+ die "API key file not found: $API_KEY_FILE"
+ fi
+
+ local api_key
+ api_key=$(cat "$API_KEY_FILE")
+ if [[ -z "$api_key" ]]; then
+ die "API key file is empty: $API_KEY_FILE"
+ fi
+
+ if [[ -f "$src" ]]; then
+ # Single file upload
+ info "Uploading single file: $src"
+ upload_file "$src" "$api_key"
+ elif [[ -d "$src" ]]; then
+ # Directory: collect image files recursively
+ local files_list
+ files_list=$(mktemp)
+ trap "rm -f $files_list" EXIT
+
+ info "Scanning directory: $src"
+ find "$src" -type f -regextype posix-extended -iregex ".*$IMAGE_EXTENSIONS" -print > "$files_list"
+
+ local count
+ count=$(wc -l < "$files_list" | awk '{print $1}')
+ if [[ "$count" -eq 0 ]]; then
+ die "No image files found in: $src"
+ fi
+ info "Found $count image file(s)"
+
+ bulk_upload "$api_key" "$files_list"
+ rm -f "$files_list"
+ else
+ die "Unsupported path type: $src"
+ fi
+}
+
+main "$@"
diff --git a/scripts/random-wallpaper.sh b/scripts/random-wallpaper.sh
new file mode 100644
index 0000000..03fb160
--- /dev/null
+++ b/scripts/random-wallpaper.sh
@@ -0,0 +1,51 @@
+#!/bin/sh
+# Randomly sets a GNOME desktop wallpaper from image files in a given directory.
+
+WALLPAPER_DIR="$HOME/Pictures/Pixel7ProDCIM/Irregular Ninja/irregular.ninja"
+
+if [ ! -d "$WALLPAPER_DIR" ]; then
+ printf 'Directory not found: %s\n' "$WALLPAPER_DIR" >&2
+ exit 1
+fi
+
+# Pick a random image file (common extensions)
+IMAGE=$(find "$WALLPAPER_DIR" -maxdepth 1 -type f \
+ \( \
+ -iname '*.jpg' -o \
+ -iname '*.jpeg' -o \
+ -iname '*.png' -o \
+ -iname '*.bmp' -o \
+ -iname '*.webp' -o \
+ -iname '*.gif' \
+ \) | sort -R | head -n 1)
+
+if [ -z "$IMAGE" ]; then
+ printf 'No images found in %s\n' "$WALLPAPER_DIR" >&2
+ exit 1
+fi
+
+# GNOME Shell caches wallpaper textures keyed by URI.
+# If the path is always the same, Shell won't reload even when the file
+# contents change. Use a unique filename on every run so the URI changes
+# and Shell is forced to load the new image.
+CLEAN_DIR="$HOME/.local/share/wallpapers"
+UNIQUE="random-wallpaper-$(date +%s)"
+CLEAN_PATH="$CLEAN_DIR/$UNIQUE.jpg"
+
+mkdir -p "$CLEAN_DIR"
+cp "$IMAGE" "$CLEAN_PATH"
+
+# Clean up old copies to avoid filling the directory
+# Keep the last 5 wallpapers around
+ls -1t "$CLEAN_DIR"/random-wallpaper-*.jpg 2> /dev/null | sed -n '6,$p' | xargs -r rm -f
+
+# dconf-service on Fedora 50 has a bug where gsettings set keeps changes in
+# memory but never syncs them to the on-disk database. GNOME Shell reads
+# directly from disk, so it never sees wallpaper changes made with gsettings.
+# Writing via dconf write bypasses the buggy service and hits the database
+# directly, making GNOME Shell pick up the change immediately.
+dconf write /org/gnome/desktop/background/picture-uri "'file://$CLEAN_PATH'"
+dconf write /org/gnome/desktop/background/picture-uri-dark "'file://$CLEAN_PATH'"
+
+# Show me what was set
+printf 'Set wallpaper to: %s (copied from %s)\n' "$CLEAN_PATH" "$IMAGE"
diff --git a/scripts/tmux-cycle-a-session b/scripts/tmux-cycle-a-session
new file mode 100755
index 0000000..2b4557a
--- /dev/null
+++ b/scripts/tmux-cycle-a-session
@@ -0,0 +1,29 @@
+#!/usr/bin/env bash
+# Cycle through tmux sessions whose name starts with "A-".
+# Usage: tmux-cycle-a-session next|prev
+set -euo pipefail
+
+direction="${1:-next}"
+
+mapfile -t sessions < <(tmux list-sessions -F '#S' | grep '^A-' | sort)
+[ "${#sessions[@]}" -eq 0 ] && exit 0
+
+cur=$(tmux display-message -p '#S')
+
+# Find current index (0-based); -1 if not found
+idx=-1
+for i in "${!sessions[@]}"; do
+ if [ "${sessions[$i]}" = "$cur" ]; then
+ idx=$i
+ break
+ fi
+done
+
+n="${#sessions[@]}"
+case "$direction" in
+ next) target_idx=$(( idx < 0 ? 0 : (idx + 1) % n )) ;;
+ prev) target_idx=$(( idx < 0 ? n - 1 : (idx - 1 + n) % n )) ;;
+ *) echo "usage: $0 next|prev" >&2; exit 2 ;;
+esac
+
+tmux switch-client -t "${sessions[$target_idx]}"
diff --git a/scripts/usbimport b/scripts/usbimport
new file mode 100755
index 0000000..6a21778
--- /dev/null
+++ b/scripts/usbimport
@@ -0,0 +1,477 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+declare -r PROGRAM=${0##*/}
+declare -r GVFS_ROOT=${GVFS_ROOT:-"/run/user/$(id -u)/gvfs"}
+declare -r FUJIFILM_DEST=${FUJIFILM_DEST:-"$HOME/Documents/Inbox/Fujifilm"}
+declare -r SUPERNOTE_DEST=${SUPERNOTE_DEST:-"$HOME/Documents/Inbox/Supernote"}
+declare -r CONVERT_PARALLELISM=${CONVERT_PARALLELISM:-3}
+
+declare -i TOTAL_FOUND=0
+declare -i TOTAL_COPIED=0
+declare -i TOTAL_SKIPPED=0
+declare -i TOTAL_FAILED=0
+declare -i TOTAL_CONVERTED=0
+declare -i TOTAL_CONVERT_SKIPPED=0
+declare -a CLEANUP_PATHS=()
+
+log() {
+ printf '%s\n' "$*"
+}
+
+error() {
+ log "$*" >&2
+}
+
+die() {
+ error "$*"
+ return 1
+}
+
+cleanup() {
+ local path
+
+ for path in "${CLEANUP_PATHS[@]}"; do
+ [[ -n $path ]] && rm -rf -- "$path"
+ done
+}
+trap cleanup EXIT
+
+usage() {
+ cat <<EOF_USAGE
+Usage: $PROGRAM [auto|fujifilm|supernote|--test] [destination]
+
+Import files from supported USB devices mounted through GVFS.
+
+Modes:
+ auto Import from the single supported device currently connected.
+ fujifilm Copy only JPEG files from a Fuji/Fujifilm camera.
+ supernote Copy the Supernote Nomad Note folder, then convert .note files to PDF.
+
+Default destinations:
+ Fujifilm JPEGs: $FUJIFILM_DEST
+ Supernote files: $SUPERNOTE_DEST
+
+Environment overrides:
+ FUJIFILM_DEST=/path/to/dir
+ SUPERNOTE_DEST=/path/to/dir
+ CONVERT_PARALLELISM=3
+ GVFS_ROOT=/run/user/UID/gvfs
+EOF_USAGE
+}
+
+require_command() {
+ local command_name=$1
+
+ if ! command -v "$command_name" >/dev/null 2>&1; then
+ die "$command_name is required but was not found in PATH."
+ fi
+}
+
+strip_home() {
+ local path=$1
+
+ printf '%s\n' "${path#"$HOME"/}"
+}
+
+increment() {
+ local -n counter=$1
+
+ ((counter += 1))
+}
+
+find_fujifilm_root() {
+ local root
+
+ for root in "$GVFS_ROOT"/gphoto2:*; do
+ [[ -e $root ]] || continue
+ if gio info "$root" >/dev/null 2>&1; then
+ printf '%s\n' "$root"
+ return 0
+ fi
+ done
+
+ return 1
+}
+
+find_supernote_root() {
+ local root storage
+
+ for root in "$GVFS_ROOT"/mtp:*Supernote* "$GVFS_ROOT"/mtp:*supernote*; do
+ [[ -e $root ]] || continue
+ storage="$root/Internal shared storage"
+
+ if gio info "$storage" >/dev/null 2>&1; then
+ printf '%s\n' "$storage"
+ return 0
+ fi
+ if gio info "$root" >/dev/null 2>&1; then
+ printf '%s\n' "$root"
+ return 0
+ fi
+ done
+
+ return 1
+}
+
+is_jpeg_name() {
+ local name=$1
+
+ case "$name" in
+ *.[Jj][Pp][Gg]|*.[Jj][Pp][Ee][Gg]) return 0 ;;
+ *) return 1 ;;
+ esac
+}
+
+copy_fujifilm_jpegs_from_dir() {
+ local source_dir=$1 dest_root=$2
+ local name _ignored type source_path dest_path
+
+ while IFS=$'\t' read -r name _ignored type _ignored; do
+ [[ -n ${name:-} ]] || continue
+ source_path="$source_dir/$name"
+
+ case "$type" in
+ *directory*)
+ copy_fujifilm_jpegs_from_dir "$source_path" "$dest_root"
+ ;;
+ *regular*)
+ is_jpeg_name "$name" || continue
+ increment TOTAL_FOUND
+ dest_path="$dest_root/$name"
+
+ if [[ -e $dest_path ]]; then
+ log "skip existing: $name"
+ increment TOTAL_SKIPPED
+ continue
+ fi
+
+ log "copy: $source_path -> $dest_path"
+ if gio copy -- "$source_path" "$dest_path"; then
+ increment TOTAL_COPIED
+ else
+ increment TOTAL_FAILED
+ fi
+ ;;
+ esac
+ done < <(gio list -a standard::name,standard::type "$source_dir")
+}
+
+gio_modified_time() {
+ local source_path=$1
+
+ gio info -a time::modified "$source_path" 2>/dev/null \
+ | awk -F': ' '/time::modified:/ {print $2; exit}'
+}
+
+local_file_signature_matches() {
+ local source_size=$1 source_mtime=$2 dest_path=$3
+ local dest_size dest_mtime
+
+ [[ -e $dest_path && -n $source_mtime ]] || return 1
+
+ dest_size=$(stat -c '%s' "$dest_path")
+ dest_mtime=$(stat -c '%Y' "$dest_path")
+ [[ $source_size == "$dest_size" && $source_mtime == "$dest_mtime" ]]
+}
+
+copy_supernote_files_from_dir() {
+ local source_dir=$1 dest_dir=$2 rel_dir=${3:-}
+ local name size type _ignored source_path dest_path rel_path source_mtime
+
+ mkdir -p "$dest_dir"
+
+ while IFS=$'\t' read -r name size type _ignored; do
+ [[ -n ${name:-} ]] || continue
+ source_path="$source_dir/$name"
+ dest_path="$dest_dir/$name"
+ rel_path="${rel_dir:+$rel_dir/}$name"
+
+ case "$type" in
+ *directory*)
+ copy_supernote_files_from_dir "$source_path" "$dest_path" "$rel_path"
+ ;;
+ *regular*)
+ increment TOTAL_FOUND
+ source_mtime=$(gio_modified_time "$source_path")
+
+ if local_file_signature_matches "$size" "$source_mtime" "$dest_path"; then
+ log "skip unchanged: $rel_path"
+ increment TOTAL_SKIPPED
+ continue
+ fi
+
+ rm -f -- "$dest_path"
+ log "copy: $rel_path"
+ if gio copy -- "$source_path" "$dest_path"; then
+ increment TOTAL_COPIED
+ else
+ increment TOTAL_FAILED
+ fi
+ ;;
+ esac
+ done < <(gio list -a standard::name,standard::type,standard::size "$source_dir")
+}
+
+note_signature() {
+ local note_path=$1
+
+ printf 'size=%s\nmtime=%s\n' "$(stat -c '%s' "$note_path")" "$(stat -c '%Y' "$note_path")"
+}
+
+pdf_is_current_for_note() {
+ local note_path=$1 pdf_path=$2 meta_path=$3
+ local current_signature recorded_signature
+
+ [[ -e $pdf_path && -e $meta_path ]] || return 1
+ current_signature=$(note_signature "$note_path")
+ recorded_signature=$(cat "$meta_path")
+ [[ $current_signature == "$recorded_signature" ]]
+}
+
+queue_stale_notes_for_pdf_conversion() {
+ local notes_dir=$1 pdf_dir=$2 todo_file=$3
+ local note_path rel_path pdf_path meta_path
+
+ while IFS= read -r -d '' note_path; do
+ rel_path=${note_path#"$notes_dir"/}
+ pdf_path="$pdf_dir/${rel_path%.note}.pdf"
+ meta_path="${pdf_path}.note-meta"
+ mkdir -p "$(dirname "$pdf_path")"
+
+ if pdf_is_current_for_note "$note_path" "$pdf_path" "$meta_path"; then
+ log "skip current PDF: $(strip_home "$pdf_path")"
+ increment TOTAL_CONVERT_SKIPPED
+ continue
+ fi
+
+ printf '%s\0' "$note_path" >>"$todo_file"
+ done < <(find "$notes_dir" -type f -iname '*.note' -print0)
+}
+
+run_pdf_conversion_workers() {
+ local notes_dir=$1 pdf_dir=$2 todo_file=$3 converted_log=$4 failed_log=$5
+
+ [[ -s $todo_file ]] || return 0
+
+ export NOTES_DIR=$notes_dir
+ export PDF_DIR=$pdf_dir
+ export CONVERTED_LOG=$converted_log
+ export FAILED_LOG=$failed_log
+
+ # Worker variables are intentionally expanded inside bash -c.
+ # shellcheck disable=SC2016
+ xargs -0 -r -n 1 -P "$CONVERT_PARALLELISM" bash -c '
+ set -euo pipefail
+
+ note_path=$1
+ rel_path=${note_path#"$NOTES_DIR"/}
+ pdf_path="$PDF_DIR/${rel_path%.note}.pdf"
+ meta_path="${pdf_path}.note-meta"
+ tmp_path="${pdf_path}.tmp.$$"
+
+ mkdir -p "$(dirname "$pdf_path")"
+ printf "convert: %s -> %s\n" "$rel_path" "${pdf_path#"$HOME"/}"
+
+ if supernote-tool convert -a -t pdf "$note_path" "$tmp_path" >/dev/null; then
+ mv -f -- "$tmp_path" "$pdf_path"
+ {
+ printf "size=%s\n" "$(stat -c "%s" "$note_path")"
+ printf "mtime=%s\n" "$(stat -c "%Y" "$note_path")"
+ } >"$meta_path"
+ printf "%s\n" "$rel_path" >>"$CONVERTED_LOG"
+ else
+ printf "failed to convert: %s\n" "$rel_path" >&2
+ rm -f -- "$tmp_path"
+ printf "%s\n" "$rel_path" >>"$FAILED_LOG"
+ fi
+ ' _ <"$todo_file"
+}
+
+count_lines_if_present() {
+ local path=$1
+
+ if [[ -f $path ]]; then
+ wc -l <"$path"
+ else
+ printf '0\n'
+ fi
+}
+
+convert_supernote_notes_to_pdfs() {
+ local notes_dir=$1 pdf_dir=$2
+ local status_dir todo_file converted_log failed_log converted_count failed_count
+
+ require_command supernote-tool || {
+ increment TOTAL_FAILED
+ return 1
+ }
+
+ mkdir -p "$pdf_dir"
+ find "$pdf_dir" -type f -name '*.pdf.tmp.*' -delete
+
+ status_dir=$(mktemp -d)
+ CLEANUP_PATHS+=("$status_dir")
+ todo_file="$status_dir/todo"
+ converted_log="$status_dir/converted"
+ failed_log="$status_dir/failed"
+
+ queue_stale_notes_for_pdf_conversion "$notes_dir" "$pdf_dir" "$todo_file"
+ run_pdf_conversion_workers "$notes_dir" "$pdf_dir" "$todo_file" "$converted_log" "$failed_log"
+
+ converted_count=$(count_lines_if_present "$converted_log")
+ failed_count=$(count_lines_if_present "$failed_log")
+ TOTAL_CONVERTED=$((TOTAL_CONVERTED + converted_count))
+ TOTAL_FAILED=$((TOTAL_FAILED + failed_count))
+
+ log "Conversion summary: converted=$TOTAL_CONVERTED skipped=$TOTAL_CONVERT_SKIPPED"
+}
+
+finish_import() {
+ local dest=$1
+
+ log "Summary: found=$TOTAL_FOUND copied=$TOTAL_COPIED skipped=$TOTAL_SKIPPED failed=$TOTAL_FAILED"
+
+ if [[ $TOTAL_FAILED -eq 0 ]]; then
+ log "Flushing copied files to disk..."
+ sync
+ log "Imported files to: $dest"
+ log "Import complete. It is safe to unplug the USB device."
+ return 0
+ fi
+
+ error "Imported files to: $dest"
+ error "Import finished with failures."
+ error "It is safe to unplug the USB device,"
+ error "but some files were not copied or converted."
+ return 1
+}
+
+import_fujifilm() {
+ local dest=${1:-$FUJIFILM_DEST}
+ local root
+
+ root=$(find_fujifilm_root) || {
+ error "No GVFS Fuji/Fujifilm camera mount found under $GVFS_ROOT"
+ error "Open the camera in your file manager first, then rerun $PROGRAM fujifilm."
+ return 1
+ }
+
+ mkdir -p "$dest"
+ log "Device: Fujifilm camera"
+ log "Source: $root"
+ log "Destination: $dest"
+ log "Importing JPEG files only; RAW files are ignored."
+ copy_fujifilm_jpegs_from_dir "$root" "$dest"
+ finish_import "$dest"
+}
+
+import_supernote() {
+ local dest=${1:-$SUPERNOTE_DEST}
+ local root note_root
+
+ root=$(find_supernote_root) || {
+ error "No GVFS Supernote mount found under $GVFS_ROOT"
+ error "Open the Supernote in your file manager first, then rerun $PROGRAM supernote."
+ return 1
+ }
+ note_root="$root/Note"
+
+ if ! gio info "$note_root" >/dev/null 2>&1; then
+ error "Supernote is mounted, but its Note folder was not found at: $note_root"
+ return 1
+ fi
+
+ mkdir -p "$dest"
+ log "Device: Supernote Nomad"
+ log "Source: $note_root"
+ log "Destination: $dest"
+ log "Importing only the Supernote Note folder."
+ copy_supernote_files_from_dir "$note_root" "$dest"
+ log "Supernote note files are copied locally."
+ log "It is safe to unplug the Supernote now if you eject/unmount it safely."
+ convert_supernote_notes_to_pdfs "$dest" "$dest"
+ finish_import "$dest"
+}
+
+import_auto() {
+ local has_supernote=0 detected=0
+
+ if find_fujifilm_root >/dev/null; then
+ detected=$((detected + 1))
+ fi
+ if find_supernote_root >/dev/null; then
+ has_supernote=1
+ detected=$((detected + 1))
+ fi
+
+ case "$detected" in
+ 0)
+ error "No supported USB device found under $GVFS_ROOT."
+ error "Supported devices: Fujifilm cameras, Supernote Nomad."
+ return 1
+ ;;
+ 1)
+ if [[ $has_supernote -eq 1 ]]; then
+ import_supernote "$@"
+ else
+ import_fujifilm "$@"
+ fi
+ ;;
+ *)
+ error "Multiple supported devices are connected."
+ error "Run one of these explicitly:"
+ error " $PROGRAM fujifilm"
+ error " $PROGRAM supernote"
+ return 2
+ ;;
+ esac
+}
+
+run_self_test() {
+ require_command bash
+ require_command shellcheck
+
+ bash -n "$0"
+ shellcheck --norc --external-sources --check-sourced "$0"
+}
+
+main() {
+ local mode=${1:-auto}
+ local dest=${2:-}
+
+ case "$mode" in
+ -h|--help)
+ usage
+ return 0
+ ;;
+ --test)
+ run_self_test
+ ;;
+ auto)
+ require_command gio
+ import_auto "$dest"
+ ;;
+ fujifilm)
+ require_command gio
+ import_fujifilm "$dest"
+ ;;
+ supernote)
+ require_command gio
+ import_supernote "$dest"
+ ;;
+ -* )
+ error "Unknown option: $mode"
+ usage >&2
+ return 2
+ ;;
+ *)
+ error "Unknown mode: $mode"
+ require_command gio
+ usage >&2
+ return 2
+ ;;
+ esac
+}
+
+main "$@"
diff --git a/scripts/wol-f3s b/scripts/wol-f3s
index c9563aa..12a4ed5 100755
--- a/scripts/wol-f3s
+++ b/scripts/wol-f3s
@@ -2,6 +2,13 @@
# Wake-on-LAN and shutdown script for f3s hosts (f0, f1, f2, f3)
# and optional shutdown for Raspberry Pi nodes (pi0–pi3)
#
+# Before any shutdown of f3s Beelink hosts (f0/f1/f2/f3), this script will
+# attempt to umount all NFS filesystems currently mounted on this machine
+# (earth). NFS mounts here are tunnelled via stunnel (127.0.0.1:2323 →
+# 192.168.1.138:2323 CARP VIP on f0/f1). If any NFS mount is active and
+# cannot be umounted, the shutdown is aborted to prevent data loss or a
+# hung filesystem.
+#
# Usage:
# wol-f3s # Wake f0, f1, and f2
# wol-f3s f0 # Wake only f0
@@ -50,6 +57,51 @@ wake() {
wol -i "$BROADCAST" "$mac"
}
+# umount_nfs_mounts tries to umount all currently mounted NFS filesystems on
+# this local machine (earth). It reads /proc/mounts to find active nfs/nfs4
+# mounts, attempts to umount each one, and returns 1 if any mount could not
+# be umounted. This prevents shutting down f3s servers while NFS is still in
+# use, which would leave the filesystem in a hung/stale state.
+umount_nfs_mounts() {
+ # Collect all active NFS mount points from /proc/mounts (covers nfs and nfs4)
+ local nfs_mounts=()
+ while IFS= read -r mountpoint; do
+ nfs_mounts+=("$mountpoint")
+ done < <(awk '$3 ~ /^nfs/ { print $2 }' /proc/mounts)
+
+ if [[ ${#nfs_mounts[@]} -eq 0 ]]; then
+ echo " No NFS filesystems currently mounted — nothing to umount."
+ return 0
+ fi
+
+ local failed=0
+ for mp in "${nfs_mounts[@]}"; do
+ echo " Umounting NFS filesystem: $mp ..."
+ if umount "$mp" 2>/dev/null; then
+ echo " ✓ Umounted $mp"
+ else
+ # If it's no longer listed in /proc/mounts, it's already gone.
+ if ! grep -q "^[^ ]* $mp nfs" /proc/mounts; then
+ echo " ✓ $mp was already unmounted"
+ else
+ echo " ✗ Failed to umount $mp (in use or already unmounted?)"
+ failed=1
+ fi
+ fi
+ done
+
+ if [[ $failed -ne 0 ]]; then
+ echo ""
+ echo "✗ One or more NFS filesystems could not be umounted."
+ echo " Aborting shutdown to prevent data loss or a hung filesystem."
+ echo " Please check running processes that may have open files on NFS,"
+ echo " then retry."
+ return 1
+ fi
+
+ return 0
+}
+
shutdown_host() {
local name=$1
local ip=$2
@@ -81,7 +133,12 @@ case "$ACTION" in
wake "f2" "$F2_MAC"
;;
shutdown|poweroff|down)
- # This is to mute Gogios alerts for a day
+ # Umount local NFS filesystems first; abort if any are stuck.
+ # The NFS mounts on earth tunnel through stunnel to the f3s CARP VIP,
+ # so they will hang or corrupt if the servers go down while mounted.
+ echo "Checking for locally mounted NFS filesystems..."
+ umount_nfs_mounts || exit 1
+ # Mute Gogios monitoring alerts for a day on both OpenBSD gateways
ssh rex@blowfish.buetow.org touch /tmp/f3s_taken_down
ssh rex@fishfinger.buetow.org touch /tmp/f3s_taken_down
shutdown_host "f0" "$F0_IP"
@@ -92,12 +149,18 @@ case "$ACTION" in
exit 0
;;
shutdown-f3|poweroff-f3|down-f3)
+ # Umount local NFS filesystems first; abort if any are stuck.
+ # f3 does not host the NFS CARP VIP (that is f0/f1), but umounting
+ # first is still the safe default to avoid stale state.
+ echo "Checking for locally mounted NFS filesystems..."
+ umount_nfs_mounts || exit 1
shutdown_host "f3" "$F3_IP"
echo ""
echo "✓ Shutdown command sent to f3."
exit 0
;;
shutdown-pis)
+ # Raspberry Pis do not serve NFS, so no umount check is needed here.
shutdown_host "pi0" "$PI0_IP"
shutdown_host "pi1" "$PI1_IP"
shutdown_host "pi2" "$PI2_IP"
@@ -107,6 +170,12 @@ case "$ACTION" in
exit 0
;;
shutdown-all)
+ # Umount local NFS filesystems first; abort if any are stuck.
+ # The NFS mounts on earth tunnel through stunnel to the f3s CARP VIP,
+ # so they will hang or corrupt if the servers go down while mounted.
+ echo "Checking for locally mounted NFS filesystems..."
+ umount_nfs_mounts || exit 1
+ # Mute Gogios monitoring alerts for a day on both OpenBSD gateways
ssh rex@blowfish.buetow.org touch /tmp/f3s_taken_down
ssh rex@fishfinger.buetow.org touch /tmp/f3s_taken_down
shutdown_host "f0" "$F0_IP"