diff options
Diffstat (limited to 'scripts')
| -rwxr-xr-x | scripts/immich-upload | 262 | ||||
| -rw-r--r-- | scripts/random-wallpaper.sh | 51 | ||||
| -rwxr-xr-x | scripts/tmux-cycle-a-session | 29 | ||||
| -rwxr-xr-x | scripts/usbimport | 477 | ||||
| -rwxr-xr-x | scripts/wol-f3s | 71 |
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" |
