summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-05-30 16:53:30 +0300
committerPaul Buetow <paul@buetow.org>2026-05-30 16:53:30 +0300
commitf2fecaa6ffef505da254b7083116ad840588634b (patch)
tree5662004b072ec15a509a45b87a31e811e7180b2f
parentcab9ae5285a140fab9f4341a8e20eb24b08adca5 (diff)
new
-rw-r--r--prompts/skills/bash-best-practices/SKILL.md66
-rw-r--r--prompts/skills/bash-best-practices/reference/advanced.md153
-rw-r--r--prompts/skills/bash-best-practices/reference/error-handling.md66
-rw-r--r--prompts/skills/bash-best-practices/reference/functions.md77
-rw-r--r--prompts/skills/bash-best-practices/reference/io-patterns.md132
-rw-r--r--prompts/skills/bash-best-practices/reference/style.md131
-rwxr-xr-xscripts/immich-upload262
-rwxr-xr-xscripts/usbimport468
8 files changed, 1355 insertions, 0 deletions
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 `$(<file)` used instead of unnecessary `cat`
+- [ ] Consistent style throughout the script/project
diff --git a/prompts/skills/bash-best-practices/reference/advanced.md b/prompts/skills/bash-best-practices/reference/advanced.md
new file mode 100644
index 0000000..07c65c7
--- /dev/null
+++ b/prompts/skills/bash-best-practices/reference/advanced.md
@@ -0,0 +1,153 @@
+# Advanced Bash Patterns
+
+## Avoid `eval`
+
+Avoid `eval`. Prefer sourcing files or process substitution to generate and source code dynamically.
+
+```bash
+# Good: source a file of declarations
+source vars.source.sh
+
+# Good: source generated code from a command
+source <(./vars.sh)
+```
+
+## Namerefs (`declare -n`)
+
+Use namerefs (Bash 4.3+) for cleaner indirection instead of `eval`.
+
+```bash
+set_value() {
+ local -n ref="$1"
+ ref="$2"
+}
+set_value my_var hello
+```
+
+You can also construct the target name dynamically:
+
+```bash
+make_var() {
+ local idx=$1; shift
+ local name="slot_$idx"
+ printf -v "$name" '%s' "$*"
+}
+
+get_var() {
+ local idx=$1
+ local -n ref="slot_$idx"
+ printf '%s\n' "$ref"
+}
+```
+
+## Background-Job Throttling
+
+When spawning parallel background jobs, cap them to the number of CPU cores and use `wait -n` to pause only until any slot frees up. This avoids process explosions.
+
+```bash
+local -r max_jobs=$(( $(nproc 2>/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 <<EOF
+Hello $USER
+EOF
+
+# Literal here-document (no interpolation)
+cat <<'EOF'
+$USER is not expanded
+EOF
+
+# Here-string
+if grep -q foo <<< "$VAR"; then
+ echo match
+fi
+```
+
+Use `<<-EOF` to strip leading tabs from the body.
+
+### Prefer here-strings over `echo | command`
+
+For single-line input, prefer `command <<< "$var"` instead of `echo "$var" | command`. It avoids an extra pipe, subshell, and process spawn.
+
+```bash
+# Good
+tr '[:upper:]' '[:lower:]' <<< "$text"
+
+# Avoid
+echo "$text" | tr '[:upper:]' '[:lower:]'
+```
+
+## Input Placeholders and Redirection
+
+- `-` as stdin/stdout placeholder for commands like `tar` and `cat`.
+- Redirect via file descriptors explicitly (`2>/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=$(<config.ini)`
+- **Read lines into an array** safely with `mapfile` (aka `readarray`): `mapfile -t lines < file`
+- **Assign formatted strings without a subshell** using `printf -v`: `printf -v msg 'Hello %s' "$USER"`
+
+## Safe xargs with NULs
+
+Avoid breaking on spaces/newlines by pairing `find -print0` with `xargs -0`:
+
+```bash
+find . -type f -name '*.log' -print0 | xargs -0 rm -f
+```
diff --git a/prompts/skills/bash-best-practices/reference/style.md b/prompts/skills/bash-best-practices/reference/style.md
new file mode 100644
index 0000000..6932393
--- /dev/null
+++ b/prompts/skills/bash-best-practices/reference/style.md
@@ -0,0 +1,131 @@
+# Bash Style and Structure
+
+## Shebang
+
+Use `#!/usr/bin/env bash` for portability across Unix-like systems (not all have Bash at `/bin/bash`).
+
+```bash
+#!/usr/bin/env bash
+```
+
+## Strict Mode Header
+
+Start every script with a strict-mode header. Combine `set -e`, `set -u`, and `set -o pipefail` so the script aborts on unexpected errors, unset variables, and pipeline failures.
+
+```bash
+set -euo pipefail
+```
+
+Some projects also add `set -f` (disable pathname expansion). Choose the combination appropriate for your script and apply it consistently.
+
+If a script sources configuration files that may leave variables unset, initialize those variables to empty strings **before** enabling `set -u`:
+
+```bash
+test -z "$CONFIG_FILE_PATH" && CONFIG_FILE_PATH=''
+test -z "$LOG_VERBOSE" && LOG_VERBOSE=''
+set -euo pipefail
+```
+
+Alternatively, access potentially-unset optional variables with `${VAR:-}` or `${VAR:-default}`:
+
+```bash
+if [ -f "${HTML_JS_SCRIPT:-}" ]; then
+ cp "$HTML_JS_SCRIPT" "$dest"
+fi
+```
+
+## Command Substitution
+
+Always use `$(...)` instead of backticks. It nests cleanly, is easier to read, and avoids quoting issues.
+
+```bash
+# Good
+date_stamp=$(date +%Y%m%d)
+
+# Bad (backticks)
+date_stamp=`date +%Y%m%d`
+```
+
+## Indentation and Line Length
+
+- **Indentation**: Use soft-tabs (spaces), not tabs. Two or four spaces are both acceptable; pick one and be consistent within the project.
+- **Line length**: Limit to 80 characters where practical. It encourages smaller functions and is friendlier on small screens.
+
+## Breaking Long Pipelines
+
+Break long pipelines with a backslash before the pipe and a leading pipe on continuation lines. The leading pipe is a visual eye-catcher.
+
+```bash
+# Good
+command1 \
+ | command2 \
+ | command3 \
+ | command4
+```
+
+## Quoting Variables
+
+- Quote variables when the value comes from external input, may contain whitespace, or is unknown.
+- In small scripts with simple bare-word values, unquoted variables are acceptable for readability.
+- In large or shared scripts, quote consistently to avoid accidents and keep ShellCheck happy.
+- Use `${var}` braces only when required (adjacent text, arrays) or when they improve clarity.
+
+```bash
+# Unknown/external input: quote
+echo "${greeting} ${name}!"
+
+# Simple bare words: optional but be consistent
+local -r greeting=Hello
+local -r name=Paul
+echo "$greeting $name!"
+
+# Braces required
+echo "foo${FOO}baz"
+```
+
+## Boolean Style
+
+Bash has no native boolean. Use the string literals `yes` and `no`.
+
+```bash
+declare -r SUGAR_FREE=yes
+declare -r I_NEED_THE_BUZZ=no
+```
+
+## Multi-line Comments
+
+Use a here-doc redirected to the null command for multi-line comments.
+
+```bash
+: <<COMMENT
+This is a multi-line comment.
+COMMENT
+```
+
+## `declare` Modifiers
+
+Use `declare` modifiers for clarity and safety:
+
+- `-r` for read-only (constants)
+- `-i` for integers
+- `-a` for indexed arrays
+- `-A` for associative arrays (Bash 4+)
+
+```bash
+declare -r MAX_RETRIES=3
+declare -i counter=0
+declare -a fruits=(apple banana cherry)
+```
+
+## `local -i` for Integer Variables
+
+Declare integer locals with `local -i` so arithmetic is cleaner and safer.
+
+```bash
+some_function () {
+ local -i num_files=0
+ num_files=$(( num_files + 1 ))
+ # Alternatively:
+ (( num_files++ ))
+}
+```
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/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 <<EOF_USAGE
+Usage: ${0##*/} [auto|fujifilm|fuji|camera|supernote|nomad] [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 only the Note folder from a Supernote Nomad, then convert .note files to PDF.
+
+Default destinations:
+ Fujifilm JPEGs: $FUJIFILM_DEST
+ Supernote Note files: $SUPERNOTE_DEST
+ Supernote PDF files: $SUPERNOTE_PDF_DEST
+ Optional backup mirror: $SUPERNOTE_BACKUP_DEST
+
+Environment overrides:
+ FUJIFILM_DEST=/path/to/dir
+ SUPERNOTE_DEST=/path/to/dir
+ SUPERNOTE_PDF_DEST=/path/to/dir
+ SUPERNOTE_BACKUP_DEST=/path/to/dir
+ SUPERNOTE_BACKUP_TIMEOUT=20s
+ GVFS_ROOT=/run/user/UID/gvfs
+EOF_USAGE
+}
+
+require_gio() {
+ if ! command -v gio >/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 "$@"