diff --git a/scripts/stoa-drive.sh b/scripts/stoa-drive.sh index b2d2cc6..89c1c87 100755 --- a/scripts/stoa-drive.sh +++ b/scripts/stoa-drive.sh @@ -10,16 +10,29 @@ ROFI=(rofi -dmenu -config ~/.config/rofi/config.rasi) STOA_CONF="${XDG_CONFIG_HOME:-$HOME/.config}/stoa/stoa.conf" DRIVE_DIR="${HOME}/Drive" +SYNC_LIST="${XDG_CONFIG_HOME:-$HOME/.config}/stoa/drive-sync.list" +SYNC_CACHE="${XDG_CACHE_HOME:-$HOME/.cache}/stoa/drive-sync" +SYNC_UNIT="stoa-drive-sync" # ── VFS cache settings ── # Override any of these in ~/.config/stoa/stoa.conf: # RCLONE_CACHE_SIZE=20G (default: 5G; use "off" for unlimited) # RCLONE_CACHE_MODE=full (default: full — caches all reads for reliable offline use) # RCLONE_CACHE_MAX_AGE=720h (default: 720h = 30 days; "off" = never evict by age) +# RCLONE_DIR_CACHE_TIME=24h (default: 24h — how long directory listings stay cached) +# RCLONE_ATTR_TIMEOUT=1h (default: 1h — how long file stat()s stay cached) +# RCLONE_POLL_INTERVAL=1m (default: 1m — how often to poll remote for changes) +# STOA_SYNC_INTERVAL=5min (default: 5min — bisync timer interval; systemd OnUnitActiveSec) +# STOA_SYNC_CONFLICT=newer (default: newer — newer|older|larger|smaller|path1|path2|none) [ -f "$STOA_CONF" ] && source "$STOA_CONF" 2>/dev/null _VFS_CACHE_SIZE="${RCLONE_CACHE_SIZE:-5G}" _VFS_CACHE_MODE="${RCLONE_CACHE_MODE:-full}" _VFS_CACHE_AGE="${RCLONE_CACHE_MAX_AGE:-720h}" +_DIR_CACHE_TIME="${RCLONE_DIR_CACHE_TIME:-24h}" +_ATTR_TIMEOUT="${RCLONE_ATTR_TIMEOUT:-1h}" +_POLL_INTERVAL="${RCLONE_POLL_INTERVAL:-1m}" +_SYNC_INTERVAL="${STOA_SYNC_INTERVAL:-5min}" +_SYNC_CONFLICT="${STOA_SYNC_CONFLICT:-newer}" # ── Helpers ── @@ -79,14 +92,28 @@ _mount_remote() { mkdir -p "$mountpoint" + # Exclude paths that are mirrored via bisync — avoids the mount and the + # local sync target fighting over the same files. + local excludes=() + while IFS=$'\t' read -r rp _lp; do + [ -z "$rp" ] && continue + [[ "$rp" == \#* ]] && continue + local rname="${rp%%:*}" + local rpath="${rp#*:}" + [ "$rname" = "$remote" ] && [ -n "$rpath" ] && excludes+=(--exclude "/${rpath}/**") + done < <([ -f "$SYNC_LIST" ] && cat "$SYNC_LIST") + rclone mount "${remote}:" "$mountpoint" \ --vfs-cache-mode "$_VFS_CACHE_MODE" \ --vfs-cache-max-size "$_VFS_CACHE_SIZE" \ --vfs-cache-max-age "$_VFS_CACHE_AGE" \ --vfs-read-chunk-size 16M \ --vfs-read-chunk-size-limit 256M \ - --dir-cache-time 5m \ - --poll-interval 30s \ + --vfs-fast-fingerprint \ + --dir-cache-time "$_DIR_CACHE_TIME" \ + --attr-timeout "$_ATTR_TIMEOUT" \ + --poll-interval "$_POLL_INTERVAL" \ + "${excludes[@]}" \ --allow-non-empty \ --daemon \ 2>/dev/null @@ -316,6 +343,282 @@ _open_in_thunar() { disown } +# ══════════════════════════════════════════════════════════════ +# BISYNC — keep a local folder mirrored to a remote path +# ══════════════════════════════════════════════════════════════ +# +# Pairs live in ~/.config/stoa/drive-sync.list, one per line, TAB-separated: +# gdrive:Obsidian/home/zeno/Obsidian +# Lines starting with # are ignored. +# +# State (workdir + first-sync marker) lives under ~/.cache/stoa/drive-sync/. +# When a pair is registered, the path is also auto-excluded from the +# rclone mount of that remote — so the mounted view and the synced copy +# do not collide. + +_pair_id() { + # Stable, filesystem-safe identifier for a pair (used as cache subdir name). + printf '%s' "$1__$2" | tr '/:' '__' | tr -cd 'a-zA-Z0-9_.-' +} + +_pair_workdir() { + echo "${SYNC_CACHE}/$(_pair_id "$1" "$2")" +} + +_list_sync_pairs() { + # Emit "remote:pathlocal-path" for each configured pair. + [ ! -f "$SYNC_LIST" ] && return + while IFS= read -r line; do + [ -z "$line" ] && continue + [[ "$line" == \#* ]] && continue + printf '%s\n' "$line" + done < "$SYNC_LIST" +} + +_run_sync() { + local remote_path="$1" + local local_path="$2" + local quiet="${3:-0}" + + local remote="${remote_path%%:*}" + if ! rclone listremotes 2>/dev/null | grep -qF "${remote}:"; then + [ "$quiet" = "0" ] && _notify "Sync: remote '${remote}' not configured" + return 1 + fi + + mkdir -p "$local_path" + local workdir + workdir=$(_pair_workdir "$remote_path" "$local_path") + mkdir -p "$workdir" + + local marker="${workdir}/.first-sync-done" + local logfile="${workdir}/last.log" + local resync_flag=() + [ ! -f "$marker" ] && resync_flag=(--resync) + + if rclone bisync "$local_path" "$remote_path" \ + --workdir "$workdir" \ + --conflict-resolve "$_SYNC_CONFLICT" \ + --conflict-loser num \ + --create-empty-src-dirs \ + --compare size,modtime \ + --fast-list \ + "${resync_flag[@]}" \ + >"$logfile" 2>&1; then + touch "$marker" + [ "$quiet" = "0" ] && _notify "Synced: ${local_path} ⇄ ${remote_path}" + return 0 + else + [ "$quiet" = "0" ] && _notify "Sync failed for ${remote_path}. See ${logfile}" + return 1 + fi +} + +_run_all_syncs() { + local pairs + pairs=$(_list_sync_pairs) + [ -z "$pairs" ] && { [ "${1:-}" != "quiet" ] && _notify "No sync pairs configured"; return; } + + local ok=0 fail=0 + while IFS=$'\t' read -r rp lp; do + [ -z "$rp" ] && continue + if _run_sync "$rp" "$lp" 1; then ((ok++)); else ((fail++)); fi + done <<< "$pairs" + + if [ "${1:-}" = "quiet" ]; then + return 0 + fi + if [ "$fail" -eq 0 ]; then + _notify "Synced ${ok} pair(s)" + else + _notify "Synced ${ok}, failed ${fail}. Check ${SYNC_CACHE}/*/last.log" + fi +} + +_add_sync_pair() { + local remotes + remotes=$(_list_remotes) + [ -z "$remotes" ] && { _notify "No accounts configured"; return; } + + local items=() + while IFS= read -r r; do + [ -n "$r" ] && items+=(" $r") + done <<< "$remotes" + items+=(" Back") + + local choice + choice=$(_rofi_select " Remote" "${items[@]}") + [ -z "$choice" ] || [[ "$choice" == *"Back"* ]] && return + local remote + remote=$(echo "$choice" | sed 's/^ //') + + local rpath + rpath=$(_rofi_input " Remote path (e.g. Obsidian)") + [ -z "$rpath" ] && return + rpath="${rpath#/}"; rpath="${rpath%/}" + + local lpath + lpath=$(_rofi_input " Local path (e.g. ~/Obsidian)") + [ -z "$lpath" ] && return + lpath="${lpath/#\~/$HOME}" + + local entry="${remote}:${rpath}"$'\t'"${lpath}" + mkdir -p "$(dirname "$SYNC_LIST")" + touch "$SYNC_LIST" + + if grep -qF "$entry" "$SYNC_LIST" 2>/dev/null; then + _notify "Pair already exists" + return + fi + + echo "$entry" >> "$SYNC_LIST" + _notify "Added: ${remote}:${rpath} ⇄ ${lpath}" + + if _rofi_confirm "Run first sync now? (Will --resync)"; then + _run_sync "${remote}:${rpath}" "$lpath" + fi +} + +_remove_sync_pair() { + local pairs + pairs=$(_list_sync_pairs) + [ -z "$pairs" ] && { _notify "No sync pairs configured"; return; } + + local items=() + while IFS=$'\t' read -r rp lp; do + [ -z "$rp" ] && continue + items+=(" ${rp} ⇄ ${lp}") + done <<< "$pairs" + items+=(" Back") + + local choice + choice=$(_rofi_select " Remove sync pair" "${items[@]}") + [ -z "$choice" ] || [[ "$choice" == *"Back"* ]] && return + + local clean="${choice# }" + local rp="${clean%% ⇄ *}" + local lp="${clean##* ⇄ }" + + _rofi_confirm "Remove pair '${rp} ⇄ ${lp}'?" || return + + local tmp + tmp=$(mktemp) + grep -vxF "${rp}"$'\t'"${lp}" "$SYNC_LIST" > "$tmp" || true + mv "$tmp" "$SYNC_LIST" + + if _rofi_confirm "Also delete sync state for this pair?"; then + rm -rf "$(_pair_workdir "$rp" "$lp")" + fi + _notify "Removed pair" +} + +_auto_sync_enabled() { + systemctl --user is-enabled "${SYNC_UNIT}.timer" &>/dev/null +} + +_install_sync_units() { + local unit_dir="${HOME}/.config/systemd/user" + mkdir -p "$unit_dir" + + cat > "${unit_dir}/${SYNC_UNIT}.service" < "${unit_dir}/${SYNC_UNIT}.timer" </dev/null + _notify "Auto-sync enabled (every ${_SYNC_INTERVAL})" +} + +_disable_auto_sync() { + systemctl --user disable --now "${SYNC_UNIT}.timer" 2>/dev/null + _notify "Auto-sync disabled" +} + +_sync_status() { + local lines=() + if _auto_sync_enabled; then + local next + next=$(systemctl --user list-timers "${SYNC_UNIT}.timer" --no-pager 2>/dev/null \ + | awk 'NR==2 {print $1, $2}') + lines+=(" Auto-sync: ON (every ${_SYNC_INTERVAL})") + [ -n "$next" ] && lines+=(" next run: ${next}") + else + lines+=(" Auto-sync: OFF") + fi + lines+=("") + + local pairs + pairs=$(_list_sync_pairs) + if [ -z "$pairs" ]; then + lines+=(" No sync pairs configured") + else + lines+=(" Pairs:") + while IFS=$'\t' read -r rp lp; do + [ -z "$rp" ] && continue + local wd marker="off" + wd=$(_pair_workdir "$rp" "$lp") + [ -f "${wd}/.first-sync-done" ] && marker="ready" || marker="pending --resync" + lines+=(" ${rp} ⇄ ${lp} [${marker}]") + done <<< "$pairs" + fi + + printf '%s\n' "${lines[@]}" | "${ROFI[@]}" -p " Sync Status" +} + +_sync_menu() { + while true; do + local auto="OFF" + _auto_sync_enabled && auto="ON" + + local pair_count + pair_count=$(_list_sync_pairs | grep -c .) + + local choice + choice=$(_rofi_select " Folder Sync (${pair_count} pair(s), auto: ${auto})" \ + " Sync now (all)" \ + " Add pair" \ + " Remove pair" \ + " Enable auto-sync" \ + " Disable auto-sync" \ + " Status" \ + " Back") + [ -z "$choice" ] || [[ "$choice" == *"Back"* ]] && return + + case "$choice" in + *"Sync now"*) _run_all_syncs ;; + *"Add pair"*) _add_sync_pair ;; + *"Remove pair"*) _remove_sync_pair ;; + *"Enable"*) _enable_auto_sync ;; + *"Disable"*) _disable_auto_sync ;; + *"Status"*) _sync_status ;; + esac + done +} + # ══════════════════════════════════════════════════════════════ # MAIN MENU # ══════════════════════════════════════════════════════════════ @@ -343,6 +646,7 @@ menu_drive() { " Mount all" \ " Unmount all" \ " Mount / Unmount" \ + " Folder Sync" \ " Add account" \ " Remove account" \ " Open in Thunar" \ @@ -354,6 +658,7 @@ menu_drive() { *"Mount all"*) _mount_all ;; *"Unmount all"*) _unmount_all ;; *"Mount / Un"*) _mount_unmount_menu ;; + *"Folder Sync"*) _sync_menu ;; *"Add account"*) _add_account ;; *"Remove"*) _remove_account ;; *"Thunar"*) _open_in_thunar ;; @@ -411,6 +716,31 @@ case "${1:-}" in [ -z "$2" ] && { echo "Usage: stoa-drive unmount "; exit 1; } _unmount_remote "$2" ;; + sync) + _check_rclone || exit 1 + if [ -n "$2" ] && [ -n "$3" ]; then + _run_sync "$2" "$3" + else + echo "Usage: stoa-drive sync " + exit 1 + fi + ;; + sync-all) _check_rclone && _run_all_syncs ;; + sync-all-quiet) _check_rclone && _run_all_syncs quiet ;; + sync-enable) _enable_auto_sync ;; + sync-disable) _disable_auto_sync ;; + sync-status) + _check_rclone || exit 1 + if _auto_sync_enabled; then echo "Auto-sync: ON (every ${_SYNC_INTERVAL})" + else echo "Auto-sync: OFF"; fi + _list_sync_pairs | while IFS=$'\t' read -r rp lp; do + [ -z "$rp" ] && continue + wd=$(_pair_workdir "$rp" "$lp") + state="pending --resync" + [ -f "${wd}/.first-sync-done" ] && state="ready" + echo " ${rp} ⇄ ${lp} [${state}]" + done + ;; status) _check_rclone || exit 1 remotes=$(_list_remotes) @@ -432,7 +762,9 @@ case "${1:-}" in menu_drive ;; *) - echo "Usage: stoa-drive [mount-all|unmount-all|mount |unmount |status]" + echo "Usage: stoa-drive [mount-all|unmount-all|mount |unmount " + echo " |sync |sync-all|sync-enable|sync-disable" + echo " |sync-status|status]" exit 1 ;; esac diff --git a/stoa.conf b/stoa.conf index 1d57f5f..f162591 100644 --- a/stoa.conf +++ b/stoa.conf @@ -15,3 +15,25 @@ STOA_SHOW_KEYBINDS=true # waybar → legacy GTK fallback # Unset = auto-pick noctalia if installed, else waybar. STOA_BAR=noctalia + +# ── Cloud Drive (rclone mount) ── +# Tuning for `stoa-drive mount`. Larger caches = snappier Thunar at the +# cost of staleness (changes made elsewhere take longer to appear). +#RCLONE_CACHE_SIZE=5G +#RCLONE_CACHE_MODE=full +#RCLONE_CACHE_MAX_AGE=720h +#RCLONE_DIR_CACHE_TIME=24h +#RCLONE_ATTR_TIMEOUT=1h +#RCLONE_POLL_INTERVAL=1m + +# ── Folder Sync (rclone bisync) ── +# For folders you use a lot (Obsidian vaults, code, notes), keep them +# physically on the NVMe and bisync to the cloud on a timer instead of +# accessing through the mount. Pairs are registered via: +# stoa-drive → Folder Sync → Add pair +# and stored in ~/.config/stoa/drive-sync.list. The timer interval and +# conflict policy live here. +# STOA_SYNC_INTERVAL: any systemd OnUnitActiveSec value (30s, 5min, 1h, ...) +# STOA_SYNC_CONFLICT: newer | older | larger | smaller | path1 | path2 | none +#STOA_SYNC_INTERVAL=5min +#STOA_SYNC_CONFLICT=newer