diff options
| author | Paul Buetow <paul@buetow.org> | 2026-05-30 16:53:30 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-05-30 16:53:30 +0300 |
| commit | f2fecaa6ffef505da254b7083116ad840588634b (patch) | |
| tree | 5662004b072ec15a509a45b87a31e811e7180b2f /scripts/usbimport | |
| parent | cab9ae5285a140fab9f4341a8e20eb24b08adca5 (diff) | |
new
Diffstat (limited to 'scripts/usbimport')
| -rwxr-xr-x | scripts/usbimport | 468 |
1 files changed, 468 insertions, 0 deletions
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 "$@" |
