#!/bin/bash # downpour.sh -- back up your iCloud-stored data to a local folder. # https://downpour.sh # Copyright (c) 2026 downpour.sh. MIT licensed: https://opensource.org/licenses/MIT # # Usage: bash [output_folder] (default output base: ~/Desktop) # The dated downpour--YYYY-MM-DD-HHMMSS/ subfolder is created # INSIDE output_folder. At most one positional path; positional and # flag order don't matter. # # Flags — each sets the env var shown in [brackets]; exporting that env var # directly is equivalent (e.g. PHOTOS_SYS_COPY=no bash ): # Photos, system library (default ON) # --no-system-originals skip copying system-library originals [PHOTOS_SYS_COPY=no] # --no-system-report skip the system-library report [PHOTOS_SYS_REPORT=no] # Photos, other libraries (default OFF) # --other-libraries also process non-system libraries (copy + report) # --no-other-originals other libraries: skip originals copy [PHOTOS_OTHER_COPY=no] # --no-other-report other libraries: skip the report [PHOTOS_OTHER_REPORT=no] # Photos, shared albums (default ON) # --no-shared-originals skip the per-album asset copy [SHARED_COPY=no] # --no-shared-report skip the shared-albums report rows [SHARED_REPORT=no] # --no-verify-shared-matches skip czkawka match verification + fetch [PHOTOS_VERIFY_SHARED_MATCHES=0] # (alias: --disable-verify-shared-matches) # Auto-fetch components # --disable-protoc Notes: skip protoc fetch, use strings [NOTES_FETCH_PROTOC=0] # (alias: --no-protoc) # --disable-imessage-exporter Messages: skip binary fetch, use bash [MESSAGES_USE_IMESSAGE_EXPORTER=0] # (alias: --no-imessage-exporter) # --disable-czkawka Photos: skip czkawka fetch (shared-album [PHOTOS_VERIFY_SHARED_MATCHES=0] # matches stay unchecked) (alias: --no-czkawka) # --disable-convert-docs skip iWork->ODF conversion + LibreOffice [CONVERT_DOCS=0] # (alias: --no-convert-docs) # -h / --help print this help and exit # # Console + GUI-menu only. SSH / headless / no-TTY / cron runs are refused # (there is no unattended mode). Use the interactive menu to deselect leaves and to enable # the off-by-default categories (Books, Freeform, Maps, Music Playlists, # Safari Bookmarks, Stickies, Text Replacements, Voice Memos, Convert iWork # Files) — the flags above cover only the Photos scopes and the auto-fetches. if (return 0 2>/dev/null); then printf 'ERROR: this script must be executed, not sourced.\n' >&2; return 1; fi : "${SKIP_ALIASES:=1}" CLEAN_DOWNLOADS="${CLEAN_DOWNLOADS:-0}" EXPORT_OVERHEAD_GB="${EXPORT_OVERHEAD_GB:-10}" CONFIRM_AUTO_NO_SEC="${CONFIRM_AUTO_NO_SEC:-10}" MENU_AUTO_CANCEL_SEC="${MENU_AUTO_CANCEL_SEC:-60}" FILES_INCLUSIONS=( Documents # wholesale mirror Downloads # wholesale mirror Desktop # wholesale mirror — prior downpour-/export- timestamped subfolders are pruned Public # wholesale mirror (the shared / Drop Box folder) Pictures # full minus *.photoslibrary (see FILES_INCLUDE_PHOTOSLIBRARY_BUNDLES) Movies # full minus *.imovielibrary (see FILES_INCLUDE_IMOVIE_BUNDLES) Music # full minus GarageBand/ (see FILES_INCLUDE_GARAGEBAND_BUNDLES) ) FILES_EXCLUSIONS=( "Movies/iMovie Theater.theater" # iMovie's shared-theater bundle "Movies/iMovie Theater" # pre-bundle theater store (older macOS) "Movies/TV" # the TV.app media library "Music/Audio Music Apps" # Audio Units / plug-in scratch "Music/Music" # the Music.app (iTunes) library ) FILES_INCLUDE_IMOVIE_BUNDLES=1 FILES_INCLUDE_GARAGEBAND_BUNDLES=1 FILES_INCLUDE_PHOTOSLIBRARY_BUNDLES=0 FILES_INCLUDE_OTHER_HOME=1 : "${FREEFORM_GUI_WAIT_SEC:=20}" : "${EXPORT_PASSWORDS_CONFIRM:=1}" : "${PHOTOS_SYS_COPY:=yes}" : "${PHOTOS_SYS_REPORT:=yes}" : "${PHOTOS_OTHER_COPY:=no}" : "${PHOTOS_OTHER_REPORT:=yes}" : "${PHOTOS_INCLUDE_SHARED_SOURCE:=yes}" : "${PHOTOS_PERLIB_HEADROOM_MB:=500}" : "${PHOTO_LIBRARY_DOWNLOAD_MISSING:=1}" : "${PHOTO_LIBRARY_DOWNLOAD_TIMEOUT_SEC:=60}" SHARED_COPY="${SHARED_COPY:-yes}" SHARED_REPORT="${SHARED_REPORT:-yes}" : "${SHARED_ALBUMS_PER_ASSET_SEC:=60}" : "${STICKIES_RTF_MAX_MB:=50}" if (return 0 2>/dev/null); then printf 'ERROR: this script must be executed, not sourced.\n' >&2 return 1 fi trap '' USR1 2>/dev/null || true [ -z "${BASH_VERSION:-}" ] && { printf 'ERROR: requires bash\n' >&2; exit 1; } case "$BASH_VERSION" in [12].*|3.0*|3.1*) printf 'ERROR: bash 3.2+ required (found %s)\n' "$BASH_VERSION" >&2; exit 1 ;; esac _macos_pv="$(sw_vers -productVersion 2>/dev/null)" if [ -z "$_macos_pv" ]; then printf 'ERROR: requires macOS (Tahoe 26+); could not read sw_vers -productVersion.\n' >&2 exit 1 fi _macos_major="${_macos_pv%%.*}" case "$_macos_major" in ''|*[!0-9]*) printf 'ERROR: unexpected sw_vers -productVersion output: %s\n' "$_macos_pv" >&2 exit 1 ;; esac _macos_major=$((10#$_macos_major)) if [ "$_macos_major" -lt 26 ]; then printf 'ERROR: macOS Tahoe (26) or newer required. This Mac reports %s.\n' "$_macos_pv" >&2 printf ' Earlier macOS versions are not supported.\n' >&2 exit 1 fi unset _macos_pv _macos_major if [ -z "${HOME:-}" ] || [ ! -d "$HOME" ]; then printf 'ERROR: HOME is unset or not a directory (got %q)\n' "${HOME:-}" >&2 exit 1 fi set -uo pipefail IFS=$' \t\n' export COPYFILE_DISABLE=1 WARN_COUNT=0 ERR_COUNT=0 : "${_LEAF_COUNTS_ID:=$$}" SCRATCH_DIR="" OUTPUT_PATHS_TO_CLEAN=() CHILD_PIDS=() _TEE_PIDS=() _PRE_PREPARE_WARNS=() _PRE_PREPARE_ERRS=() SUCCESS_FLAG="no" _CLEANUP_RUNNING=0 DOWNPOUR_TRACE_FILE="" if [ -n "${DOWNPOUR_TRACE:-}" ]; then DOWNPOUR_TRACE_FILE="${TMPDIR:-/tmp}/downpour-trace.$$.log" : > "$DOWNPOUR_TRACE_FILE" 2>/dev/null || DOWNPOUR_TRACE_FILE="" if [ -n "$DOWNPOUR_TRACE_FILE" ]; then exec 2> >(tee -a "$DOWNPOUR_TRACE_FILE" >&2) set -x else printf 'warn: DOWNPOUR_TRACE set but cannot write %s — tracing disabled\n' \ "${TMPDIR:-/tmp}/downpour-trace.$$.log" >&2 fi fi _TM_SUSPENDED=0 _LAST_BLANK=0 [ "${_INVOKED_BY_WRAPPER:-0}" = "1" ] && _LAST_BLANK=1 _BANNER_SHOWN=0 : "${TCC_POLL_TIMEOUT:=20}" case "$TCC_POLL_TIMEOUT" in ''|*[!0-9]*) TCC_POLL_TIMEOUT=20 ;; 0|[1-9]*) ;; *) TCC_POLL_TIMEOUT=$((10#$TCC_POLL_TIMEOUT)) ;; esac : "${TCC_POLL_INTERVAL_SEC:=2}" case "$TCC_POLL_INTERVAL_SEC" in ''|*[!0-9]*) TCC_POLL_INTERVAL_SEC=2 ;; 0) TCC_POLL_INTERVAL_SEC=1 ;; [1-9]*) ;; *) TCC_POLL_INTERVAL_SEC=$((10#$TCC_POLL_INTERVAL_SEC)) ;; esac if [ -t 1 ] || [ "${_DOWNPOUR_FORCE_TTY_OUT:-0}" = "1" ]; then _B=$'\033[1m' # bold on _D=$'\033[2m' # dim on _N=$'\033[22m' # bold + dim off (back to regular) else _B=""; _D=""; _N="" fi _TAG_OK='ok:'; _TAG_WARN='warn:'; _TAG_ERR='error:' _TAG_OK_W=${#_TAG_OK}; _TAG_WARN_W=${#_TAG_WARN}; _TAG_ERR_W=${#_TAG_ERR} _C_OK=''; _C_WARN=''; _C_ERR=''; _C_RST1=''; _C_RST2='' _C_BRAND=''; _C_BRAND_RST='' _status_color=0 _TPUT_COLORS=0 if [ -z "${NO_COLOR:-}" ] && command -v tput >/dev/null 2>&1; then _sc_n="$(tput colors 2>/dev/null || echo 0)" case "$_sc_n" in ''|*[!0-9]*) _sc_n=0 ;; esac _TPUT_COLORS="$_sc_n" [ "$_sc_n" -ge 8 ] && _status_color=1 unset _sc_n fi if [ -t 1 ] || [ "${_DOWNPOUR_FORCE_TTY_OUT:-0}" = "1" ]; then _TAG_OK='✓'; _TAG_OK_W=1 if [ "$_status_color" = "1" ]; then _C_OK=$'\033[32m'; _C_RST1=$'\033[0m' if [ "${_TPUT_COLORS:-0}" -ge 256 ]; then _C_BRAND=$'\033[38;5;75m'; else _C_BRAND=$'\033[34m'; fi _C_BRAND_RST=$'\033[0m' fi fi if [ -t 2 ] || [ "${_DOWNPOUR_FORCE_TTY_ERR:-0}" = "1" ]; then _TAG_WARN='!'; _TAG_WARN_W=1; _TAG_ERR='✗'; _TAG_ERR_W=1 [ "$_status_color" = "1" ] && { _C_WARN=$'\033[33m'; _C_ERR=$'\033[31m'; _C_RST2=$'\033[0m'; } fi unset _status_color _blank() { [ "$_LAST_BLANK" = "1" ] && return printf '\n' _LAST_BLANK=1 } _WRAP_WIDTH="${DOWNPOUR_WRAP_WIDTH:-120}" case "$_WRAP_WIDTH" in ''|*[!0-9]*) _WRAP_WIDTH=120 ;; esac { [ "$_WRAP_WIDTH" -ge 40 ] && [ "$_WRAP_WIDTH" -le 200 ]; } || _WRAP_WIDTH=120 _REPORT_WIDTH="${DOWNPOUR_FILE_WRAP_WIDTH:-120}" case "$_REPORT_WIDTH" in ''|*[!0-9]*) _REPORT_WIDTH=120 ;; esac [ "$_REPORT_WIDTH" -gt 2000 ] && _REPORT_WIDTH=2000 [ "$_REPORT_WIDTH" -lt 40 ] && _REPORT_WIDTH=40 _log_fold() { _LF_P1="$1" _LF_W1="$2" _LF_CONT="$3" _LF_W="${_WRAP_WIDTH:-120}" \ LC_ALL=C awk ' BEGIN { p1 = ENVIRON["_LF_P1"]; w1 = ENVIRON["_LF_W1"] + 0 cont = ENVIRON["_LF_CONT"]; W = ENVIRON["_LF_W"] + 0; if (W <= 0) W = 80 a1 = W - w1; if (a1 < 8) a1 = 8 ac = W - length(cont); if (ac < 8) ac = 8 } { n = split($0, word, " "); line = ""; first = 1 for (i = 1; i <= n; i++) { if (word[i] == "") continue lim = (first ? a1 : ac) if (line == "") line = word[i] else if (length(line) + 1 + length(word[i]) > lim) { if (first) { printf "%s%s\n", p1, line; first = 0 } else printf "%s%s\n", cont, line line = word[i] } else line = line " " word[i] } if (first) printf "%s%s\n", p1, line else printf "%s%s\n", cont, line }' } log_info() { local _m="$*" case "$_m" in *": /"*|*": ~/"*) printf ' %s\n' "$_m"; _LAST_BLANK=0; return 0 ;; esac if [ "$(( ${#_m} + 2 ))" -le "${_WRAP_WIDTH:-120}" ]; then printf ' %s\n' "$_m" else local _lead="${_m%%[! ]*}" _txt _txt="${_m#"$_lead"}" printf '%s\n' "$_txt" | _log_fold " $_lead" "$(( 2 + ${#_lead} ))" " $_lead" fi _LAST_BLANK=0 } log_ok() { local _m="$*" _pw=$(( 2 + _TAG_OK_W + 1 )) if [ "$(( ${#_m} + _pw ))" -le "${_WRAP_WIDTH:-120}" ]; then printf ' %s%s%s %s\n' "$_C_OK" "$_TAG_OK" "$_C_RST1" "$_m" else printf '%s\n' "$_m" | _log_fold " ${_C_OK}${_TAG_OK}${_C_RST1} " "$_pw" " " fi _LAST_BLANK=0 } log_warn() { local _m="$*" _pw=$(( 2 + _TAG_WARN_W + 1 )) if [ "$(( ${#_m} + _pw ))" -le "${_WRAP_WIDTH:-120}" ]; then printf ' %s%s%s %s\n' "$_C_WARN" "$_TAG_WARN" "$_C_RST2" "$_m" >&2 else printf '%s\n' "$_m" | _log_fold " ${_C_WARN}${_TAG_WARN}${_C_RST2} " "$_pw" " " >&2 fi WARN_COUNT=$((WARN_COUNT+1)) local _msg="${*//$'\n'/ }" if [ -n "${_LEAF_COUNTS_DIR:-}" ] && [ -d "${_LEAF_COUNTS_DIR:-}" ] \ && [ -w "${_LEAF_COUNTS_DIR:-}" ]; then printf '%s\n' "$_msg" >> "$_LEAF_COUNTS_DIR/$_LEAF_COUNTS_ID.warns" 2>/dev/null || true else _PRE_PREPARE_WARNS+=( "$_msg" ) fi _LAST_BLANK=0 } log_err() { local _m="$*" _pw=$(( 2 + _TAG_ERR_W + 1 )) if [ "$(( ${#_m} + _pw ))" -le "${_WRAP_WIDTH:-120}" ]; then printf ' %s%s%s %s\n' "$_C_ERR" "$_TAG_ERR" "$_C_RST2" "$_m" >&2 else printf '%s\n' "$_m" | _log_fold " ${_C_ERR}${_TAG_ERR}${_C_RST2} " "$_pw" " " >&2 fi ERR_COUNT=$((ERR_COUNT+1)) local _msg="${*//$'\n'/ }" if [ -n "${_LEAF_COUNTS_DIR:-}" ] && [ -d "${_LEAF_COUNTS_DIR:-}" ] \ && [ -w "${_LEAF_COUNTS_DIR:-}" ]; then printf '%s\n' "$_msg" >> "$_LEAF_COUNTS_DIR/$_LEAF_COUNTS_ID.errs" 2>/dev/null || true else _PRE_PREPARE_ERRS+=( "$_msg" ) fi _LAST_BLANK=0 } log_step() { _blank; printf '%s%s── %s%s%s\n' "$_C_BRAND" "$_B" "$*" "$_N" "$_C_BRAND_RST"; _LAST_BLANK=0; } die() { log_err "$*"; exit 1; } cleanup_handler() { local rc="${1:-$?}" trap '' INT TERM HUP QUIT USR1 [ "$_CLEANUP_RUNNING" -eq 1 ] && return _CLEANUP_RUNNING=1 local p _any_alive=0 if declare -F _heartbeat_stop >/dev/null 2>&1; then _heartbeat_stop 2>/dev/null || true fi if declare -F leaf_cleanup_hook >/dev/null 2>&1; then ( leaf_cleanup_hook ) || true fi if [ -n "${_LEAF_COUNTS_DIR:-}" ]; then local _post_w _post_e if [ -s "$_LEAF_COUNTS_DIR/$_LEAF_COUNTS_ID.warns" ]; then _post_w="$(wc -l < "$_LEAF_COUNTS_DIR/$_LEAF_COUNTS_ID.warns" 2>/dev/null | tr -d ' ')" case "$_post_w" in ''|*[!0-9]*) _post_w=0 ;; esac [ "$_post_w" -gt "$WARN_COUNT" ] && WARN_COUNT="$_post_w" fi if [ -s "$_LEAF_COUNTS_DIR/$_LEAF_COUNTS_ID.errs" ]; then _post_e="$(wc -l < "$_LEAF_COUNTS_DIR/$_LEAF_COUNTS_ID.errs" 2>/dev/null | tr -d ' ')" case "$_post_e" in ''|*[!0-9]*) _post_e=0 ;; esac [ "$_post_e" -gt "$ERR_COUNT" ] && ERR_COUNT="$_post_e" fi fi _write_leaf_counts if [ "${#_TEE_PIDS[@]}" -gt 0 ]; then local _t _n for _t in "${_TEE_PIDS[@]+"${_TEE_PIDS[@]}"}"; do disown "$_t" 2>/dev/null || true done exec 1>/dev/null 2>/dev/null local _alive _n=0 while [ "$_n" -lt 20 ]; do _alive=0 for _t in "${_TEE_PIDS[@]+"${_TEE_PIDS[@]}"}"; do kill -0 "$_t" 2>/dev/null && { _alive=1; break; } done [ "$_alive" = "0" ] && break sleep 0.1 2>/dev/null || true _n=$((_n + 1)) done for _t in "${_TEE_PIDS[@]+"${_TEE_PIDS[@]}"}"; do kill -KILL "$_t" 2>/dev/null || true pkill -KILL -P "$_t" 2>/dev/null || true done local _new_cp=() _cp _tp _skip_cp for _cp in "${CHILD_PIDS[@]+"${CHILD_PIDS[@]}"}"; do _skip_cp=0 for _tp in "${_TEE_PIDS[@]+"${_TEE_PIDS[@]}"}"; do [ "$_cp" = "$_tp" ] && { _skip_cp=1; break; } done [ "$_skip_cp" = "1" ] && continue _new_cp+=( "$_cp" ) done CHILD_PIDS=( "${_new_cp[@]+"${_new_cp[@]}"}" ) fi for p in "${CHILD_PIDS[@]+"${CHILD_PIDS[@]}"}"; do [ -z "$p" ] && continue if kill -0 "$p" 2>/dev/null; then _any_alive=1; break; fi done if [ "$_any_alive" = "1" ]; then for p in "${CHILD_PIDS[@]+"${CHILD_PIDS[@]}"}"; do [ -z "$p" ] && continue kill -TERM "$p" 2>/dev/null || true done sleep 1 2>/dev/null || true for p in "${CHILD_PIDS[@]+"${CHILD_PIDS[@]}"}"; do [ -z "$p" ] && continue kill -KILL "$p" 2>/dev/null || true done fi [ "${#CHILD_PIDS[@]}" -gt 0 ] && CHILD_PIDS=() [ "${#_TEE_PIDS[@]}" -gt 0 ] && _TEE_PIDS=() if [ "$SUCCESS_FLAG" != "yes" ] && [ "${#OUTPUT_PATHS_TO_CLEAN[@]}" -gt 0 ]; then if [ "${_INVOKED_BY_WRAPPER:-0}" != "1" ]; then local _kept_root="${_OUT_DIR:-}" if [ -z "$_kept_root" ]; then local _cand for _cand in "${OUTPUT_PATHS_TO_CLEAN[@]+"${OUTPUT_PATHS_TO_CLEAN[@]}"}"; do [ -d "$_cand" ] && { _kept_root="$_cand"; break; } done fi printf ' Keeping the partial (INCOMPLETE) export%s — scratch/temp removed.\n' "${_kept_root:+ at $_kept_root}" 2>/dev/null >/dev/tty \ || printf ' Keeping the partial (INCOMPLETE) export — scratch/temp removed.\n' 2>/dev/null \ || true if [ -n "$_kept_root" ] && [ -d "$_kept_root" ]; then { printf '\357\273\277' # UTF-8 BOM, as above _report_frame "ABORTED — export interrupted" printf ' Result: INCOMPLETE \342\200\224 the run was interrupted before it finished.\n' printf '\n'; _report_band "WHAT THIS MEANS"; printf '\n' printf ' \342\200\242 The partial output was KEPT; only scratch / temporary files were removed.\n' printf ' \342\200\242 Some categories may be missing, partial, or mid-write.\n' printf ' \342\200\242 Completeness was NOT verified: the run never reached its summary step,\n' printf ' so nothing here is a ledger of what you got. Do NOT treat this folder as\n' printf ' a complete or trustworthy backup.\n' printf ' \342\200\242 Re-run downpour.sh to produce a complete, verified export.\n' } > "$_kept_root/_downpour_report.txt" 2>/dev/null || true fi fi fi if [ -z "${_INVOKED_BY_WRAPPER:-}" ] && [ -n "${_LEAF_COUNTS_DIR:-}" ] \ && [ -f "$_LEAF_COUNTS_DIR/leaf_scratch_dirs" ]; then local _lsd while IFS= read -r _lsd; do [ -n "$_lsd" ] && [ -d "$_lsd" ] || continue case "$_lsd" in */downpour.*) ;; *) continue ;; esac chmod -R u+w "$_lsd" 2>/dev/null || true rm -rf -- "$_lsd" 2>/dev/null || true done < "$_LEAF_COUNTS_DIR/leaf_scratch_dirs" fi if [ -n "$SCRATCH_DIR" ] && [ -d "$SCRATCH_DIR" ]; then chmod -R u+w "$SCRATCH_DIR" 2>/dev/null || true rm -rf -- "$SCRATCH_DIR" 2>/dev/null || true fi if [ -n "$DOWNPOUR_TRACE_FILE" ]; then if [ "$rc" -eq 0 ] && [ "$SUCCESS_FLAG" = "yes" ]; then rm -f -- "$DOWNPOUR_TRACE_FILE" 2>/dev/null || true else printf ' trace: %s\n' "$DOWNPOUR_TRACE_FILE" 2>/dev/null >/dev/tty \ || printf ' trace: %s\n' "$DOWNPOUR_TRACE_FILE" >&2 \ || true fi fi if [ -z "${_INVOKED_BY_WRAPPER:-}" ] && [ -z "${_LEAF_COUNTS_DIR:-}" ]; then _blank fi trap - INT TERM HUP QUIT EXIT exit "$rc" } resolve_output_dir() { local out="${1:-$HOME/Desktop}" case "$out" in [~]) out="$HOME" ;; [~]/*) out="$HOME/${out#~/}" ;; esac [ -d "$out" ] || die "Output folder does not exist: $out" [ -w "$out" ] || die "Output folder is not writable: $out" case "$out" in *$'\n'*) die "Output folder path contains a newline (unsupported): $out" ;; esac local abs abs="$(cd "$out" 2>/dev/null && pwd -P)" || die "Cannot resolve output folder: $out" [ -n "$abs" ] || die "Cannot resolve output folder: $out" [ "$abs" = "/" ] && die "Refusing to use / as the output folder" case "$abs" in *$'\n'*) die "Output folder path contains a newline (unsupported): $out" ;; esac local prefix for prefix in "${SOURCE_PROTECTED_PREFIXES[@]+"${SOURCE_PROTECTED_PREFIXES[@]}"}"; do case "$abs/" in "$prefix"/*|"$prefix"/) die "Output folder is inside protected source-data path: $prefix Choose a destination outside ~/Library." ;; esac done printf '%s' "$abs" } _parse_output_folder_arg() { local out_arg="" arg end_opts=0 _seen=0 while [ "$#" -gt 0 ]; do arg="$1"; shift if [ "$end_opts" -eq 0 ]; then case "$arg" in --) end_opts=1; continue ;; -*) die "Unknown flag: $arg" ;; esac fi if [ "$_seen" -eq 1 ]; then die "Too many positional arguments: '$arg' (already saw '$out_arg'). Pass at most one output folder." fi out_arg="$arg"; _seen=1 done printf '%s' "$out_arg" } require_no_collision() { local p for p in "$@"; do if [ -e "$p" ] || [ -L "$p" ]; then die "Output exists, refusing to clobber: '$p'" fi done } _fda_grant_hint() { log_info " Grant it in: System Settings > Privacy & Security > Full Disk Access" log_info " Add your terminal (Terminal, iTerm, Ghostty, etc.) and enable the toggle." } check_full_disk_access() { local tcc="$HOME/Library/Application Support/com.apple.TCC/TCC.db" [ -n "$(head -c1 -- "$tcc" 2>/dev/null)" ] && return 0 local _fda_path for _fda_path in \ "$HOME/Library/Application Support/AddressBook" \ "$HOME/Library/Mail" \ "$HOME/Library/Messages" \ "$HOME/Library/Group Containers/group.com.apple.calendar" \ "$HOME/Library/Group Containers/group.com.apple.notes"; do [ -d "$_fda_path" ] || continue ls "$_fda_path" >/dev/null 2>&1 && return 0 done return 1 } require_full_disk_access() { case "${_FDA_STATE:-unknown}" in granted) return 0 ;; denied) if check_full_disk_access; then log_ok "Full Disk Access: granted (after wrapper pre-flight)" return 0 fi log_err "Full Disk Access not granted (wrapper pre-flight already polled and timed out)." _fda_grant_hint log_info " Then re-run." exit 1 ;; unknown|'') ;; *) log_warn "_FDA_STATE='$_FDA_STATE' unrecognized; treating as unset (full probe-and-poll path)." ;; esac check_full_disk_access && return 0 log_info "Full Disk Access not yet granted — opening System Settings." log_info " Add your terminal (Terminal, iTerm, Ghostty, etc.) to the" log_info " Full Disk Access list and toggle it ON." log_info " Polling for up to ${TCC_POLL_TIMEOUT} seconds; the run will proceed when you grant." open 'x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles' \ >/dev/null 2>&1 || true if wait_for_grant check_full_disk_access "Full Disk Access" "$TCC_POLL_TIMEOUT"; then log_ok "Full Disk Access: granted" return 0 fi log_err "Full Disk Access was not granted within ${TCC_POLL_TIMEOUT} s." _fda_grant_hint log_info " If polling didn't pick up your grant, quit and re-launch the" log_info " terminal, then re-run this script." exit 1 } require_accessibility() { case "${_ACCESSIBILITY_STATE:-unknown}" in granted) return 0 ;; denied|na) if check_accessibility; then return 0 fi log_err "Accessibility not granted (wrapper pre-flight already probed)." log_info " Grant it in: System Settings > Privacy & Security > Accessibility" log_info " Add your terminal binary and enable the toggle, then re-run." return 1 ;; esac log_info "Probing Accessibility..." if check_accessibility; then log_ok "Accessibility: granted" return 0 fi log_info "Accessibility permission required — opening System Settings." log_info " Add your terminal (Terminal, iTerm, Ghostty, etc.) to the" log_info " Privacy & Security > Accessibility list and toggle it ON." log_info " Polling for up to ${TCC_POLL_TIMEOUT} seconds; the run will proceed when you grant." open 'x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility' \ >/dev/null 2>&1 || true if wait_for_grant check_accessibility "Accessibility" "$TCC_POLL_TIMEOUT"; then log_ok "Accessibility: granted" return 0 fi log_err "Accessibility was not granted within ${TCC_POLL_TIMEOUT} s." log_info " Grant it in: System Settings > Privacy & Security > Accessibility" log_info " Add your terminal binary and enable the toggle, then re-run." return 1 } print_summary() { _probe_disclose if [ "${_INVOKED_BY_WRAPPER:-0}" = "1" ]; then _write_leaf_counts return 0 fi _blank if [ "$ERR_COUNT" -eq 0 ] && [ "$WARN_COUNT" -eq 0 ]; then printf '%sDone. No warnings or errors.%s\n' "$_D" "$_N" elif [ "$ERR_COUNT" -eq 0 ]; then printf '%sDone with %d %s.%s\n' \ "$_D" "$WARN_COUNT" "$(_pluralize "$WARN_COUNT" warning)" "$_N" else printf '%sDone with %d %s and %d %s.%s\n' \ "$_D" "$ERR_COUNT" "$(_pluralize "$ERR_COUNT" error)" \ "$WARN_COUNT" "$(_pluralize "$WARN_COUNT" warning)" "$_N" fi _LAST_BLANK=0 _blank _write_leaf_counts if [ -n "${_LEAF_REPORT_CATEGORY:-}" ] && [ -n "${_LEAF_REPORT_DIR:-}" ]; then if [ ! -d "$_LEAF_REPORT_DIR" ] && [ -s "$(_report_store)" ]; then mkdir -p "$_LEAF_REPORT_DIR" 2>/dev/null || true fi [ -d "$_LEAF_REPORT_DIR" ] && \ finalize_leaf_report "$_LEAF_REPORT_CATEGORY" "$_LEAF_REPORT_DIR" fi } _write_leaf_counts() { [ "${_INVOKED_BY_WRAPPER:-0}" = "1" ] || return 0 [ -n "${_LEAF_COUNTS_DIR:-}" ] || return 0 [ -d "${_LEAF_COUNTS_DIR:-}" ] && [ -w "${_LEAF_COUNTS_DIR:-}" ] || return 0 { printf 'WARN=%d\n' "$WARN_COUNT" printf 'ERR=%d\n' "$ERR_COUNT" } > "$_LEAF_COUNTS_DIR/$_LEAF_COUNTS_ID.counts" 2>/dev/null || true } maybe_show_help() { local arg for arg in "$@"; do case "$arg" in -h|--help|-\?|--help=*) local src="${BASH_SOURCE[0]:-}" if [ -n "$src" ] && [ -f "$src" ] && [ -r "$src" ]; then awk 'NR==1 { next } /^$/ { exit } { sub(/^# ?/, ""); print }' "$src" else printf 'Help text lives in this script'\''s header comment (the spec is the script).\n' printf 'View it with: curl -fsSL | head -60\n' fi exit 0 ;; esac done } _countdown_key_prompt() { local _ckp_sec="$1" _ckp_var="$2" _ckp_text="$3" _ckp_s _ckp_stty="" printf -v "$_ckp_var" '%s' '' 2>/dev/null || true case "$_ckp_sec" in ''|*[!0-9]*|0) _ckp_sec=10 ;; esac ( : /dev/null || return 0 local _ckp_kf; _ckp_kf="$(mktemp "${SCRATCH_DIR:-${TMPDIR:-/tmp}}/.ckp.XXXXXX" 2>/dev/null)" || return 0 _ckp_stty="$(stty -g /dev/null || true)" ( _ckp_k=""; IFS= read -r -n 1 _ckp_k /dev/null \ && printf '%s' "$_ckp_k" > "$_ckp_kf"; ) & local _ckp_rpid=$! for _ckp_s in $(LC_ALL=C seq "$_ckp_sec" -1 1); do printf '\r %s (auto-no in %ds) ' "$_ckp_text" "$_ckp_s" >&2 [ -s "$_ckp_kf" ] && break sleep 1 2>/dev/null || true [ -s "$_ckp_kf" ] && break done kill -KILL "$_ckp_rpid" 2>/dev/null || true wait "$_ckp_rpid" 2>/dev/null || true [ -n "$_ckp_stty" ] && stty "$_ckp_stty" /dev/null || true printf -v "$_ckp_var" '%s' "$(cat "$_ckp_kf" 2>/dev/null)" 2>/dev/null || true rm -f "$_ckp_kf" 2>/dev/null } ensure_scratch() { [ -n "$SCRATCH_DIR" ] && [ -d "$SCRATCH_DIR" ] && return 0 SCRATCH_DIR="$(mktemp -d -t downpour)" || die "Cannot create scratch directory" [ -n "$SCRATCH_DIR" ] && [ -d "$SCRATCH_DIR" ] || die "Cannot create scratch directory (mktemp produced no usable path)." case "$SCRATCH_DIR" in *[!A-Za-z0-9./_-]*) local _orphan="$SCRATCH_DIR" SCRATCH_DIR="" rm -rf -- "$_orphan" 2>/dev/null || true die "Unsafe scratch path '$_orphan' (contains characters outside [A-Za-z0-9./_-]). TMPDIR='$TMPDIR' produced this path; set TMPDIR to a path of safe characters and re-run." ;; esac if [ "${_INVOKED_BY_WRAPPER:-}" = "1" ] && [ -n "${_LEAF_COUNTS_DIR:-}" ] \ && [ -d "$_LEAF_COUNTS_DIR" ]; then printf '%s\n' "$SCRATCH_DIR" >> "$_LEAF_COUNTS_DIR/leaf_scratch_dirs" 2>/dev/null || true fi } track_output_path() { OUTPUT_PATHS_TO_CLEAN+=( "$1" ); } _untrack_output_path() { local _target="$1" _cp _new=() _removed=0 for _cp in "${OUTPUT_PATHS_TO_CLEAN[@]+"${OUTPUT_PATHS_TO_CLEAN[@]}"}"; do if [ "$_removed" -eq 0 ] && [ "$_cp" = "$_target" ]; then _removed=1 continue fi _new+=( "$_cp" ) done OUTPUT_PATHS_TO_CLEAN=( "${_new[@]+"${_new[@]}"}" ) } mark_success() { SUCCESS_FLAG="yes"; } _md5_hex() { md5 -q -- "$1" 2>/dev/null; } _create_output_dir() { local _path="$1" _ctx="${2:-}" track_output_path "$_path" mkdir -p "$_path" || die "Cannot create $_path${_ctx:+ ($_ctx)}" } is_macos_junk_path() { case "$1" in .DS_Store|*/.DS_Store) return 0 ;; .localized|*/.localized) return 0 ;; ._*|*/._*) return 0 ;; .AppleDouble|.AppleDouble/*|*/.AppleDouble|*/.AppleDouble/*) return 0 ;; .Spotlight-V100|.Spotlight-V100/*|*/.Spotlight-V100|*/.Spotlight-V100/*) return 0 ;; .fseventsd|.fseventsd/*|*/.fseventsd|*/.fseventsd/*) return 0 ;; .Trashes|.Trashes/*|*/.Trashes|*/.Trashes/*) return 0 ;; .TemporaryItems|.TemporaryItems/*|*/.TemporaryItems|*/.TemporaryItems/*) return 0 ;; .DocumentRevisions-V100|.DocumentRevisions-V100/*|*/.DocumentRevisions-V100|*/.DocumentRevisions-V100/*) return 0 ;; __MACOSX|__MACOSX/*|*/__MACOSX|*/__MACOSX/*) return 0 ;; .VolumeIcon.icns|*/.VolumeIcon.icns) return 0 ;; .com.apple.timemachine|*/.com.apple.timemachine) return 0 ;; .com.apple.timemachine.*|*/.com.apple.timemachine.*) return 0 ;; .apdisk|*/.apdisk) return 0 ;; esac return 1 } is_macos_alias() { local f="$1" size magic hex _is_network_mount_for "$f" && return 1 size="$(stat -f %z -- "$f" 2>/dev/null)" case "$size" in ''|*[!0-9]*) return 1 ;; esac [ "$size" -lt 200 ] && return 1 [ "$size" -gt 32768 ] && return 1 magic="$(LC_ALL=C od -An -tx1 -N 4 -- "$f" 2>/dev/null)" magic="${magic// /}" magic="${magic//$'\n'/}" case "$magic" in 626f6f6b) return 0 ;; esac hex="$(xattr -px com.apple.FinderInfo -- "$f" 2>/dev/null | head -1)" hex="${hex// /}" hex="${hex:0:8}" hex="${hex//A/a}" hex="${hex//B/b}" hex="${hex//C/c}" hex="${hex//D/d}" hex="${hex//E/e}" hex="${hex//F/f}" case "$hex" in 616c6973*|616c7973*) return 0 ;; esac return 1 } should_skip_copy() { local f="$1" rel="${2:-$1}" [ -L "$f" ] && return 0 is_macos_junk_path "$rel" && return 0 [ "${SKIP_ALIASES:-0}" = "1" ] && is_macos_alias "$f" && return 0 return 1 } is_icloud_stub() { case "${1##*/}" in .?*.icloud) return 0 ;; esac return 1 } _strip_icloud_stub_name() { local s="$1" s="${s%.icloud}" s="${s#.}" if [ -n "${2:-}" ]; then printf -v "$2" '%s' "$s"; return 0; fi printf '%s' "$s" } _expand_tilde() { local p="$1" case "$p" in [~]|[~]/*) printf '%s' "$HOME${p#~}" ;; *) printf '%s' "$p" ;; esac } _is_gzipped() { local mb mb="$(LC_ALL=C head -c 2 -- "$1" 2>/dev/null | xxd -p 2>/dev/null)" [ "$mb" = "1f8b" ] } _icloud_stub_sibling_for() { local p="${1:-}" [ -z "$p" ] && return 1 local _bn="${p##*/}" [ -z "$_bn" ] && return 1 local _dir="" case "$p" in */*) _dir="${p%/*}/" ;; esac local _stub="${_dir}.${_bn}.icloud" [ -f "$_stub" ] && { printf '%s' "$_stub"; return 0; } return 1 } is_dataless() { local _fl _fl="$(stat -f '%f' -- "${1:-}" 2>/dev/null)" || return 1 case "$_fl" in ''|*[!0-9]*) return 1 ;; esac [ "$(( 10#$_fl & 1073741824 ))" -ne 0 ] } _materialized_copy_incomplete() { local _exp_size="$1" _dst="$2" _dst_size _dst_size="$(stat -f '%z' -- "$_dst" 2>/dev/null)" [ -n "$_dst_size" ] || return 0 if [ -n "$_exp_size" ] && [ "$_exp_size" != "$_dst_size" ]; then return 0 fi is_dataless "$_dst" && return 0 return 1 } _emit_html_index() { local _out="$1" _label="$2" local _tmp _rows_rc=1 _group_rc _tmp="$(mktemp "$_out.tmp.XXXXXX" 2>/dev/null)" || { log_warn "Could not stage $_label — index not written." return 1 } track_output_path "$_tmp" { printf '\n\n\n' printf '\n' printf '%s\n' "${_idx_title:-Index}" printf '\n\n\n' printf '

%s

\n' "${_idx_h1:-${_idx_title:-Index}}" printf '\n%s\n\n' "${_idx_thead:-}" "$_idx_rows_fn" _rows_rc=$? printf '\n
\n' printf '
Exported %s.
\n' "$(_utc_now_iso8601)" printf '\n\n' } > "$_tmp" _group_rc=$? { [ "$_rows_rc" -eq 0 ] && [ "$_group_rc" -eq 0 ]; } _atomic_commit_tmp $? "$_tmp" "$_out" "$_label" _untrack_output_path "$_tmp" } _epoch_to_utc_iso_into() { local _e="${1:-}" _out="${2:-}" [ -n "$_out" ] || return 0 case "$_e" in ''|*[!0-9]*) printf -v "$_out" '%s' ''; return 0 ;; esac _e=$((10#$_e)) local _days=$((_e / 86400)) _sod=$((_e % 86400)) local _z=$((_days + 719468)) local _era=$((_z / 146097)) local _doe=$((_z - _era * 146097)) local _yoe=$(( (_doe - _doe/1460 + _doe/36524 - _doe/146096) / 365 )) local _y=$((_yoe + _era * 400)) local _doy=$(( _doe - (365*_yoe + _yoe/4 - _yoe/100) )) local _mp=$(( (5*_doy + 2) / 153 )) local _d=$(( _doy - (153*_mp + 2)/5 + 1 )) local _m if [ "$_mp" -lt 10 ]; then _m=$((_mp + 3)); else _m=$((_mp - 9)); fi [ "$_m" -le 2 ] && _y=$((_y + 1)) printf -v "$_out" '%04d-%02d-%02dT%02d:%02d:%02dZ' \ "$_y" "$_m" "$_d" "$((_sod / 3600))" "$(((_sod % 3600) / 60))" "$((_sod % 60))" return 0 } _var_key_into() { local _s="${1:-}" _out="${2:-}" _i=0 _len=${#1} _c _o _h=2166136261 [ -n "$_out" ] || return 0 while [ "$_i" -lt "$_len" ]; do _c="${_s:$_i:1}" printf -v _o '%d' "'$_c" 2>/dev/null || _o=0 _h=$(( (_h ^ (_o & 255)) & 4294967295 )) _h=$(( (_h * 16777619) & 4294967295 )) _i=$((_i + 1)) done printf -v "$_out" '%08x' "$_h" return 0 } _record_icloud_stub() { local _cf="${1:-}" _label="${2:-}" _rel="${3:-}" _orig [ -n "$_cf" ] || return 0 local _dir _strip_icloud_stub_name "${_rel##*/}" _orig tsv_escape "$_orig" _orig tsv_escape "$_label" _label case "$_rel" in */*) tsv_escape "${_rel%/*}" _dir printf '%s\t%s/%s\n' "$_label" "$_dir" "$_orig" >> "$_cf" ;; *) printf '%s\t%s\n' "$_label" "$_orig" >> "$_cf" ;; esac return 0 } _ditto_dl_retry() { local _s="$1" _d="$2" _wdl="${3:-0}" _e _r _try=0 _max case "${ICLOUD_DOWNLOAD_RETRIES:-}" in ''|*[!0-9]*) _max=2 ;; *) _max=$((10#${ICLOUD_DOWNLOAD_RETRIES})); [ "$_max" -gt 10 ] && _max=2 ;; esac while :; do _e="$(ditto -- "$_s" "$_d" 2>&1 >/dev/null)"; _r=$? [ "$_r" -eq 0 ] && break [ "$_wdl" = "1" ] || break case "$_e" in *"Permission denied"*|*"Operation not permitted"*) break ;; esac [ "$_try" -ge "$_max" ] && break _try=$((_try + 1)) rm -f -- "$_d" 2>/dev/null sleep "$(( _try * 2 ))" 2>/dev/null || true done printf '%s' "$_e" return "$_r" } _ditto_verified() { local _s="$1" _d="$2" _rt="${3:-0}" _ev="${4:-}" _st _sz _fl _wdl=0 _e _r _st="$(stat -f '%z %f' -- "$_s" 2>/dev/null)" _sz="${_st%% *}"; _fl="${_st##* }" case "$_sz" in ''|*[!0-9]*) _sz="" ;; esac case "$_fl" in ''|*[!0-9]*) ;; *) [ "$(( 10#$_fl & 1073741824 ))" -ne 0 ] && _wdl=1 ;; esac if [ "$_rt" = "1" ]; then _e="$(_ditto_dl_retry "$_s" "$_d" "$_wdl")"; _r=$? else _e="$(ditto -- "$_s" "$_d" 2>&1 >/dev/null)"; _r=$? fi [ -n "$_ev" ] && printf -v "$_ev" '%s' "$_e" if [ "$_r" -ne 0 ]; then rm -f -- "$_d" 2>/dev/null return 1 fi if [ "$_wdl" = "1" ] && _materialized_copy_incomplete "$_sz" "$_d"; then rm -f -- "$_d" 2>/dev/null return 2 fi return 0 } _pluralize() { local n="$1" singular="$2" plural="${3:-${2}s}" n="${n#"${n%%[![:space:]]*}"}" case "$n" in ''|*[!0-9]*) n=0 ;; esac n=$((10#${n:-0})) if [ "$n" = "1" ]; then printf '%s' "$singular"; else printf '%s' "$plural"; fi } _sql_cd_to_unix() { local _col="$1" printf '(CASE WHEN %s > 4000000000 THEN %s/1e9 + 978307200 ELSE %s + 978307200 END)' \ "$_col" "$_col" "$_col" } _count_lines() { local _cl_file="$1" _cl_default="${2:-0}" _cl_n _cl_n="$(LC_ALL=C awk 'END{print NR}' "$_cl_file" 2>/dev/null)" case "$_cl_n" in ''|*[!0-9]*) printf '%s\n' "$_cl_default" ;; *) printf '%s\n' "$_cl_n" ;; esac } _count_csv_rows() { local _ccr_file="$1" _ccr_default="${2:-0}" _ccr_n _ccr_n="$(LC_ALL=C awk 'END{print NR-1}' "$_ccr_file" 2>/dev/null)" case "$_ccr_n" in ''|*[!0-9]*) printf '%s\n' "$_ccr_default" ;; *) printf '%s\n' "$_ccr_n" ;; esac } _to_int() { local _ti_v="$1" _ti_default="${2:-0}" case "$_ti_v" in ''|*[!0-9]*) printf '%s\n' "$_ti_default" ;; *) printf '%s\n' "$_ti_v" ;; esac } _clamp_env_int() { local _name="$1" _default="$2" _max="${3:-}" local _v="${!_name:-}" local _clamped_new="$_default" case "$_v" in ''|*[!0-9]*) _clamped_new="$_default" ;; 0|0[0-9]*) _clamped_new="$_default" ;; *) if ! _is_int64_safe "$_v"; then _clamped_new="$_default" elif [ -n "$_max" ] && [ "$_v" -gt "$_max" ]; then _clamped_new="$_default" else _clamped_new="$_v" fi ;; esac printf -v "$_name" '%s' "$_clamped_new" printf '%s' "$_clamped_new" } _dir_size_human() { local path="$1" _s [ -d "$path" ] || { printf '0B'; return; } _s="$(LC_ALL=C du -sh "$path" 2>/dev/null | LC_ALL=C awk 'NR==1 { print $1; exit }')" case "$_s" in ''|*[!0-9.BKMGTP]*|[!0-9]*) _s=0B ;; esac case "$_s" in *[BKMGTP]) ;; *) _s="${_s}B" ;; esac printf '%s' "${_s:-0B}" } _df_mountpoint_of() { df -P "${1:-}" 2>/dev/null | LC_ALL=C awk ' NR==2 && NF>=6 { m=$6; for (i=7;i<=NF;i++) m=m" "$i; print m }' } _mount_fstype_of() { LC_ALL=C mount 2>/dev/null | mp="$1" LC_ALL=C awk ' BEGIN { mp = ENVIRON["mp"] } { needle = " on " mp " (" i = index($0, needle) if (i == 0) next fs = substr($0, i + length(needle)) k = index(fs, ","); if (k > 0) fs = substr(fs, 1, k - 1) l = index(fs, ")"); if (l > 0) fs = substr(fs, 1, l - 1) print tolower(fs); exit }' } _target_filesystem_type() { local _path="${1:-}" _mp _fs [ -e "$_path" ] || return 0 _mp="$(_df_mountpoint_of "$_path")" [ -z "$_mp" ] && return 0 _fs="$(_mount_fstype_of "$_mp")" printf '%s' "$_fs" } _is_network_mount_for() { local _p="${1:-}" _mp _key _cached _cached_mp _fs [ -n "$_p" ] || return 1 _mp="$(_df_mountpoint_of "$_p")" [ -n "$_mp" ] || return 1 _mp="${_mp%/}" [ -n "$_mp" ] || _mp="/" _key="$(printf '%s' "$_mp" | LC_ALL=C tr -c 'A-Za-z0-9_' '_')" [ -n "$_key" ] || return 1 local _mp_ref="_NETMOUNT_CACHE_MP_$_key" local _fs_ref="_NETMOUNT_CACHE_FS_$_key" _cached_mp="${!_mp_ref:-}" _cached="${!_fs_ref:-}" if [ -z "$_cached" ] || [ "$_cached_mp" != "$_mp" ]; then _fs="$(_mount_fstype_of "$_mp")" case "$_fs" in nfs|smbfs|afpfs|webdav|webdavfs|ftp|cifs) _cached=n ;; *) _cached=l ;; esac printf -v "$_mp_ref" '%s' "$_mp" printf -v "$_fs_ref" '%s' "$_cached" fi [ "$_cached" = "n" ] } warn_if_fragile_filesystem() { local _fs _fs="$(_target_filesystem_type "${1:-}")" case "$_fs" in exfat|msdos|ntfs) log_warn "Output volume is $_fs — case-insensitive (filenames differing only in case collide), no POSIX permissions, no extended attributes; some exported metadata cannot be preserved on this volume." ;; macfuse*|osxfuse*|tuxera_*|paragon_*|fuse|fuse-*|fuse_*|fusefs*|fuse[0-9]*) log_warn "Output volume is a FUSE / third-party driver ($_fs) — case-insensitivity and extended-attribute support depend on the driver. Some exported metadata may not preserve." ;; smbfs|nfs|webdav|webdavfs|cifs|afpfs|ftp) log_warn "Output volume is a network filesystem ($_fs) — permission and metadata fidelity depend on the server." ;; esac return 0 } _fmt_duration() { local s="$1" case "$s" in ''|*[!0-9]*) s=0 ;; esac if [ "$s" -lt 60 ]; then printf '%ds' "$s" elif [ "$s" -lt 3600 ]; then printf '%dm %ds' "$((s / 60))" "$((s % 60))" else printf '%dh %dm' "$((s / 3600))" "$(((s % 3600) / 60))" fi } _fmt_relative_mtime() { local _p="${1:-}" [ -e "$_p" ] || { printf ''; return; } local _mtime _now _delta _mtime="$(stat -f '%m' -- "$_p" 2>/dev/null)" case "$_mtime" in ''|*[!0-9]*) printf ''; return ;; esac _now="$(date +%s 2>/dev/null)" case "$_now" in ''|*[!0-9]*) printf ''; return ;; esac _delta=$((_now - _mtime)) [ "$_delta" -lt 0 ] && _delta=0 if [ "$_delta" -lt 60 ]; then printf 'just now' elif [ "$_delta" -lt 3600 ]; then printf '%dm ago' "$((_delta / 60))" elif [ "$_delta" -lt 86400 ]; then printf '%dh ago' "$((_delta / 3600))" elif [ "$_delta" -lt 2592000 ]; then printf '%dd ago' "$((_delta / 86400))" elif [ "$_delta" -lt 31536000 ];then printf '%dmo ago' "$((_delta / 2592000))" else printf '%dy ago' "$((_delta / 31536000))" fi } _progress_tick() { if [ -z "${_pt_last+x}" ] || [ -z "${_pt_inline+x}" ]; then if [ -z "${_PROGRESS_TICK_WARNED:-}" ]; then log_warn "_progress_tick called without caller-side 'local _pt_last=0 _pt_inline=0' — progress display may misbehave; this is a leaf-code bug." _PROGRESS_TICK_WARNED=1 fi _pt_last="${_pt_last:-0}" _pt_inline="${_pt_inline:-0}" fi if [ "${_pt_last:-0}" -eq 0 ]; then _pt_last=$((SECONDS + 1)) return 0 fi [ "$(( SECONDS - _pt_last ))" -ge 5 ] || return 0 _pt_last=$SECONDS mark_activity local _d="${1:-0}" _t="${2:-0}" _n="${3:-items}" _msg case "$_d" in ''|*[!0-9]*) _d=0 ;; esac case "$_t" in ''|*[!0-9]*) _t=0 ;; esac if [ "$_t" -gt 0 ]; then _msg="$_d/$_t $_n ($(( _d * 100 / _t ))%)" else _msg="$_d $_n" fi if { [ -t 1 ] || [ "${_DOWNPOUR_FORCE_TTY_OUT:-0}" = "1" ]; }; then printf '\r %s\033[K' "$_msg" _pt_inline=1 else log_info "$_msg" fi } _progress_end() { [ "${_pt_inline:-0}" = "1" ] || return 0 { [ -t 1 ] || [ "${_DOWNPOUR_FORCE_TTY_OUT:-0}" = "1" ]; } && printf '\n' _pt_inline=0 _pt_last=0 return 0 } csv_field() { local s="$1" if [ "${#s}" -gt 1048576 ] && [ -z "${_CSV_OVERSIZE_WARNED:-}" ]; then local _bytes _bytes=$(printf '%s' "$s" | LC_ALL=C wc -c | LC_ALL=C tr -d ' ') if [ "${_bytes:-0}" -gt 1048576 ]; then log_warn "csv_field: emitting an oversized field (${_bytes} bytes) — typical RFC 4180 consumers cannot handle values this large; check the caller for an accidental BLOB pass-through." _CSV_OVERSIZE_WARNED=1 fi fi s="${s//$'\r'/}" case "$s" in [=+@-]*|$'\t'*) s="'$s" ;; esac case "$s" in *,*|*'"'*|*$'\n'*) s="${s//\"/\"\"}"; s="${s//$'\n'/ }" s="\"$s\"" ;; esac if [ -n "${2:-}" ]; then printf -v "$2" '%s' "$s"; return 0; fi printf '%s' "$s" } _sanitize_name() { local s="$1" s="${s//\//_}"; s="${s//\\/_}"; s="${s//:/_}" s="${s//\"/_}"; s="${s//\?/_}"; s="${s//\*/_}" s="${s//|/_}"; s="${s///_}" s="${s//$'\t'/ }"; s="${s//$'\n'/ }"; s="${s//$'\r'/ }" s="${s//[[:cntrl:]]/}" while [ "${s# }" != "$s" ] || [ "${s#.}" != "$s" ]; do s="${s# }"; s="${s#.}"; done while [ "${s% }" != "$s" ] || [ "${s%.}" != "$s" ]; do s="${s% }"; s="${s%.}"; done if [ "${#s}" -gt 50 ]; then s="$(printf '%s' "$s" | LC_ALL=C awk '{ s = $0 if (length(s) > 200) { s = substr(s, 1, 200) last_lead = 0 for (i = length(s); i > 0; i--) { c = substr(s, i, 1) if (c ~ /[\200-\277]/) continue if (c ~ /[\300-\377]/) last_lead = i break } if (last_lead > 0) { lead = substr(s, last_lead, 1) expected = 0 if (lead ~ /[\300-\337]/) expected = 1 else if (lead ~ /[\340-\357]/) expected = 2 else if (lead ~ /[\360-\367]/) expected = 3 actual = length(s) - last_lead if (actual < expected) { s = substr(s, 1, last_lead - 1) } } } print s }')" fi while [ "${s% }" != "$s" ] || [ "${s%.}" != "$s" ]; do s="${s% }"; s="${s%.}"; done [ -z "$s" ] && s="Untitled" printf '%s' "$s" } _snapshot_db_cleanup_partial() { local _snap="$1" _why="$2" if ! rm -f -- "$_snap" "$_snap-wal" "$_snap-shm" 2>/dev/null; then if [ -f "$_snap" ]; then mv -f -- "$_snap" "$_snap.partial.$$" 2>/dev/null || true fi log_warn "snapshot_db: failed to clean up partial snapshot family at $_snap ($_why; manual cleanup may be needed)" fi } snapshot_db() { local src="$1" src_md5 snap _src_bytes _need_kb _free_kb _src_mb local _headroom_kb=102400 # 100 MB local _progress_min_mb=50 [ -f "$src" ] || return 1 { : < "$src"; } 2>/dev/null || return 2 ensure_scratch _src_bytes="$(stat -f %z -- "$src" 2>/dev/null || printf 0)" case "$_src_bytes" in ''|*[!0-9]*) _src_bytes=0 ;; esac local _sb _sbz for _sb in "$src-wal" "$src-shm"; do if [ -f "$_sb" ]; then _sbz="$(stat -f %z -- "$_sb" 2>/dev/null || printf 0)" case "$_sbz" in ''|*[!0-9]*) _sbz=0 ;; esac _src_bytes=$((_src_bytes + _sbz)) fi done _need_kb=$((_src_bytes / 1024 + _headroom_kb)) local _df_out _df_out="$(df -P -k "$SCRATCH_DIR" 2>/dev/null)" _free_kb="$(printf '%s\n' "$_df_out" | LC_ALL=C awk 'NR==2 && NF >= 4 {print $4+0}')" if [ -n "$_free_kb" ]; then if [ "$_free_kb" -eq 0 ]; then printf ' error: scratch volume reports 0 MB free; cannot snapshot %s\n' "$src" >&2 return 5 elif [ "$_free_kb" -lt "$_need_kb" ]; then printf ' error: scratch volume has %d MB free; need ~%d MB to snapshot %s\n' \ "$((_free_kb / 1024))" "$((_need_kb / 1024))" "$src" >&2 return 5 fi fi local _src_base _src_base="$(printf '%s' "${src##*/}" | LC_ALL=C tr -c 'A-Za-z0-9._-' '_')" src_md5="$(printf '%s' "$src" | md5 -q 2>/dev/null)" case "${src_md5:-}" in ''|*[!0-9a-f]*) log_err "snapshot_db: md5 output is not a valid hex digest for '$src' (got: ${src_md5:-}; md5 missing, swapped for a different tool, or broken)" return 6 ;; esac [ "${#src_md5}" -eq 32 ] || { log_err "snapshot_db: md5 output is not 32 chars long for '$src' (got length ${#src_md5}; md5 output format unexpected)" return 6 } snap="$SCRATCH_DIR/snap_${_src_base}_$src_md5.db" local _snap_just_created=0 if [ ! -f "$snap" ]; then _snap_just_created=1 _src_mb=$((_src_bytes / 1048576)) if [ "$_src_mb" -ge "$_progress_min_mb" ]; then printf ' Snapshotting %s (%d MB)...\n' "${src##*/}" "$_src_mb" >&2 fi if ! cp -f -- "$src" "$snap" 2>/dev/null; then rm -f -- "$snap" 2>/dev/null return 3 fi if [ -f "$src-wal" ] && ! cp -f -- "$src-wal" "$snap-wal" 2>/dev/null; then _snapshot_db_cleanup_partial "$snap" "WAL copy failed" return 3 fi if [ -f "$src-shm" ] && ! cp -f -- "$src-shm" "$snap-shm" 2>/dev/null; then _snapshot_db_cleanup_partial "$snap" "SHM copy failed" return 3 fi if [ -f "$snap-wal" ]; then sqlite3 "$snap" "PRAGMA wal_checkpoint(TRUNCATE);" >/dev/null 2>&1 || true fi fi local _qpid _qrc=0 _qwatchdog trap '' USR1 sqlite3 "$snap" "SELECT 1;" >/dev/null 2>&1 & _qpid=$! ( exec >/dev/null 2>&1 /dev/null || exit 0 kill -TERM "$_qpid" 2>/dev/null sleep 1 kill -0 "$_qpid" 2>/dev/null && kill -KILL "$_qpid" 2>/dev/null ) /dev/null 2>&1 & _qwatchdog=$! wait "$_qpid" 2>/dev/null; _qrc=$? pkill -KILL -P "$_qwatchdog" 2>/dev/null || true kill -KILL "$_qwatchdog" 2>/dev/null wait "$_qwatchdog" 2>/dev/null || true trap _skip_handler USR1 2>/dev/null || true if [ "$_qrc" -ne 0 ]; then rm -f -- "$snap" "$snap-wal" "$snap-shm" 2>/dev/null return 4 fi if [ "$_snap_just_created" -eq 1 ] && [ -n "$_src_mb" ] && [ "$_src_mb" -ge "$_progress_min_mb" ]; then local _wal_marker="" [ -f "$snap-wal" ] && _wal_marker=", WAL truncated" printf ' Snapshot OK: %s (%d MB%s, queryable)\n' \ "${src##*/}" "$_src_mb" "$_wal_marker" >&2 fi printf '%s' "$snap" } snapshot_db_or_die() { local _src="$1" _feature="$2" _snap _rc _snap="$(snapshot_db "$_src")" _rc=$? case "$_rc" in 0) printf '%s' "$_snap" ;; 1) die "${_feature} source database is missing: $_src" ;; 2) die "${_feature} source database is unreadable: $_src — Full Disk Access required?" ;; 3) die "${_feature} snapshot copy failed: $_src — scratch volume full or permission denied?" ;; 4) die "${_feature} source database is corrupt or unqueryable: $_src — try closing the owning app and retrying" ;; 5) die "${_feature} snapshot needs more scratch space than is available; free up the scratch volume ($TMPDIR) or set TMPDIR to a larger volume" ;; 6) die "${_feature} snapshot failed: $_src — the md5 hashing tool is missing or broken (see the error above)" ;; *) die "${_feature} snapshot failed (rc=$_rc): $_src" ;; esac } require_db_tables() { local _snap="$1" _feature="$2" shift 2 local _t _missing="" for _t in "$@"; do case "$_t" in ''|*[!A-Za-z0-9_]*) die "$_feature: invalid table name in schema check: '$_t'" ;; esac sqlite3 -readonly "$_snap" "SELECT 1 FROM \"$_t\" LIMIT 1;" \ >/dev/null 2>&1 || _missing="${_missing:+$_missing }$_t" done [ -z "$_missing" ] && return 0 local _present _present="$(sqlite3 -readonly "$_snap" \ "SELECT GROUP_CONCAT(name, ' ') FROM sqlite_master \ WHERE type='table';" 2>/dev/null)" die "$_feature: database schema unrecognised — expected table(s) not present: $_missing Apple may have changed the schema in a macOS update; please report this. Tables in this database: ${_present:-(none)}" } _col_present() { local snap="$1" table="$2" col="$3" [ -n "$snap" ] && [ -n "$table" ] && [ -n "$col" ] || return 1 case "$table" in *[!A-Za-z0-9_]*) return 1 ;; esac case "$col" in *[!A-Za-z0-9_]*) return 1 ;; esac local _cp_key _cp_var _cp_list _cp_key="${snap}__${table}" _cp_key="${_cp_key//[^A-Za-z0-9_]/_}" _cp_var="_col_present_cache_$_cp_key" _cp_list="${!_cp_var:-}" if [ -z "$_cp_list" ]; then local _cp_raw _cp_rc _cp_raw="$(sqlite3 -readonly "$snap" \ "SELECT GROUP_CONCAT(name, ',') FROM pragma_table_info('$table');" \ 2>/dev/null)" _cp_rc=$? if [ "$_cp_rc" -ne 0 ]; then _probe_miss error "$table" "$col" return 1 fi _cp_list=",$_cp_raw," printf -v "$_cp_var" '%s' "$_cp_list" fi case "$_cp_list" in *,"$col",*) return 0 ;; esac _probe_miss absent "$table" "$col" return 1 } _PROBE_KNOWN_ABSENT=" ZJOURNALENTRYMO.ZPLACENAME ZJOURNALENTRYMO.ZSTATEOFMIND ZJOURNALENTRYMO.ZTIMEZONE ZJOURNALENTRYMO.ZWEATHERCONDITIONCODE ZJOURNALENTRYMO.ZWEATHERHIGHTEMPERATURE ZJOURNALENTRYMO.ZWEATHERHUMIDITY ZJOURNALENTRYMO.ZWEATHERLOWTEMPERATURE ZCLOUDRECORDING.ZISFAVORITE ZCLOUDRECORDING.ZLOCALDELETEDDATE ZCLOUDRECORDING.ZMODIFIEDDATE ZCLOUDRECORDING.ZRECORDING_PARENT ZCLOUDRECORDING.ZTRANSCRIPTION ZCLOUDRECORDING.ZTRIMENDOFFSET ZCLOUDRECORDING.ZTRIMSTARTOFFSET ZFOLDER.ZPARENTFOLDER ZFAVORITEITEM.ZLASTVISITEDTIME ZFAVORITEITEM.ZPHONENUMBER ZFAVORITEITEM.ZURL ZTEXTREPLACEMENTENTRY.ZSYNCSTATE ZTEXTREPLACEMENTENTRY.ZWASDELETEDFROMICLOUD ZSHARE.ZCLOUDOWNERFIRSTNAME ZSHARE.ZCLOUDOWNERHASHEDPERSONID ZSHARE.ZCLOUDOWNERLASTNAME ZREMCDREMINDER.ZRECURRENCERULE " _probe_store() { local _s; _s="$(_report_store)" || return 1 printf '%s' "$_s.probes" } _probe_miss() { [ "${_PROBE_SUPPRESS:-0}" = "1" ] && return 0 local _pk if [ "${1:-absent}" = "absent" ] && [ -n "${3:-}" ]; then _pk=$'\n'"${2:-}.${3}"$'\n' case "${_PROBE_KNOWN_ABSENT:-}" in *"$_pk"*) return 0 ;; esac fi local _st; _st="$(_probe_store)" || return 0 printf '%s\t%s\t%s\t%s\n' \ "$(_report_flat "$(_report_subject "${_LEAF_CATEGORY_DIR:-}")")" \ "${1:-absent}" "${2:-}" "${3:-}" >> "$_st" 2>/dev/null || true return 0 } _probe_disclose() { local _dir="${1:-${_LEAF_CATEGORY_DIR:-}}" _st _subj _kind _list _n _st="$(_probe_store)" || return 0 [ -s "$_st" ] || return 0 _subj="$(_report_flat "$(_report_subject "$_dir")")" [ -n "$_subj" ] || return 0 for _kind in absent error; do _list="$(LC_ALL=C awk -F'\t' -v s="$_subj" -v k="$_kind" \ '$1==s && $2==k {print $3 "." $4}' "$_st" 2>/dev/null \ | LC_ALL=C sort -u)" [ -n "$_list" ] || continue _n="$(printf '%s\n' "$_list" | LC_ALL=C awk 'END{print NR}')" if [ "$_kind" = "absent" ]; then log_info "Schema probes: $_n optional $(_pluralize "$_n" column) absent on this macOS — the fields they carry are blank or omitted in this export$(_disclosure_pointer)." report_add schema "$_subj" "$_n" "" "$_list" else log_info "Schema probes: $_n column $(_pluralize "$_n" check) could not be completed (database busy or errored) — the features they gate were disabled WITHOUT confirming the columns are absent$(_disclosure_pointer)." report_incomplete "$_subj" "schema probes could not be completed" \ "$_n column $(_pluralize "$_n" check) failed to run (the database was locked or returned an error), so downpour disabled the features they gate without being able to confirm those columns are really missing. Quit the source app and re-run; if the columns do exist, the affected content will be captured." \ "$_list" fi done : > "$_st" 2>/dev/null || true return 0 } _col_in_list() { printf '%s\n' "$1" | LC_ALL=C grep -qFx -- "$2" } _pick_first_col() { local _snap="$1" _table="$2" shift 2 local _candidate _pfc_prev="${_PROBE_SUPPRESS:-0}" _PROBE_SUPPRESS=1 for _candidate in "$@"; do if _col_present "$_snap" "$_table" "$_candidate"; then _PROBE_SUPPRESS="$_pfc_prev" printf '%s' "$_candidate" return 0 fi done _PROBE_SUPPRESS="$_pfc_prev" _probe_miss absent "$_table" "$1" return 1 } _resolve_entity_number() { case "$2" in ''|*[!A-Za-z0-9_]*) return 1 ;; esac local _ent _ent="$(sqlite3 -readonly "$1" \ "SELECT Z_ENT FROM Z_PRIMARYKEY WHERE Z_NAME='$2' LIMIT 1;" \ 2>/dev/null)" case "$_ent" in ''|*[!0-9]*) return 1 ;; esac printf '%s' "$_ent" } _discover_z_join() { case "$2" in ''|*[!A-Za-z0-9_]*) return 1 ;; esac local _matches _n _t _matches="$(sqlite3 -readonly "$1" \ "SELECT name FROM sqlite_master WHERE type='table' AND name GLOB 'Z_[0-9]*$2' ORDER BY name;" 2>/dev/null)" _n="$(printf '%s\n' "$_matches" | grep -c .)" case "${_n:-0}" in ''|*[!0-9]*) _n=0 ;; esac [ "$_n" -eq 0 ] && return 1 [ "$_n" -gt 1 ] && return 2 _t="$_matches" case "$_t" in Z_*) : ;; *) return 1 ;; esac case "$_t" in *[!A-Za-z0-9_]*) return 1 ;; esac printf '%s' "$_t" } report_no_source() { local feature="$1" reason="$2" path="${3:-}" icloud_app="${4:-}" log_warn "$feature $reason on this Mac — nothing to export" [ -n "$path" ] && log_info "(Looked for: $(tsv_escape "$path"))" _icloud_sync_hint "$icloud_app" _flag_no_content mark_success print_summary exit 0 } report_empty_source() { local items="$1" icloud_app="${2:-}" log_warn "No $items to export — nothing to do." _icloud_sync_hint "$icloud_app" _flag_no_content mark_success print_summary exit 0 } _flag_no_content() { [ -n "${_LEAF_COUNTS_DIR:-}" ] && [ -d "${_LEAF_COUNTS_DIR:-}" ] \ && [ -w "${_LEAF_COUNTS_DIR:-}" ] || return 0 : > "$_LEAF_COUNTS_DIR/$_LEAF_COUNTS_ID.nocontent" 2>/dev/null || true } _icloud_sync_hint() { [ -n "${1:-}" ] || return 0 log_info "If $1 is in use on another Apple device, this may be an iCloud-sync gap — open $1 once to trigger sync, then re-run." } _url_encode_path() { _uep_in="$1" LC_ALL=C awk 'BEGIN{ for(i=0;i<256;i++) ord[sprintf("%c",i)]=i s=ENVIRON["_uep_in"]; out="" for(i=1;i<=length(s);i++){ c=substr(s,i,1) if (c ~ /[A-Za-z0-9._~\/-]/) out=out c else out=out sprintf("%%%02X", ord[c]) } print out }' } _bytes_human() { awk -v b="${1:-0}" 'BEGIN{ split("B KB MB GB TB", u); s=1 while (b>=1024 && s<5){ b=b/1024; s++ } if (s==1) printf "%d %s", b, u[s]; else printf "%.1f %s", b, u[s] }' } _optimize_storage_prose() { local _label="$1" _evicted="$2" _total="${3:-}" _recover="${4:-}" case "${_evicted:-0}" in ''|*[!0-9]*) return 0 ;; esac _is_int64_safe "$_evicted" || return 0 _evicted=$((10#${_evicted:-0})) [ "$_evicted" -eq 0 ] && return 0 local _msg="iCloud eviction (Optimize Mac Storage or app-equivalent) appears active for $_label — " if [ -n "$_total" ] && [ "$_total" != "0" ]; then case "$_total" in *[!0-9]*) _total="" ;; *) if _is_int64_safe "$_total"; then _total=$((10#$_total)); else _total=""; fi ;; esac fi if [ -n "$_total" ] && [ "$_total" -gt 0 ]; then _msg="$_msg$_evicted of $_total $(_pluralize "$_total" item) cloud-only and not downloaded." else _msg="$_msg$_evicted $(_pluralize "$_evicted" item) cloud-only and not downloaded." fi if [ -n "$_recover" ]; then _msg="$_msg To capture them: $_recover, then re-run." else _msg="$_msg To capture them: disable iCloud's Optimize Mac Storage for $_label and wait for sync, then re-run." fi _OPTIMIZE_STORAGE_PROSE="$_msg" return 0 } _disclose_optimize_storage() { _OPTIMIZE_STORAGE_PROSE="" _optimize_storage_prose "$@" || return 0 [ -n "$_OPTIMIZE_STORAGE_PROSE" ] || return 0 log_warn "$_OPTIMIZE_STORAGE_PROSE" } _disclosure_pointer() { [ "${_INVOKED_BY_WRAPPER:-0}" = "1" ] && return 0 printf ' — see _downpour_report.txt' } _photos_report_header() { local lib="${1//$'\n'/ }"; lib="${lib//$'\r'/ }" printf '# downpour-photos-report v1\n' printf '# library: %s\n' "$lib" printf '# generated: %s\n' "$(_utc_now_iso8601)" printf '# host: %s\n' "$(hostname -s 2>/dev/null || printf 'unknown')" printf '# hash: md5\n' } _doc_header() { local _title="${1//$'\n'/ }"; _title="${_title//$'\r'/ }"; shift printf '%s\n' "$_title" if [ $# -ge 2 ]; then printf '\n' local _k _v while [ $# -ge 2 ]; do _k="${1//$'\n'/ }"; _k="${_k//$'\r'/ }" _v="${2//$'\n'/ }"; _v="${_v//$'\r'/ }" printf '%-8s %s\n' "$_k:" "$_v" shift 2 done fi printf '\n' } _report_store() { if [ -n "${_REPORT_STORE:-}" ]; then printf '%s' "$_REPORT_STORE"; return 0; fi [ -n "${SCRATCH_DIR:-}" ] || return 1 _REPORT_STORE="$SCRATCH_DIR/_report_entries" printf '%s' "$_REPORT_STORE" } _report_store_init() { _report_store >/dev/null 2>&1 && return 0 SCRATCH_DIR="$(mktemp -d -t downpour 2>/dev/null || printf '')" if [ -n "$SCRATCH_DIR" ] && [ -d "$SCRATCH_DIR" ]; then case "$SCRATCH_DIR" in *[!A-Za-z0-9./_-]*) rm -rf -- "$SCRATCH_DIR" 2>/dev/null || true SCRATCH_DIR="" ;; esac else SCRATCH_DIR="" fi return 0 } _report_flat() { printf '%s' "${1:-}" | LC_ALL=C tr -d '\011\012\015\035\037\000'; } _report_subject() { local _s if [ -n "${_LEAF_REPORT_CATEGORY:-}" ]; then _s="$_LEAF_REPORT_CATEGORY" else local _d="${1:-}" _rel="${1:-}" if [ -n "${_EXPORT_ROOT:-}" ]; then case "$_d/" in "$_EXPORT_ROOT"/*) _rel="${_d#"$_EXPORT_ROOT"/}" ;; esac fi _rel="${_rel#/}"; _s="${_rel%%/*}" fi case "$_s" in 'Music Playlists') _s='Music playlists' ;; 'Voice Memos') _s='Voice memos' ;; 'Safari') _s='Safari bookmarks' ;; esac printf '%s' "$_s" } report_add() { local _sev="${1:-}" _subj="${2:-}" _title="${3:-}" _prose="${4:-}" _items_raw="${5:-}" local _store; _store="$(_report_store)" || return 0 local _items="" if [ -n "$_items_raw" ]; then _items="$(printf '%s' "$_items_raw" | LC_ALL=C tr -d '\015\035\037\000' | LC_ALL=C tr '\012\011' '\035 ')" _items="${_items%$'\035'}" fi printf '%s\037%s\037%s\037%s\037%s\n' \ "$_sev" "$(_report_flat "$_subj")" "$(_report_flat "$_title")" \ "$(_report_flat "$_prose")" "$_items" >> "$_store" 2>/dev/null || true } report_incomplete() { report_add incomplete "${1:-}" "${2:-}" "${3:-}" "${4:-}"; } report_blocked() { report_add blocked "${1:-}" "${2:-}" "${3:-}" "${4:-}"; } report_skipped() { report_add skipped "${1:-}" "${2:-}" "${3:-}" "${4:-}"; } # retained for API symmetry + pinned by tests/fault-injection.sh (no live caller; skipped is emitted via _write_skipped_report) report_review() { report_add review "${1:-}" "${2:-}" "${3:-}" "${4:-}"; } report_warning() { report_add warning "${1:-}" "${2:-}" "" ""; } # report_status() { report_add "${1:-}" "${2:-}" "" "" ""; } # _disclose_excluded() { local _short="${1:-}" _why="${2:-}" _subj [ -n "$_short" ] || return 0 if [ -n "$_why" ]; then log_info "Excluded $_short — $_why" else log_info "Excluded $_short" fi _subj="$(_report_subject "${_LEAF_CATEGORY_DIR:-}")" [ -n "$_subj" ] && report_add excluded "$_subj" "$_short" "" "" return 0 } _report_has_severity() { local _sev="${1:-}" _dir="${2:-}" _store _subj _us _store="$(_report_store)" || return 1 [ -f "$_store" ] || return 1 _subj="$(_report_flat "$(_report_subject "$_dir")")" _us=$'\037' s="$_sev" j="$_subj" awk -F"$_us" \ 'BEGIN { s=ENVIRON["s"]; j=ENVIRON["j"] } $1==s && $2==j { f=1; exit } END { exit !f }' "$_store" 2>/dev/null } report_have_incomplete() { _report_has_severity incomplete "${1:-}"; } report_have_skipped() { _report_has_severity skipped "${1:-}"; } _write_skipped_report() { local _dir="${1:-}" _feature="${2:-}" _ev="${3:-}" _tot="${4:-}" _rec="${5:-}" [ -n "$_dir" ] || { cat >/dev/null; return 0; } local _items; _items="$(cat)" local _prose="These were only in iCloud at export time (not on this Mac). Re-download them in Finder or the source app, then re-run." if [ -n "$_ev" ]; then _OPTIMIZE_STORAGE_PROSE="" _optimize_storage_prose "$_feature" "$_ev" "$_tot" "$_rec" [ -n "$_OPTIMIZE_STORAGE_PROSE" ] && _prose="$_OPTIMIZE_STORAGE_PROSE" fi report_add skipped "$(_report_subject "$_dir")" "$_feature — cloud-only items" \ "$_prose" "$_items" } note_incomplete() { local _dir="${1:-}" _what="${2:-}" _why="${3:-}" [ -n "$_dir" ] || return 0 local _subj; _subj="$(_report_subject "$_dir")" report_add incomplete "$_subj" "$_what" "$_why" "" } _incomplete_push() { local _ipn="$1"; shift case "$_ipn" in ''|*[!A-Za-z0-9_]*) die "_incomplete_push: invalid array name: '$_ipn'" ;; esac local _us; _us=$'\x1f' local _iw="${2:-}" eval "$_ipn+=( \"\$1\$_us\$_iw\" )" } _incomplete_drain() { local _iddir="${1:-}"; shift 2>/dev/null || true [ -n "$_iddir" ] || return 0 local _us _ide; _us=$'\x1f' for _ide in "$@"; do note_incomplete "$_iddir" "${_ide%%$_us*}" "${_ide#*$_us}" done } _summary_wrap_text() { IND="$1" _W="${_REPORT_WIDTH:-120}" LC_ALL=C awk ' BEGIN { ind = ENVIRON["IND"]; w = (ENVIRON["_W"] + 0) - length(ind); if (w < 20) w = 20 } { nn = split($0, word, " ") nl = 0; line = "" for (i = 1; i <= nn; i++) { if (word[i] == "") continue if (line == "") line = word[i] else if (length(line)+1+length(word[i]) > w) { lines[++nl] = line; line = word[i] } else line = line " " word[i] } if (line != "") lines[++nl] = line if (nl >= 2 && index(lines[nl], " ") == 0) { p = lines[nl-1]; sp = 0 for (k = length(p); k >= 1; k--) if (substr(p,k,1) == " ") { sp = k; break } if (sp > 0) { merged = substr(p, sp+1) " " lines[nl] if (length(merged) <= w) { lines[nl-1] = substr(p,1,sp-1); lines[nl] = merged } } } for (i = 1; i <= nl; i++) print ind lines[i] for (k in lines) delete lines[k] }' } _report_band() { local _label="${1:-}" _n="${2:-}" if [ -n "$_n" ]; then printf ' %s \302\267 %s\n' "$_label" "$_n" else printf ' %s\n' "$_label" fi } _render_entry() { local _rec="${1:-}" _title _prose _items _it _title="${_rec%%$'\037'*}"; _rec="${_rec#*$'\037'}" _prose="${_rec%%$'\037'*}"; _items="${_rec#*$'\037'}" printf ' \342\200\242 %s\n' "$_title" [ -n "$_prose" ] && printf '%s\n' "$_prose" | _summary_wrap_text ' ' if [ -n "$_items" ]; then local _cap="${DOWNPOUR_REPORT_MAX_ITEMS:-100}" _n=0 _tot case "$_cap" in ''|*[!0-9]*) _cap=100 ;; esac _tot="$(printf '%s\n' "$_items" | LC_ALL=C tr '\035' '\012' | LC_ALL=C grep -c . )" printf '%s\n' "$_items" | LC_ALL=C tr '\035' '\012' | while IFS= read -r _it; do [ -n "$_it" ] || continue _n=$((_n + 1)) if [ "$_cap" -gt 0 ] && [ "$_n" -gt "$_cap" ]; then printf ' \342\200\246 and %d more (set DOWNPOUR_REPORT_MAX_ITEMS=0 for the full list)\n' \ "$(( _tot - _cap ))" break fi printf ' %s\n' "$_it" done fi } _report_frame() { local _sub="${1:-}" _rule _rwid _i=0 _rwid="${_REPORT_WIDTH:-120}" _rule="$(while [ "$_i" -lt "$_rwid" ]; do printf '\342\225\220'; _i=$((_i + 1)); done)" printf '%s\n' "$_rule" printf ' downpour report\n' printf ' %s \302\267 %s\n' "$(date '+%Y-%m-%d %H:%M' 2>/dev/null)" "$_sub" printf '%s\n' "$_rule" printf '\n' } _lc() { printf '%s' "${1:-}" | LC_ALL=C tr '[:upper:]' '[:lower:]'; } _report_sort_names() { _SORTED_NAMES=() local _l while IFS= read -r _l; do [ -n "$_l" ] && _SORTED_NAMES+=( "$_l" ) done } _report_collect() { _store="$(_report_store 2>/dev/null || printf '')" _att=() _rev=() _warn=() _exp=() _des=() _emp=() _exc=() _sch=() _seq=0 if [ -n "$_store" ] && [ -f "$_store" ]; then while IFS=$'\037' read -r _sev _subj _title _prose _items; do case "$_sev" in incomplete|blocked|skipped|error) _seq=$((_seq + 1)) case "$_sev" in blocked) _rk=0 ;; incomplete) _rk=1 ;; skipped) _rk=2 ;; *) _rk=3 ;; esac _att+=( "$(_lc "$_subj")"$'\037'"$_rk"$'\037'"$(printf '%06d' "$_seq")"$'\037'"$_subj"$'\037'"$_title"$'\037'"$_prose"$'\037'"$_items" ) ;; review) _seq=$((_seq + 1)) _rev+=( "$(_lc "$_subj")"$'\037'"0"$'\037'"$(printf '%06d' "$_seq")"$'\037'"$_subj"$'\037'"$_title"$'\037'"$_prose"$'\037'"$_items" ) ;; warning) case "$_title" in "$_subj "*) _title="${_title#"$_subj" }" ;; esac _warn+=( "$_subj"$'\037'"$_title" ) ;; exported) _exp+=( "$_subj" ) ;; deselected) _des+=( "$_subj" ) ;; excluded) _exc+=( "$_subj"$'\037'"$_title" ) ;; schema) _sch+=( "$_subj"$'\037'"$_title"$'\037'"$_items" ) ;; empty) _emp+=( "$_subj" ) ;; esac done < "$_store" fi _att_n=${#_att[@]} _rev_n=${#_rev[@]} _warn_n=${#_warn[@]} _exp_n=${#_exp[@]} _des_n=${#_des[@]} _emp_n=${#_emp[@]} _exc_n=${#_exc[@]} } _emit_report_html() { local -a _att=() _rev=() _warn=() _exp=() _des=() _emp=() _exc=() _sch=() local _att_n _rev_n _warn_n _exp_n _des_n _emp_n _exc_n _store _seq _rk local _sev _subj _title _prose _items _report_collect local _e _h1 _h2 _h3 if [ "$_att_n" -eq 0 ]; then printf '
COMPLETE — everything you selected exported in full.
\n' else html_escape "$_att_n" _h1 printf '
INCOMPLETE — %s %s your attention.
\n' \ "$_h1" "$(_pluralize "$_att_n" "item needs" "items need")" fi _report_html_group() { #