From f2fecaa6ffef505da254b7083116ad840588634b Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Sat, 30 May 2026 16:53:30 +0300 Subject: new --- prompts/skills/bash-best-practices/SKILL.md | 66 +++ .../bash-best-practices/reference/advanced.md | 153 +++++++ .../reference/error-handling.md | 66 +++ .../bash-best-practices/reference/functions.md | 77 ++++ .../bash-best-practices/reference/io-patterns.md | 132 ++++++ .../skills/bash-best-practices/reference/style.md | 131 ++++++ scripts/immich-upload | 262 ++++++++++++ scripts/usbimport | 468 +++++++++++++++++++++ 8 files changed, 1355 insertions(+) create mode 100644 prompts/skills/bash-best-practices/SKILL.md create mode 100644 prompts/skills/bash-best-practices/reference/advanced.md create mode 100644 prompts/skills/bash-best-practices/reference/error-handling.md create mode 100644 prompts/skills/bash-best-practices/reference/functions.md create mode 100644 prompts/skills/bash-best-practices/reference/io-patterns.md create mode 100644 prompts/skills/bash-best-practices/reference/style.md create mode 100755 scripts/immich-upload create mode 100755 scripts/usbimport diff --git a/prompts/skills/bash-best-practices/SKILL.md b/prompts/skills/bash-best-practices/SKILL.md new file mode 100644 index 0000000..c59440a --- /dev/null +++ b/prompts/skills/bash-best-practices/SKILL.md @@ -0,0 +1,66 @@ +--- +name: bash-best-practices +description: Bash coding style and conventions derived from foo.zone blog posts. Covers structure, safety, idioms, pipelines, redirection, and common pitfalls. Use when writing, reviewing, or refactoring Bash scripts. +--- + +# Bash Best Practices + +Style and structural conventions drawn from the foo.zone Bash coding style guide and Bash Golf series. Apply when writing, reviewing, or refactoring Bash. + +## When to Use + +- Writing new Bash scripts or functions +- Reviewing or refactoring Bash code +- Aligning code with a strict, readable Bash style +- Resolving style questions (shebang, quoting, pipelines, error handling) + +## Conventions Overview + +Start every script with a portable shebang and strict-mode header: + +```bash +#!/usr/bin/env bash +set -euo pipefail +``` + +Use soft-tabs (spaces), limit lines to ~80 characters, and quote variables whose content is unknown or external. Prefer Bash built-ins for light work and external tools (`sed`, `awk`, `grep`, `bc`) for heavy text processing. + +Key idioms covered in detail below: + +| Topic | File | +|-------|------| +| Shebang, strict mode, indentation, quoting, booleans, `declare`, `local -i` | [`reference/style.md`](reference/style.md) | +| Function naming, namespaces, `::`, private helpers (`_`), assign-then-shift, `case` dispatch | [`reference/functions.md`](reference/functions.md) | +| `set -e`, `set -o pipefail`, `PIPESTATUS`, arithmetic comparisons, restricted bash | [`reference/error-handling.md`](reference/error-handling.md) | +| Process substitution, `while read`, here-docs/here-strings, pipelines, `/dev/tcp`, `mapfile` | [`reference/io-patterns.md`](reference/io-patterns.md) | +| `eval` avoidance, namerefs, dynamic command arrays, atomic overwrite, throttling, `shellcheck` | [`reference/advanced.md`](reference/advanced.md) | + +## Quick Checklist + +- [ ] Shebang is `#!/usr/bin/env bash` +- [ ] Strict mode header (`set -euo pipefail`) at top of script +- [ ] Soft-tabs used, line length around 80 +- [ ] `$(...)` used instead of backticks +- [ ] Variables quoted when content is unknown/external +- [ ] Internal helpers prefixed with `_` +- [ ] `case` used for multi-branch literal string matching +- [ ] Built-ins preferred for light work, external tools for heavy +- [ ] Booleans use `yes`/`no` +- [ ] `eval` avoided; `source` or process substitution used instead +- [ ] `set -e` enabled with localized `set +e` for expected failures +- [ ] `pipefail` used when pipelines must fail on any stage +- [ ] Numeric comparisons use `(( ))` or `-gt`/`-lt`/`-eq` +- [ ] Constants declared with `local -r` or `declare -r` +- [ ] Pipelines broken with backslash and leading `|` +- [ ] `FUNCNAME` used for logging when helpful +- [ ] `declare -n` used for indirection where possible +- [ ] `find -print0 | xargs -0` for file lists with spaces +- [ ] `while read` fed by process substitution (`< <(...)`) for variable survival +- [ ] `IFS='' read -r line` used when exact line preservation matters +- [ ] Here-strings (`<<<`) preferred over `echo | command` for single-line input +- [ ] Commands built dynamically in arrays when arguments are conditional +- [ ] Atomic file overwrite via temp + `diff -q` + `mv` +- [ ] Unit tests and `shellcheck` integrated +- [ ] `local -i` used for integer counters +- [ ] `mapfile` or `$(/dev/null || echo 4) )) + +for item in ...; do + while (( $(jobs -rp | wc -l) >= max_jobs )); do + wait -n + done + do_work "$item" & +done +wait +``` + +## Build Commands Dynamically with Arrays + +When constructing commands conditionally, build an array to avoid word-splitting and empty-argument bugs. + +```bash +local -a cmd=("$SOURCE_HIGHLIGHT" "--src-lang=$lang") +if [ -n "$SOURCE_HIGHLIGHT_CSS" ]; then + cmd+=("--style-css-file=$SOURCE_HIGHLIGHT_CSS") +fi +"${cmd[@]}" <<< "$text" +``` + +## Atomic / Safe File Overwrite + +Write to a temporary file, compare with `diff -q`, and `mv` only if content actually changed. This preserves mtime (helpful for downstream skip-logic) and avoids leaving partial files on interrupt. + +```bash +safe_overwrite () { + local -r tmp="$1"; shift + local -r dest="$1"; shift + + if [[ -f "$dest" ]] && diff -q "$tmp" "$dest" >/dev/null 2>&1; then + rm "$tmp" + else + mv "$tmp" "$dest" + fi +} + +# Usage: +echo 'new content' > "$dest.tmp" +safe_overwrite "$dest.tmp" "$dest" +``` + +## Self-Testing and ShellCheck + +Include an `assert` module with helpers like `assert::equals`, `assert::contains`, `assert::not_empty`, and `assert::matches`. Run them as part of a `--test` target. + +Also run `shellcheck` against your scripts as part of the test suite: + +```bash +assert::shellcheck () { + shellcheck \ + --norc \ + --external-sources \ + --check-sourced \ + --exclude=SC2155,SC2010,SC2154,SC1090,SC2012,SC2016,SC1091 \ + ./"$0" +} +``` + +If ShellCheck flags are unavoidable, document the specific `--exclude` reasons in comments. + +## Random Numbers + +Use the special `$RANDOM` variable for quick pseudo-random integers. + +```bash +declare -i delay=$(( RANDOM % 60 )) +sleep $delay +``` + +## Environment Variables for Arguments + +Pass required arguments via environment variables with `${VAR:?message}` for mandatory checks. + +```bash +#!/usr/bin/env bash +declare -r USER=${USER:?Missing the username} +declare -r PASS=${PASS:?Missing the secret password for $USER} +``` + +## Atomic Locking with `mkdir` + +Portable advisory locks can be emulated with `mkdir` because it is atomic: + +```bash +lockdir=/tmp/myjob.lock +if mkdir "$lockdir" 2>/dev/null; then + trap 'rmdir "$lockdir"' EXIT INT TERM + # critical section + do_work +else + echo "Another instance is running" >&2 + exit 1 +fi +``` + +## Smarter globs and faster find-exec + +- Enable extended globs when useful: `shopt -s extglob`; then patterns like `!(tmp|cache)` work. +- Use `-exec ... {} +` to batch many paths in fewer process invocations: + +```bash +find . -name '*.log' -exec gzip -9 {} + +``` diff --git a/prompts/skills/bash-best-practices/reference/error-handling.md b/prompts/skills/bash-best-practices/reference/error-handling.md new file mode 100644 index 0000000..ef06460 --- /dev/null +++ b/prompts/skills/bash-best-practices/reference/error-handling.md @@ -0,0 +1,66 @@ +# Bash Error Handling and Safety + +## Paranoid mode (`set -e`) + +Enable `set -e` so the script exits on any unexpected non-zero status. Temporarily disable it around commands that are allowed to fail. + +```bash +set -e + +some_function () { + # ... critical code ... + + set +e + grep ... || true + local -i ec=$? + set -e + + if (( ec != 0 )); then + : # handle expected non-match + fi +} +``` + +## `pipefail` + +Use `set -o pipefail` so the pipeline returns the status of the last command that exited non-zero, not just the last command. + +```bash +set -o pipefail +command1 | command2 | command3 +``` + +## `PIPESTATUS` + +Capture `PIPESTATUS` into an array immediately after a pipeline to inspect each stage's exit code. + +```bash +tar -cf - ./* | ( cd "$dir" && tar -xf - ) +return_codes=("${PIPESTATUS[@]}") +if (( return_codes[0] != 0 )); then + echo 'tar failed' >&2 +fi +``` + +## Arithmetic and Comparisons + +Use arithmetic evaluation `(( ))` or numeric comparison operators (`-gt`, `-lt`, `-eq`) to avoid unintended lexicographical comparison. + +```bash +# Wrong: lexicographical +if [[ "$my_var" > 3 ]]; then + +# Right: numeric +if (( my_var > 3 )); then +if [[ "$my_var" -gt 3 ]]; then +``` + +## Restricted Bash + +Use `rbash` as a coarse sandbox for highly constrained environments. + +```bash +rbash -c 'echo hi' +``` + +See `man bash` (RESTRICTED SHELL) for details and caveats. diff --git a/prompts/skills/bash-best-practices/reference/functions.md b/prompts/skills/bash-best-practices/reference/functions.md new file mode 100644 index 0000000..083b7a2 --- /dev/null +++ b/prompts/skills/bash-best-practices/reference/functions.md @@ -0,0 +1,77 @@ +# Bash Function Patterns + +## Function Naming and Namespacing + +- Prefer the POSIX-compatible form: `name() { ... }` +- Emulate namespaces with `::`, e.g. `pkg::lang::action`. +- Use `FUNCNAME[0]` for self-aware logging. + +```bash +log() { + local -r callee=${FUNCNAME[1]} + echo "$callee: $*" >&2 +} +``` + +## Private / Internal Functions + +Mark internal helpers with a leading underscore so the public API is obvious: `module::_helper`. This matches the convention used by many Bash projects. + +```bash +# Public +foo::generate () { ... } + +# Internal only +foo::_sort_entries () { ... } +``` + +## Function Arguments: Assign-then-Shift + +Assign function arguments to named `local` variables immediately using `$1`, then `shift`. This makes adding and removing arguments easy without renumbering. + +```bash +some_function () { + local -r param_foo="$1"; shift + local -r param_bar="$1"; shift + local -r param_baz="$1"; shift +} +``` + +## Scope and Functions + +- Functions declared inside other functions are global once defined. +- `export -f function_name` makes a function available in subshells (e.g. `xargs -P`). +- `local` variables have dynamic scope: they are visible down the call stack. + +## Chaining Conditionals + +Functions return exit statuses and can be chained in conditionals. + +```bash +if deploy_check || smoke_test; then + echo "All good." +else + echo "Something failed." >&2 +fi +``` + +## `case` for Multi-Branch String Dispatch + +Replace long `if/elif` chains with `case ... esac` when matching literal string patterns. It is more readable, avoids quoting pitfalls, and performs exact matching. + +```bash +case "$line" in + '* ') + html::make_list_item "$line" + ;; + '# '*) + html::make_heading "$line" 1 + ;; + '## '*) + html::make_heading "$line" 2 + ;; + *) + html::make_paragraph "$line" + ;; +esac +``` diff --git a/prompts/skills/bash-best-practices/reference/io-patterns.md b/prompts/skills/bash-best-practices/reference/io-patterns.md new file mode 100644 index 0000000..ef70e4f --- /dev/null +++ b/prompts/skills/bash-best-practices/reference/io-patterns.md @@ -0,0 +1,132 @@ +# Bash I/O, Pipelines, and Data Processing + +## Built-ins vs External Commands + +- Prefer Bash built-ins for light text processing and arithmetic. +- Use external commands (`sed`, `awk`, `grep`, `cut`, `tr`, `bc`) for heavy or complex text processing. + +```bash +# Prefer built-in +addition=$(( X + Y )) + +# Prefer external for sophisticated transforms +substitution="$(echo "$string" | sed -e 's/^foo/bar/')" +``` + +## Process Substitution + +Use `<(command)` and `>(command)` for treating command output as a file. + +```bash +diff -u <(sort file1) <(sort file2) +tar cjf >(bzip2 -c > file.tar.bz2) foo +``` + +## `while read` with Process Substitution + +When iterating over command output and modifying variables in the parent shell, use process substitution as the input source rather than piping into `while`. A pipe creates a subshell, so variable changes are lost. + +```bash +# Good: changes to $count survive +local -i count=0 +while IFS='' read -r line; do + (( count++ )) +done < <(command) + +# Bad: $count is lost because the while runs in a subshell +local -i count=0 +command | while IFS='' read -r line; do + (( count++ )) +done +``` + +### `IFS='' read -r line` for exact line preservation + +Use `IFS='' read -r line` when reading lines you intend to preserve exactly, including leading and trailing whitespace. Without `IFS=''`, leading/trailing whitespace is stripped; without `-r`, backslashes are interpreted. + +```bash +while IFS='' read -r line; do + echo "$line" +done < file.txt +``` + +## Here-Documents and Here-Strings + +Use here-docs and here-strings for multi-line or inline input. + +```bash +# Here-document with variable interpolation +cat </dev/null`, `1>&2`). +- Remember redirection order matters. + +```bash +echo Foo 2>/dev/null 1>&2 # suppresses everything +``` + +## `/dev/tcp` Networking + +Bash supports TCP via pseudo-files: + +```bash +cat < /dev/tcp/time.nist.gov/13 +exec 5<>/dev/tcp/google.de/80 +``` + +## List Processing: Pipes over Arrays + +For simple list processing, prefer pipelines over arrays. Pass data through stdout to the next stage and use stderr for logging. + +```bash +main () { + filter_lines | + process_lines | + postprocess_lines | + generate_report +} +``` + +## Reading Files and Arrays + +- **Read a whole file into a variable** without spawning `cat`: `cfg=$( +# +# If 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") " + 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/usbimport b/scripts/usbimport new file mode 100755 index 0000000..cf738b8 --- /dev/null +++ b/scripts/usbimport @@ -0,0 +1,468 @@ +#!/usr/bin/env bash +set -euo pipefail + +GVFS_ROOT=${GVFS_ROOT:-"/run/user/$(id -u)/gvfs"} +FUJIFILM_DEST=${FUJIFILM_DEST:-"$HOME/Documents/Inbox/Fujifilm"} +SUPERNOTE_DEST=${SUPERNOTE_DEST:-"$HOME/Documents/Inbox/Note"} +SUPERNOTE_PDF_DEST=${SUPERNOTE_PDF_DEST:-"$HOME/Documents/Supernote"} +SUPERNOTE_BACKUP_DEST=${SUPERNOTE_BACKUP_DEST:-"$HOME/Books/journals/Supernote"} +SUPERNOTE_BACKUP_TIMEOUT=${SUPERNOTE_BACKUP_TIMEOUT:-20s} + +TOTAL_FOUND=0 +TOTAL_COPIED=0 +TOTAL_SKIPPED=0 +TOTAL_FAILED=0 +TOTAL_CONVERTED=0 +TOTAL_CONVERT_SKIPPED=0 +TOTAL_BACKED_UP=0 +TOTAL_BACKUP_SKIPPED=0 +TOTAL_BACKUP_FAILED=0 + +log() { + printf '%s\n' "$*" +} + +usage() { + cat </dev/null 2>&1; then + log "gio is required but was not found in PATH." >&2 + return 1 + fi +} + +find_fujifilm_root() { + local root + shopt -s nullglob + for root in "$GVFS_ROOT"/gphoto2:*; do + if gio info "$root" >/dev/null 2>&1; then + printf '%s\n' "$root" + shopt -u nullglob + return 0 + fi + done + shopt -u nullglob + return 1 +} + +find_supernote_root() { + local root storage + shopt -s nullglob + 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" + shopt -u nullglob + return 0 + fi + if gio info "$root" >/dev/null 2>&1; then + printf '%s\n' "$root" + shopt -u nullglob + return 0 + fi + done + shopt -u nullglob + return 1 +} + +copy_fujifilm_jpegs_from_dir() { + local dir=$1 dest_root=$2 + local name type src dest + + while IFS=$'\t' read -r name _ type _; do + [[ -n ${name:-} ]] || continue + src="$dir/$name" + + case "$type" in + *directory*) + copy_fujifilm_jpegs_from_dir "$src" "$dest_root" + ;; + *regular*) + case "$name" in + *.[Jj][Pp][Gg]|*.[Jj][Pp][Ee][Gg]) + TOTAL_FOUND=$((TOTAL_FOUND + 1)) + dest="$dest_root/$name" + if [[ -e "$dest" ]]; then + log "skip existing: $name" + TOTAL_SKIPPED=$((TOTAL_SKIPPED + 1)) + continue + fi + + log "copy: $src -> $dest" + if gio copy -- "$src" "$dest"; then + TOTAL_COPIED=$((TOTAL_COPIED + 1)) + else + TOTAL_FAILED=$((TOTAL_FAILED + 1)) + fi + ;; + esac + ;; + esac + done < <(gio list -a standard::name,standard::type "$dir") +} + +copy_supernote_files_from_dir() { + local src_dir=$1 dest_dir=$2 rel_dir=${3:-} + local name size type src dest rel_path src_mtime dest_size dest_mtime + + mkdir -p "$dest_dir" + + while IFS=$'\t' read -r name size type _; do + [[ -n ${name:-} ]] || continue + src="$src_dir/$name" + dest="$dest_dir/$name" + rel_path="${rel_dir:+$rel_dir/}$name" + + case "$type" in + *directory*) + copy_supernote_files_from_dir "$src" "$dest" "$rel_path" + ;; + *regular*) + TOTAL_FOUND=$((TOTAL_FOUND + 1)) + if [[ -e "$dest" ]]; then + src_mtime=$(gio info -a time::modified "$src" 2>/dev/null | awk -F': ' '/time::modified:/ {print $2; exit}') + dest_size=$(stat -c '%s' "$dest") + dest_mtime=$(stat -c '%Y' "$dest") + if [[ -n "$src_mtime" && "$size" == "$dest_size" && "$src_mtime" == "$dest_mtime" ]]; then + log "skip unchanged: $rel_path" + TOTAL_SKIPPED=$((TOTAL_SKIPPED + 1)) + continue + fi + rm -f -- "$dest" + fi + + log "copy: $rel_path" + if gio copy -- "$src" "$dest"; then + TOTAL_COPIED=$((TOTAL_COPIED + 1)) + else + TOTAL_FAILED=$((TOTAL_FAILED + 1)) + fi + ;; + esac + done < <(gio list -a standard::name,standard::type,standard::size "$src_dir") +} + +copy_if_changed() { + local src=$1 dest=$2 label=$3 + local src_size dest_size src_mtime dest_mtime + + mkdir -p "$(dirname "$dest")" + if [[ -e "$dest" ]]; then + src_size=$(stat -c '%s' "$src") + dest_size=$(stat -c '%s' "$dest") + src_mtime=$(stat -c '%Y' "$src") + dest_mtime=$(stat -c '%Y' "$dest") + if [[ "$src_size" == "$dest_size" && "$src_mtime" == "$dest_mtime" ]]; then + log "skip existing $label: ${dest#"$HOME"/}" + return 1 + fi + fi + + if timeout "$SUPERNOTE_BACKUP_TIMEOUT" cp -pf -- "$src" "$dest"; then + log "copy $label: ${dest#"$HOME"/}" + return 0 + fi + + log "backup copy timed out or failed: ${dest#"$HOME"/}" >&2 + return 2 +} + +note_signature() { + local note=$1 + + printf 'size=%s\nmtime=%s\n' "$(stat -c '%s' "$note")" "$(stat -c '%Y' "$note")" +} + +pdf_is_current_for_note() { + local note=$1 pdf=$2 meta=$3 + local current recorded + + [[ -e "$pdf" && -e "$meta" ]] || return 1 + current=$(note_signature "$note") + recorded=$(cat "$meta") + [[ "$current" == "$recorded" ]] +} + +convert_supernote_notes_to_pdfs() { + local notes_dir=$1 pdf_dir=$2 + local note rel pdf meta status_dir todo converted_log failed_log converted_count failed_count + + if ! command -v supernote-tool >/dev/null 2>&1; then + log "supernote-tool is required to convert .note files to PDF but was not found in PATH." >&2 + TOTAL_FAILED=$((TOTAL_FAILED + 1)) + return 1 + fi + + mkdir -p "$pdf_dir" + find "$pdf_dir" -type f -name '*.pdf.tmp.*' -delete + status_dir=$(mktemp -d) + todo="$status_dir/todo" + converted_log="$status_dir/converted" + failed_log="$status_dir/failed" + + while IFS= read -r -d '' note; do + rel=${note#"$notes_dir"/} + pdf="$pdf_dir/${rel%.note}.pdf" + meta="${pdf}.note-meta" + mkdir -p "$(dirname "$pdf")" + + if pdf_is_current_for_note "$note" "$pdf" "$meta"; then + log "skip current PDF: ${pdf#"$HOME"/}" + TOTAL_CONVERT_SKIPPED=$((TOTAL_CONVERT_SKIPPED + 1)) + continue + fi + + printf '%s\0' "$note" >>"$todo" + done < <(find "$notes_dir" -type f -iname '*.note' -print0) + + if [[ -s "$todo" ]]; then + 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 3 bash -c ' + set -euo pipefail + note=$1 + rel=${note#"$NOTES_DIR"/} + pdf="$PDF_DIR/${rel%.note}.pdf" + meta="${pdf}.note-meta" + tmp="${pdf}.tmp.$$" + + mkdir -p "$(dirname "$pdf")" + printf "convert: %s -> %s\n" "$rel" "${pdf#"$HOME"/}" + if supernote-tool convert -a -t pdf "$note" "$tmp" >/dev/null; then + mv -f -- "$tmp" "$pdf" + { + printf "size=%s\n" "$(stat -c "%s" "$note")" + printf "mtime=%s\n" "$(stat -c "%Y" "$note")" + } >"$meta" + printf "%s\n" "$rel" >>"$CONVERTED_LOG" + else + printf "failed to convert: %s\n" "$rel" >&2 + rm -f -- "$tmp" + printf "%s\n" "$rel" >>"$FAILED_LOG" + fi + ' _ <"$todo" + fi + + converted_count=0 + failed_count=0 + [[ -f "$converted_log" ]] && converted_count=$(wc -l <"$converted_log") + [[ -f "$failed_log" ]] && failed_count=$(wc -l <"$failed_log") + TOTAL_CONVERTED=$((TOTAL_CONVERTED + converted_count)) + TOTAL_FAILED=$((TOTAL_FAILED + failed_count)) + rm -rf -- "$status_dir" + + log "Conversion summary: converted=$TOTAL_CONVERTED skipped=$TOTAL_CONVERT_SKIPPED" +} + +backup_supernote_notes_and_pdfs() { + local notes_dir=$1 pdf_dir=$2 backup_dir=$3 + local src rel dest rc + + if [[ ! -d "$backup_dir" ]]; then + log "Backup directory not found; skipping backup mirror: $backup_dir" + return 0 + fi + + while IFS= read -r -d '' src; do + rel=${src#"$notes_dir"/} + dest="$backup_dir/$rel" + if copy_if_changed "$src" "$dest" "backup note"; then + TOTAL_BACKED_UP=$((TOTAL_BACKED_UP + 1)) + else + rc=$? + if [[ $rc -eq 1 ]]; then + TOTAL_BACKUP_SKIPPED=$((TOTAL_BACKUP_SKIPPED + 1)) + else + TOTAL_BACKUP_FAILED=$((TOTAL_BACKUP_FAILED + 1)) + fi + fi + done < <(find "$notes_dir" -type f -iname '*.note' -print0) + + if [[ -d "$pdf_dir" ]]; then + while IFS= read -r -d '' src; do + rel=${src#"$pdf_dir"/} + dest="$backup_dir/$rel" + if copy_if_changed "$src" "$dest" "backup PDF"; then + TOTAL_BACKED_UP=$((TOTAL_BACKED_UP + 1)) + else + rc=$? + if [[ $rc -eq 1 ]]; then + TOTAL_BACKUP_SKIPPED=$((TOTAL_BACKUP_SKIPPED + 1)) + else + TOTAL_BACKUP_FAILED=$((TOTAL_BACKUP_FAILED + 1)) + fi + fi + done < <(find "$pdf_dir" -type f -iname '*.pdf' -print0) + fi + + log "Backup summary: copied=$TOTAL_BACKED_UP skipped=$TOTAL_BACKUP_SKIPPED failed=$TOTAL_BACKUP_FAILED destination=$backup_dir" +} + +finish_import() { + local dest=$1 + + log "Summary: found=$TOTAL_FOUND copied=$TOTAL_COPIED skipped=$TOTAL_SKIPPED failed=$TOTAL_FAILED" + if [[ $TOTAL_BACKUP_FAILED -gt 0 ]]; then + log "Optional backup failures: $TOTAL_BACKUP_FAILED" >&2 + fi + 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 + + log "Imported files to: $dest" >&2 + log "Import finished with failures. It is safe to unplug the USB device, but some files were not copied or converted." >&2 + return 1 +} + +import_fujifilm() { + local dest=${1:-$FUJIFILM_DEST} + local root + + root=$(find_fujifilm_root) || { + log "No GVFS Fuji/Fujifilm camera mount found under $GVFS_ROOT" >&2 + log "Open the camera in your file manager first, then rerun ${0##*/} fujifilm." >&2 + 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) || { + log "No GVFS Supernote mount found under $GVFS_ROOT" >&2 + log "Open the Supernote in your file manager first, then rerun ${0##*/} supernote." >&2 + return 1 + } + note_root="$root/Note" + + if ! gio info "$note_root" >/dev/null 2>&1; then + log "Supernote is mounted, but its Note folder was not found at: $note_root" >&2 + return 1 + fi + + mkdir -p "$dest" + log "Device: Supernote Nomad" + log "Source: $note_root" + log "Destination: $dest" + log "PDF destination: $SUPERNOTE_PDF_DEST" + log "Optional backup destination: $SUPERNOTE_BACKUP_DEST" + log "Importing only the Supernote Note folder." + copy_supernote_files_from_dir "$note_root" "$dest" + log "Supernote note files are copied locally. It is safe to unplug the Supernote now if you eject/unmount it safely." + convert_supernote_notes_to_pdfs "$dest" "$SUPERNOTE_PDF_DEST" + backup_supernote_notes_and_pdfs "$dest" "$SUPERNOTE_PDF_DEST" "$SUPERNOTE_BACKUP_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) + log "No supported USB device found under $GVFS_ROOT." >&2 + log "Supported devices: Fujifilm cameras, Supernote Nomad." >&2 + return 1 + ;; + 1) + if [[ $has_supernote -eq 1 ]]; then + import_supernote "$@" + else + import_fujifilm "$@" + fi + ;; + *) + log "Multiple supported devices are connected." >&2 + log "Run one of these explicitly:" >&2 + log " ${0##*/} fujifilm" >&2 + log " ${0##*/} supernote" >&2 + return 2 + ;; + esac +} + +main() { + local mode=${1:-auto} + local dest=${2:-} + + case "$mode" in + -h|--help) + usage + return 0 + ;; + auto) + require_gio + import_auto "$dest" + ;; + fujifilm|fuji|camera) + require_gio + import_fujifilm "$dest" + ;; + supernote|nomad) + require_gio + import_supernote "$dest" + ;; + -* ) + log "Unknown option: $mode" >&2 + usage >&2 + return 2 + ;; + *) + require_gio + import_auto "$mode" + ;; + esac +} + +main "$@" -- cgit v1.2.3