#!/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 </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 "$@"