diff options
Diffstat (limited to 'scripts/usbimport')
| -rwxr-xr-x | scripts/usbimport | 477 |
1 files changed, 477 insertions, 0 deletions
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 "$@" |
