commit 19f1791421707ae9bc00a88d5d8b2256f79db262 Author: Andrew Kopylev Date: Tue Apr 28 07:48:41 2026 +0300 Initial commit: Vault Bridge SFTP v0.9.0 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..421184a --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +node_modules/ +main.js +main.js.map +data.json +.DS_Store +*.log + +# Personal docs not meant for the public repo: +RELEASING.md diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..ee4c5b5 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Andrew Kopylev + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..7717673 --- /dev/null +++ b/README.md @@ -0,0 +1,268 @@ +# Vault Bridge SFTP + +Sync your Obsidian vault across desktops through **your own** SSH/SFTP server. No cloud, no proxy services, no subscriptions — your notes go straight between your machines and your server. + +## What it does + +A bidirectional sync engine for [Obsidian](https://obsidian.md/) vaults that uses SFTP/SSH as transport. It does proper 3-way diffing (so it can tell "you edited" from "they deleted"), preserves both versions on conflict, and protects you from catastrophic operations. + +## Features + +- **Bidirectional sync** with 3-way diff (local + remote manifest + last-synced snapshot). Pulls others' changes, pushes yours, in one operation. +- **Conflict-copy resolution** — when the same file was edited on two devices, both versions are kept. The newer mtime wins on the original path; the loser becomes `notes/foo (conflict from device-A 2026-04-28 14-30).md`. +- **Multi-device safe** — server-side lock prevents two devices syncing simultaneously; manifest generation counter detects out-of-order writes. +- **Bandwidth-efficient** — SHA-1 per file decides what actually changed; identical files are skipped. +- **Bulk-delete protection** — if a sync would delete more than 5% of files (or 20+), a modal lists them and offers Continue / Skip-deletes / Cancel. +- **Server-reset detection** — if the remote manifest is wiped (manual `rm`, server restored from backup), the plugin refuses to interpret that as "delete everything locally" and offers safe recovery options. +- **Self-heal** — if a file in the manifest is missing from the server's filesystem, the plugin drops the entry instead of crashing. +- **Auto-sync triggers** (all toggleable): on Obsidian start, on quit (push-only, best-effort), and after vault changes (debounced, default 10s). +- **Atomic transfers** — every upload and download goes through a temp file + rename, so an interrupted sync never corrupts a target. +- **Password or SSH key** authentication, with passphrase support. + +## When to use this + +Good fit if: +- You have your own server, VPS, NAS, or home machine reachable via SSH. +- You want multi-device sync without paying for Obsidian Sync. +- You don't want Dropbox / Google Drive / iCloud touching your notes. +- You want a transparent sync — open-source, plain JSON manifest, plain SHA-1 hashes; nothing proprietary. + +Not a good fit if: +- You need mobile sync. Mobile Obsidian (iOS/Android) cannot open raw SSH sockets — this plugin is **desktop only**. Use Obsidian Sync or an OS-level sync (Syncthing) for mobile. +- You only have one device. Just back up your vault folder. +- You don't have an SSH server. + +## Installation + +### From source + +```sh +git clone https://github.com/andrewkopylev/vaultbridge.git +cd vaultbridge +npm install +npm run build +./install.sh /path/to/your/vault +``` + +Then in Obsidian → Settings → Community plugins → enable **Vault Bridge SFTP**. + +### Manual install (after a release is published) + +1. Download `main.js` and `manifest.json` from the latest [release](https://github.com/andrewkopylev/vaultbridge/releases). +2. Create `/.obsidian/plugins/vault-bridge-sftp/` and place both files inside. +3. In Obsidian: Settings → Community plugins → enable **Vault Bridge SFTP**. + +### From the Obsidian Community Plugin store + +*Coming soon — see [RELEASING.md](RELEASING.md) for submission status.* + +## Quick start + +1. Open Settings → Vault Bridge SFTP. +2. Fill in: + - **Host / Port / Username** — your SSH server. + - **Authentication** — Password OR Private key (with optional passphrase). + - **Remote root** — absolute path on the server, e.g. `/home/me/obsidian-vault`. Created if it doesn't exist. +3. Click **Test connection**. The plugin will create the remote root and an empty `.sync/` directory inside it. +4. Click **Sync now** (or `Ctrl+P → Vault Bridge: Sync now`). The first sync uploads your full vault — expect this to take a while; later syncs only transfer what actually changed. + +## Settings reference + +| Setting | Description | +|---|---| +| Host / Port / Username | SSH connection info. | +| Authentication | Password or Private key (with optional passphrase). No host fingerprint verification. | +| Remote root | Absolute path on the server. Created on first connect if missing. | +| Sync everything (`.obsidian` too) | When ON, plugins/themes/snippets/hotkeys are synced so all devices look identical. The plugin's own `state/` directory is always excluded regardless. Default: ON. | +| Sync workspace.json | When OFF, panel-layout files stay device-specific (recommended — turning it ON causes flapping when working on two devices at once). Default: OFF. | +| Exclude patterns | Gitignore-style. One per line. | +| Sync on startup | Run a full bidirectional sync after Obsidian loads. Default: ON. | +| Sync on quit | Best-effort push when Obsidian closes (5-second timeout, push-only — no prompts). Default: ON. | +| Sync after changes | Debounced sync triggered by vault edits. Default: ON. | +| Debounce delay | Seconds to wait after the last edit before auto-syncing. Default: 10. | +| Device label | Human-readable name used in conflict-copy filenames. Per-device, not synced. | + +## Commands reference + +All commands are accessible via Command Palette (`Ctrl+P` / `Cmd+P`): + +### Daily use + +| Command | What it does | +|---|---| +| `Vault Bridge: Sync now` | Bidirectional sync. Pulls changes, pushes yours, handles conflicts. The everyday command. Also bound to the ribbon icon and the status bar click. | +| `Vault Bridge: Test connection` | Verify SSH credentials and create remote root if missing. | + +### One-way operations + +| Command | What it does | +|---|---| +| `Vault Bridge: Pull from server` | Download additions and updates only. Never modifies the server. Useful for refreshing a fresh device. | +| `Vault Bridge: Force push everything` | Re-upload every local file regardless of remote state. Rewrites manifest. Use after manifest corruption. | +| `Vault Bridge: Force pull everything` | Re-download every file from the manifest, even if local sha1 matches. Use after local index corruption. | + +### Maintenance + +| Command | What it does | +|---|---| +| `Vault Bridge: Inspect remote state` | Show server-side manifest generation, file count, last writer, lock status. | +| `Vault Bridge: Force-release remote sync lock` | Release a stuck lock that belongs to *this* device (foreign locks are not touched). | +| `Vault Bridge: Rebuild remote manifest` | Walk the actual server filesystem, hash every file, rewrite the manifest. Use after manual file changes on the server. | +| `Vault Bridge: Reset local snapshot` | Wipe this device's "last sync" record. Next sync treats every local file as a fresh addition. | +| `Vault Bridge: Rebuild local index` | Force a re-scan and re-hash of every local file. | +| `Vault Bridge: Show index stats` | Quick stats: file count, total size, last full scan timestamp. | + +## How sync works + +The engine compares three sources for every path on every sync: + +- **L** — local index (current vault state with SHA-1 hashes) +- **R** — remote manifest (`/.sync/manifest.json` on the server) +- **S** — last-synced snapshot (the manifest from the last successful sync, kept locally) + +Decision matrix per path: + +| L vs S | R vs S | Action | +|---|---|---| +| unchanged | unchanged | skip | +| changed / added | unchanged | push | +| unchanged | changed / added | pull | +| changed (same content as R) | changed (same content as L) | record, no I/O | +| changed | changed (different) | conflict-copy + winner by mtime | +| deleted | unchanged | delete on server | +| unchanged | deleted | delete locally | +| deleted | changed | restore from remote | +| changed | deleted | restore from local (push back) | +| deleted | deleted | drop from snapshot | + +### Server-side metadata + +In `/.sync/`: +- `manifest.json` — `{generation, entries: {path: {mtime, size, sha1}}}`. Each successful sync bumps `generation`. +- `lock.json` — held during a sync. Stale locks (>5 min) are taken automatically. + +### Local per-device metadata + +In `/.obsidian/plugins/vault-bridge-sftp/state/` (never synced): +- `index.json` — current local index +- `last-synced.json` — snapshot S +- `device.json` — per-device id and label + +## Multi-device guide + +### Adding a second device + +1. On device A, install the plugin, fill in server settings, run **Sync now** to seed the server. +2. On device B (empty vault), install the plugin and fill in the same server settings. +3. On device B, run **Vault Bridge: Pull from server**. The vault gets populated. +4. From now on, run **Sync now** on either device. They stay in sync. + +### Conflicts in practice + +If both devices edit `notes/foo.md` before either has synced, the second to sync gets: +- `notes/foo.md` — winner (newer mtime) +- `notes/foo (conflict from device-XYZ 2026-04-28 14-30).md` — loser, preserved next to the original + +You decide what to do (merge, keep one, etc.) in your editor. + +## Recovery scenarios + +### "I deleted a file directly on the server via SSH" + +The manifest still has the entry, so the next sync sees nothing changed. To propagate the deletion: + +1. Run **Vault Bridge: Rebuild remote manifest** — re-walks the server filesystem and rewrites the manifest based on reality. +2. Run **Sync now** — the diff now sees "remote deleted file", proposes deleting locally (bulk-delete modal will appear if many files). + +### "I wiped the server folder by accident" + +When the server manifest is empty (gen=0) but your local snapshot has gen > 0, the plugin detects this and shows the **Server Reset** dialog with three options: + +- **Force push from local** — re-upload every file from this device, rebuild the manifest. +- **Reset snapshot** — clear this device's snapshot so the next sync treats local files as fresh additions. +- **Cancel** — investigate before doing anything. + +This blocks the catastrophic "treat empty manifest as N deletions" path before the bulk-delete modal even runs. + +### "Sync says lock is held by another device" + +If a device crashed mid-sync, its lock will go stale after 5 minutes and the next sync will take it. To break it sooner, run **Force-release remote sync lock** *on the device that holds it* (foreign locks are intentionally untouched). + +### "Local index seems wrong" + +Run **Rebuild local index** — full re-scan and re-hash. Cheap on small vaults. + +## Excluding files + +Default soft excludes: +- `.trash/**` (Obsidian's local trash) +- `.obsidian/workspace.json`, `workspace-mobile.json` — only when "Sync workspace.json" is OFF (recommended) + +Hardcoded excludes (cannot be turned off): +- `.obsidian/plugins/vault-bridge-sftp/state/**` — the plugin's own state. Recursive sync would corrupt the index. + +You can add gitignore-style patterns in **Exclude patterns** in settings: + +``` +node_modules/** +*.tmp +private/secrets.md +``` + +## Security notes + +- **Password is stored in plain text** in `data.json` inside the vault. If you sync `.obsidian` (the default), the password is pushed to the server too. Prefer SSH key authentication on shared machines. +- **No host fingerprint verification.** This plugin trusts whatever server is at the configured host:port. Use only on networks you control or via a VPN to mitigate man-in-the-middle risk. +- **No end-to-end encryption.** Files on the server are stored as-is. If you have sensitive notes, encrypt the server's filesystem. +- **`.sync/` directory is world-readable** by default. Lock down permissions if you store the vault on a multi-user server. + +## Limitations + +- **Desktop only** (`isDesktopOnly: true`). Mobile Obsidian cannot open raw SSH sockets. +- **No rename detection by hash yet.** A renamed large file is currently re-uploaded. Planned for a future release. +- **Conflict resolution by mtime** assumes device clocks are roughly in sync. +- **Single SFTP connection per sync** — operations are serial. For thousands of files, throughput is RTT-bound. +- **External edits to server files** (outside the plugin) require **Rebuild remote manifest** to re-establish consistency. + +## Development + +```sh +git clone https://github.com/andrewkopylev/vaultbridge.git +cd vaultbridge +npm install +npm run dev # esbuild watch mode +npm run build # production build → main.js +./install.sh # copy main.js + manifest.json into /.obsidian/plugins/vault-bridge-sftp/ +``` + +Source layout: +``` +src/ +├── main.ts # plugin entry, command wiring, vault events, triggers +├── settings.ts # settings schema + UI tab +├── sftp/ +│ ├── client.ts # ssh2-sftp-client wrapper +│ ├── remote-state.ts # manifest + lock management on the server +│ └── transfer.ts # atomic upload/download primitives +├── sync/ +│ ├── diff.ts # 3-way diff — pure function +│ ├── sync-engine.ts # bidirectional orchestrator +│ ├── push-engine.ts # one-way push (force-push) +│ ├── pull-engine.ts # one-way pull (additive) +│ ├── manifest-rebuilder.ts # walk server, hash, rewrite manifest +│ ├── exclude.ts # gitignore-style matcher +│ ├── hash.ts # sha1 +│ ├── index-store.ts # local file index +│ ├── last-synced.ts # snapshot S +│ └── scanner.ts # walk vault, build index +├── state/ +│ ├── paths.ts # state-dir path constants +│ └── device-store.ts # per-device id/label +└── ui/ + ├── bulk-delete-modal.ts # 5%/20-file deletion confirmation + └── server-reset-modal.ts # gen=0 vs S>0 recovery dialog +``` + +## License + +MIT — see [LICENSE](LICENSE). diff --git a/esbuild.config.mjs b/esbuild.config.mjs new file mode 100644 index 0000000..56180a4 --- /dev/null +++ b/esbuild.config.mjs @@ -0,0 +1,51 @@ +import esbuild from "esbuild"; +import process from "process"; +import builtins from "builtin-modules"; + +const banner = `/* +Vault Bridge SFTP — Obsidian plugin +Generated bundle. Do not edit by hand; edit src/ instead. +*/ +`; + +const prod = process.argv[2] === "production"; + +const ctx = await esbuild.context({ + banner: { js: banner }, + entryPoints: ["src/main.ts"], + bundle: true, + external: [ + "obsidian", + "electron", + "@codemirror/autocomplete", + "@codemirror/collab", + "@codemirror/commands", + "@codemirror/language", + "@codemirror/lint", + "@codemirror/search", + "@codemirror/state", + "@codemirror/view", + "@lezer/common", + "@lezer/highlight", + "@lezer/lr", + ...builtins, + // ssh2 optional native deps — fall back to pure JS at runtime + "cpu-features", + "./crypto/build/Release/sshcrypto.node", + ], + format: "cjs", + target: "es2020", + platform: "node", + logLevel: "info", + sourcemap: prod ? false : "inline", + treeShaking: true, + outfile: "main.js", + minify: prod, +}); + +if (prod) { + await ctx.rebuild(); + await ctx.dispose(); +} else { + await ctx.watch(); +} diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..cc6cdb2 --- /dev/null +++ b/install.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +# install.sh — copy the built plugin into an Obsidian vault. +# Usage: +# ./install.sh /path/to/your/vault +# +# Copies main.js and manifest.json into /.obsidian/plugins/vault-bridge-sftp/. +# Existing data.json (your settings) and state/ (index, snapshot, deviceId) are preserved. + +set -euo pipefail + +PLUGIN_ID="vault-bridge-sftp" +SRC_DIR="$(cd "$(dirname "$0")" && pwd)" + +VAULT="${1:-}" +if [ -z "$VAULT" ]; then + cat < + +Example: + $0 /home/user/Documents/MyVault + +Copies main.js + manifest.json into: + /.obsidian/plugins/$PLUGIN_ID/ + +Preserves: data.json, state/. +Removes any stale symlinks at the destination. +EOF + exit 1 +fi + +VAULT="${VAULT%/}" # strip trailing slash + +if [ ! -d "$VAULT/.obsidian" ]; then + echo "Error: '$VAULT' does not look like an Obsidian vault (no .obsidian/ folder)." >&2 + exit 1 +fi + +if [ ! -f "$SRC_DIR/main.js" ]; then + echo "Error: main.js not found at $SRC_DIR." >&2 + echo "Run 'npm run build' first." >&2 + exit 1 +fi + +if [ ! -f "$SRC_DIR/manifest.json" ]; then + echo "Error: manifest.json not found at $SRC_DIR." >&2 + exit 1 +fi + +DEST_DIR="$VAULT/.obsidian/plugins/$PLUGIN_ID" +mkdir -p "$DEST_DIR" + +# Replace any existing main.js / manifest.json (file or symlink). data.json + state/ stay put. +[ -e "$DEST_DIR/main.js" ] && rm -f "$DEST_DIR/main.js" +[ -e "$DEST_DIR/manifest.json" ] && rm -f "$DEST_DIR/manifest.json" + +cp "$SRC_DIR/main.js" "$DEST_DIR/main.js" +cp "$SRC_DIR/manifest.json" "$DEST_DIR/manifest.json" + +echo "✓ Installed Vault Bridge SFTP into:" +echo " $DEST_DIR" +echo "" +echo "If Obsidian is currently running, reload the plugin:" +echo " Settings → Community plugins → toggle Vault Bridge SFTP off, then on." diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..e71e34b --- /dev/null +++ b/manifest.json @@ -0,0 +1,9 @@ +{ + "id": "vault-bridge-sftp", + "name": "Vault Bridge SFTP", + "version": "0.1.0", + "minAppVersion": "1.5.0", + "description": "Bridge your vault across devices through your own SSH/SFTP server. Bidirectional sync with conflict resolution, multi-device safety, and full self-hosting.", + "author": "Andrew Kopylev", + "isDesktopOnly": true +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..9198aa4 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,857 @@ +{ + "name": "obsidian-sftp-sync", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "obsidian-sftp-sync", + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "ssh2-sftp-client": "^10.0.3" + }, + "devDependencies": { + "@types/node": "^20.11.0", + "@types/ssh2-sftp-client": "^9.0.4", + "builtin-modules": "^3.3.0", + "esbuild": "^0.20.2", + "obsidian": "latest", + "tslib": "^2.6.2", + "typescript": "^5.4.5" + } + }, + "node_modules/@codemirror/state": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.0.tgz", + "integrity": "sha512-MwBHVK60IiIHDcoMet78lxt6iw5gJOGSbNbOIVBHWVXIH4/Nq1+GQgLLGgI1KlnN86WDXsPudVaqYHKBIx7Eyw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@marijn/find-cluster-break": "^1.0.0" + } + }, + "node_modules/@codemirror/view": { + "version": "6.38.6", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.38.6.tgz", + "integrity": "sha512-qiS0z1bKs5WOvHIAC0Cybmv4AJSkAXgX5aD6Mqd2epSLlVJsQl8NG23jCVouIgkh4All/mrbdsf2UOLFnJw0tw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@codemirror/state": "^6.5.0", + "crelt": "^1.0.6", + "style-mod": "^4.1.0", + "w3c-keyname": "^2.2.4" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.20.2.tgz", + "integrity": "sha512-D+EBOJHXdNZcLJRBkhENNG8Wji2kgc9AZ9KiPr1JuZjsNtyHzrsfLRrY0tk2H2aoFu6RANO1y1iPPUCDYWkb5g==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.20.2.tgz", + "integrity": "sha512-t98Ra6pw2VaDhqNWO2Oph2LXbz/EJcnLmKLGBJwEwXX/JAN83Fym1rU8l0JUWK6HkIbWONCSSatf4sf2NBRx/w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.20.2.tgz", + "integrity": "sha512-mRzjLacRtl/tWU0SvD8lUEwb61yP9cqQo6noDZP/O8VkwafSYwZ4yWy24kan8jE/IMERpYncRt2dw438LP3Xmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.20.2.tgz", + "integrity": "sha512-btzExgV+/lMGDDa194CcUQm53ncxzeBrWJcncOBxuC6ndBkKxnHdFJn86mCIgTELsooUmwUm9FkhSp5HYu00Rg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.20.2.tgz", + "integrity": "sha512-4J6IRT+10J3aJH3l1yzEg9y3wkTDgDk7TSDFX+wKFiWjqWp/iCfLIYzGyasx9l0SAFPT1HwSCR+0w/h1ES/MjA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.20.2.tgz", + "integrity": "sha512-tBcXp9KNphnNH0dfhv8KYkZhjc+H3XBkF5DKtswJblV7KlT9EI2+jeA8DgBjp908WEuYll6pF+UStUCfEpdysA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.20.2.tgz", + "integrity": "sha512-d3qI41G4SuLiCGCFGUrKsSeTXyWG6yem1KcGZVS+3FYlYhtNoNgYrWcvkOoaqMhwXSMrZRl69ArHsGJ9mYdbbw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.20.2.tgz", + "integrity": "sha512-d+DipyvHRuqEeM5zDivKV1KuXn9WeRX6vqSqIDgwIfPQtwMP4jaDsQsDncjTDDsExT4lR/91OLjRo8bmC1e+Cw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.20.2.tgz", + "integrity": "sha512-VhLPeR8HTMPccbuWWcEUD1Az68TqaTYyj6nfE4QByZIQEQVWBB8vup8PpR7y1QHL3CpcF6xd5WVBU/+SBEvGTg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.20.2.tgz", + "integrity": "sha512-9pb6rBjGvTFNira2FLIWqDk/uaf42sSyLE8j1rnUpuzsODBq7FvpwHYZxQ/It/8b+QOS1RYfqgGFNLRI+qlq2A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.20.2.tgz", + "integrity": "sha512-o10utieEkNPFDZFQm9CoP7Tvb33UutoJqg3qKf1PWVeeJhJw0Q347PxMvBgVVFgouYLGIhFYG0UGdBumROyiig==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.20.2.tgz", + "integrity": "sha512-PR7sp6R/UC4CFVomVINKJ80pMFlfDfMQMYynX7t1tNTeivQ6XdX5r2XovMmha/VjR1YN/HgHWsVcTRIMkymrgQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.20.2.tgz", + "integrity": "sha512-4BlTqeutE/KnOiTG5Y6Sb/Hw6hsBOZapOVF6njAESHInhlQAghVVZL1ZpIctBOoTFbQyGW+LsVYZ8lSSB3wkjA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.20.2.tgz", + "integrity": "sha512-rD3KsaDprDcfajSKdn25ooz5J5/fWBylaaXkuotBDGnMnDP1Uv5DLAN/45qfnf3JDYyJv/ytGHQaziHUdyzaAg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.20.2.tgz", + "integrity": "sha512-snwmBKacKmwTMmhLlz/3aH1Q9T8v45bKYGE3j26TsaOVtjIag4wLfWSiZykXzXuE1kbCE+zJRmwp+ZbIHinnVg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.20.2.tgz", + "integrity": "sha512-wcWISOobRWNm3cezm5HOZcYz1sKoHLd8VL1dl309DiixxVFoFe/o8HnwuIwn6sXre88Nwj+VwZUvJf4AFxkyrQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.20.2.tgz", + "integrity": "sha512-1MdwI6OOTsfQfek8sLwgyjOXAu+wKhLEoaOLTjbijk6E2WONYpH9ZU2mNtR+lZ2B4uwr+usqGuVfFT9tMtGvGw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.20.2.tgz", + "integrity": "sha512-K8/DhBxcVQkzYc43yJXDSyjlFeHQJBiowJ0uVL6Tor3jGQfSGHNNJcWxNbOI8v5k82prYqzPuwkzHt3J1T1iZQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.20.2.tgz", + "integrity": "sha512-eMpKlV0SThJmmJgiVyN9jTPJ2VBPquf6Kt/nAoo6DgHAoN57K15ZghiHaMvqjCye/uU4X5u3YSMgVBI1h3vKrQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.20.2.tgz", + "integrity": "sha512-2UyFtRC6cXLyejf/YEld4Hajo7UHILetzE1vsRcGL3earZEW77JxrFjH4Ez2qaTiEfMgAXxfAZCm1fvM/G/o8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.20.2.tgz", + "integrity": "sha512-GRibxoawM9ZCnDxnP3usoUDO9vUkpAxIIZ6GQI+IlVmr5kP3zUq+l17xELTHMWTWzjxa2guPNyrpq1GWmPvcGQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.20.2.tgz", + "integrity": "sha512-HfLOfn9YWmkSKRQqovpnITazdtquEW8/SoHW7pWpuEeguaZI4QnCRW6b+oZTztdBnZOS2hqJ6im/D5cPzBTTlQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.20.2.tgz", + "integrity": "sha512-N49X4lJX27+l9jbLKSqZ6bKNjzQvHaT8IIFUy+YIqmXQdjYCToGWwOItDrfby14c78aDd5NHQl29xingXfCdLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@marijn/find-cluster-break": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz", + "integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@types/codemirror": { + "version": "5.60.8", + "resolved": "https://registry.npmjs.org/@types/codemirror/-/codemirror-5.60.8.tgz", + "integrity": "sha512-VjFgDF/eB+Aklcy15TtOTLQeMjTo07k7KAjql8OK5Dirr7a6sJY4T1uVBDuTVG9VEmn1uUsohOpYnVfgC6/jyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/tern": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.19.39", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.39.tgz", + "integrity": "sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/ssh2": { + "version": "1.15.5", + "resolved": "https://registry.npmjs.org/@types/ssh2/-/ssh2-1.15.5.tgz", + "integrity": "sha512-N1ASjp/nXH3ovBHddRJpli4ozpk6UdDYIX4RJWFa9L1YKnzdhTlVmiGHm4DZnj/jLbqZpes4aeR30EFGQtvhQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "^18.11.18" + } + }, + "node_modules/@types/ssh2-sftp-client": { + "version": "9.0.6", + "resolved": "https://registry.npmjs.org/@types/ssh2-sftp-client/-/ssh2-sftp-client-9.0.6.tgz", + "integrity": "sha512-4+KvXO/V77y9VjI2op2T8+RCGI/GXQAwR0q5Qkj/EJ5YSeyKszqZP6F8i3H3txYoBqjc7sgorqyvBP3+w1EHyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/ssh2": "^1.0.0" + } + }, + "node_modules/@types/ssh2/node_modules/@types/node": { + "version": "18.19.130", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", + "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/@types/ssh2/node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/tern": { + "version": "0.23.9", + "resolved": "https://registry.npmjs.org/@types/tern/-/tern-0.23.9.tgz", + "integrity": "sha512-ypzHFE/wBzh+BlH6rrBgS5I/Z7RD21pGhZ2rltb/+ZrVM1awdZwjx7hE5XfuYgHWk9uvV5HLZN3SloevCAp3Bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/asn1": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", + "integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": "~2.1.0" + } + }, + "node_modules/bcrypt-pbkdf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", + "integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==", + "license": "BSD-3-Clause", + "dependencies": { + "tweetnacl": "^0.14.3" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, + "node_modules/buildcheck": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/buildcheck/-/buildcheck-0.0.7.tgz", + "integrity": "sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA==", + "optional": true, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/builtin-modules": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-3.3.0.tgz", + "integrity": "sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/concat-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "engines": [ + "node >= 6.0" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, + "node_modules/cpu-features": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/cpu-features/-/cpu-features-0.0.10.tgz", + "integrity": "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==", + "hasInstallScript": true, + "optional": true, + "dependencies": { + "buildcheck": "~0.0.6", + "nan": "^2.19.0" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/crelt": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz", + "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/err-code": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", + "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==", + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.20.2.tgz", + "integrity": "sha512-WdOOppmUNU+IbZ0PaDiTst80zjnrOkyJNHoKupIcVyU8Lvla3Ugx94VzkQ32Ijqd7UhHJy75gNWDMUekcrSJ6g==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.20.2", + "@esbuild/android-arm": "0.20.2", + "@esbuild/android-arm64": "0.20.2", + "@esbuild/android-x64": "0.20.2", + "@esbuild/darwin-arm64": "0.20.2", + "@esbuild/darwin-x64": "0.20.2", + "@esbuild/freebsd-arm64": "0.20.2", + "@esbuild/freebsd-x64": "0.20.2", + "@esbuild/linux-arm": "0.20.2", + "@esbuild/linux-arm64": "0.20.2", + "@esbuild/linux-ia32": "0.20.2", + "@esbuild/linux-loong64": "0.20.2", + "@esbuild/linux-mips64el": "0.20.2", + "@esbuild/linux-ppc64": "0.20.2", + "@esbuild/linux-riscv64": "0.20.2", + "@esbuild/linux-s390x": "0.20.2", + "@esbuild/linux-x64": "0.20.2", + "@esbuild/netbsd-x64": "0.20.2", + "@esbuild/openbsd-x64": "0.20.2", + "@esbuild/sunos-x64": "0.20.2", + "@esbuild/win32-arm64": "0.20.2", + "@esbuild/win32-ia32": "0.20.2", + "@esbuild/win32-x64": "0.20.2" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/moment": { + "version": "2.29.4", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.29.4.tgz", + "integrity": "sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/nan": { + "version": "2.26.2", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.26.2.tgz", + "integrity": "sha512-0tTvBTYkt3tdGw22nrAy50x7gpbGCCFH3AFcyS5WiUu7Eu4vWlri1woE6qHBSfy11vksDqkiwjOnlR7WV8G1Hw==", + "license": "MIT", + "optional": true + }, + "node_modules/obsidian": { + "version": "1.12.3", + "resolved": "https://registry.npmjs.org/obsidian/-/obsidian-1.12.3.tgz", + "integrity": "sha512-HxWqe763dOqzXjnNiHmAJTRERN8KILBSqxDSEqbeSr7W8R8Jxezzbca+nz1LiiqXnMpM8lV2jzAezw3CZ4xNUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/codemirror": "5.60.8", + "moment": "2.29.4" + }, + "peerDependencies": { + "@codemirror/state": "6.5.0", + "@codemirror/view": "6.38.6" + } + }, + "node_modules/promise-retry": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz", + "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==", + "license": "MIT", + "dependencies": { + "err-code": "^2.0.2", + "retry": "^0.12.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/ssh2": { + "version": "1.17.0", + "resolved": "https://registry.npmjs.org/ssh2/-/ssh2-1.17.0.tgz", + "integrity": "sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==", + "hasInstallScript": true, + "dependencies": { + "asn1": "^0.2.6", + "bcrypt-pbkdf": "^1.0.2" + }, + "engines": { + "node": ">=10.16.0" + }, + "optionalDependencies": { + "cpu-features": "~0.0.10", + "nan": "^2.23.0" + } + }, + "node_modules/ssh2-sftp-client": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/ssh2-sftp-client/-/ssh2-sftp-client-10.0.3.tgz", + "integrity": "sha512-Wlhasz/OCgrlqC8IlBZhF19Uw/X/dHI8ug4sFQybPE+0sDztvgvDf7Om6o7LbRLe68E7XkFZf3qMnqAvqn1vkQ==", + "license": "Apache-2.0", + "dependencies": { + "concat-stream": "^2.0.0", + "promise-retry": "^2.0.1", + "ssh2": "^1.15.0" + }, + "engines": { + "node": ">=16.20.2" + }, + "funding": { + "type": "individual", + "url": "https://square.link/u/4g7sPflL" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/style-mod": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz", + "integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/tweetnacl": { + "version": "0.14.5", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", + "integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==", + "license": "Unlicense" + }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", + "dev": true, + "license": "MIT", + "peer": true + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..e73f75b --- /dev/null +++ b/package.json @@ -0,0 +1,25 @@ +{ + "name": "obsidian-vault-bridge-sftp", + "version": "0.1.0", + "description": "Vault Bridge SFTP — bidirectional Obsidian vault sync over SSH/SFTP", + "main": "main.js", + "scripts": { + "dev": "node esbuild.config.mjs", + "build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production" + }, + "keywords": ["obsidian", "sftp", "ssh", "sync"], + "author": "Andrew Kopylev", + "license": "MIT", + "devDependencies": { + "@types/node": "^20.11.0", + "@types/ssh2-sftp-client": "^9.0.4", + "builtin-modules": "^3.3.0", + "esbuild": "^0.20.2", + "obsidian": "latest", + "tslib": "^2.6.2", + "typescript": "^5.4.5" + }, + "dependencies": { + "ssh2-sftp-client": "^10.0.3" + } +} diff --git a/save2git.sh b/save2git.sh new file mode 100755 index 0000000..e462a82 --- /dev/null +++ b/save2git.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +# save2git.sh — stage, commit, and push the current state of the plugin to GitHub. +# Usage: +# ./save2git.sh # commit message defaults to "Update" +# ./save2git.sh "Your commit message" +# +# Idempotent: safe to run repeatedly. Initializes git on first run, sets the remote, +# stages and commits if there are changes, and pushes to origin/main. + +set -euo pipefail + +REPO_URL="https://github.com/andrewkopylev/vaultbridge.git" +DIR="$(cd "$(dirname "$0")" && pwd)" +cd "$DIR" + +# 1. Init git if the repo doesn't exist yet. +if [ ! -d .git ]; then + echo "→ Initializing git repository on branch 'main'…" + git init -b main +fi + +# 2. Configure or update the 'origin' remote. +if git remote get-url origin >/dev/null 2>&1; then + CURRENT_URL="$(git remote get-url origin)" + if [ "$CURRENT_URL" != "$REPO_URL" ]; then + echo "→ Updating origin URL: $CURRENT_URL → $REPO_URL" + git remote set-url origin "$REPO_URL" + fi +else + echo "→ Adding origin = $REPO_URL" + git remote add origin "$REPO_URL" +fi + +# 3. Stage everything respecting .gitignore (node_modules, main.js, data.json are excluded). +git add -A + +# 4. Commit if there are staged changes. +if git diff --cached --quiet; then + echo "→ Working tree clean — no new commit." +else + MSG="${*:-Update}" + echo "→ Committing: $MSG" + git commit -m "$MSG" +fi + +# 5. Push to origin/main. -u sets upstream on the first push; harmless thereafter. +echo "→ Pushing to origin/main…" +git push -u origin main + +echo "" +echo "✓ Done. Repository: $REPO_URL" diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..5c2073c --- /dev/null +++ b/src/main.ts @@ -0,0 +1,738 @@ +import { Notice, Plugin, TAbstractFile, TFile, debounce } from "obsidian"; +import { DEFAULT_SETTINGS, SftpSyncSettings, SftpSyncSettingTab } from "./settings"; +import { SftpClient } from "./sftp/client"; +import { RemoteState } from "./sftp/remote-state"; +import { IndexStore } from "./sync/index-store"; +import { Scanner } from "./sync/scanner"; +import { ExcludeMatcher } from "./sync/exclude"; +import { DeviceStore } from "./state/device-store"; +import { LastSyncedStore } from "./sync/last-synced"; +import { PushEngine } from "./sync/push-engine"; +import { PullEngine } from "./sync/pull-engine"; +import { SyncEngine } from "./sync/sync-engine"; +import { askBulkDeleteDecision } from "./ui/bulk-delete-modal"; +import { askServerResetDecision } from "./ui/server-reset-modal"; +import { rebuildRemoteManifest } from "./sync/manifest-rebuilder"; +import { STATE_PATHS } from "./state/paths"; + +export default class SftpSyncPlugin extends Plugin { + settings!: SftpSyncSettings; + index!: IndexStore; + exclude!: ExcludeMatcher; + scanner!: Scanner; + deviceStore!: DeviceStore; + lastSynced!: LastSyncedStore; + + private statusBar: HTMLElement | null = null; + private syncInProgress = false; + private startupComplete = false; + + // Per-file debounced refresh queue. Prevents thrashing on rapid edits. + private pendingRefresh = new Map>(); + // Global debounced auto-sync trigger. Re-created when debounce setting changes. + private autoSyncDebouncer: ReturnType | null = null; + + async onload() { + await this.loadSettings(); + + this.deviceStore = new DeviceStore(this.app); + await this.deviceStore.load(); + + this.index = new IndexStore(this.app); + await this.index.load(); + + this.lastSynced = new LastSyncedStore(this.app); + await this.lastSynced.load(); + + this.exclude = new ExcludeMatcher(this.settings); + this.scanner = new Scanner(this.app, this.index, this.exclude); + + this.statusBar = this.addStatusBarItem(); + this.setStatus("idle"); + this.statusBar.addEventListener("click", () => this.syncNow()); + + this.addSettingTab(new SftpSyncSettingTab(this.app, this)); + + this.addCommand({ + id: "test-connection", + name: "Test connection", + callback: () => this.testConnection(), + }); + + this.addCommand({ + id: "sync-now", + name: "Sync now", + callback: () => this.syncNow(), + }); + + this.addCommand({ + id: "rebuild-index", + name: "Rebuild local index", + callback: () => this.rebuildIndex(), + }); + + this.addCommand({ + id: "index-stats", + name: "Show index stats", + callback: () => this.showIndexStats(), + }); + + this.addCommand({ + id: "inspect-remote", + name: "Inspect remote state (manifest + lock)", + callback: () => this.inspectRemoteState(), + }); + + this.addCommand({ + id: "force-release-lock", + name: "Force-release remote sync lock", + callback: () => this.forceReleaseLock(), + }); + + this.addCommand({ + id: "force-push", + name: "Force push everything (re-upload all files)", + callback: () => this.forcePush(), + }); + + this.addCommand({ + id: "pull", + name: "Pull from server", + callback: () => this.pullNow(), + }); + + this.addCommand({ + id: "force-pull", + name: "Force pull everything (re-download all files)", + callback: () => this.pullNow({ forceDownload: true }), + }); + + this.addCommand({ + id: "rebuild-remote-manifest", + name: "Rebuild remote manifest (walk server, re-hash)", + callback: () => this.rebuildRemoteManifestCmd(), + }); + + this.addCommand({ + id: "reset-snapshot", + name: "Reset local snapshot (treat next sync as fresh)", + callback: () => this.resetLocalSnapshot(), + }); + + // (e) Manual trigger: ribbon icon (in addition to the status bar click). + this.addRibbonIcon("refresh-cw", "Vault Bridge: Sync now", () => this.syncNow()); + + // (a) Startup: initial scan, then auto-sync if enabled. + this.app.workspace.onLayoutReady(() => { + void this.startupRoutine(); + }); + + this.registerVaultEvents(); + this.rebuildAutoSyncDebouncer(); + } + + async onunload() { + // (b) Quit-time best-effort push — fire and forget with a timeout. + if (this.startupComplete && this.settings?.autoSyncOnQuit) { + try { + await this.bestEffortQuitPush(5000); + } catch (err) { + console.warn("Vault Bridge: quit-time push failed (ignoring)", err); + } + } + // Force a final flush so we don't lose recent updates. + if (this.index) await this.index.flush().catch(() => {}); + // Clean up any pending debounced calls. + for (const fn of this.pendingRefresh.values()) fn.cancel?.(); + this.pendingRefresh.clear(); + this.autoSyncDebouncer?.cancel?.(); + } + + async loadSettings() { + const raw = ((await this.loadData()) ?? {}) as Record; + + // Migration: drop fields that older versions wrote but no longer belong here. + // (deviceId/deviceLabel moved to state/device.json so they don't sync between machines.) + delete raw.deviceId; + delete raw.deviceLabel; + + // Migration: workspace.json is now handled by the syncWorkspaceJson toggle, not the + // user-editable exclude list. Strip the auto-added entries so the toggle actually works. + const RETIRED_AUTO_PATTERNS = new Set([ + ".obsidian/workspace.json", + ".obsidian/workspace-mobile.json", + ]); + if (Array.isArray(raw.excludePatterns)) { + raw.excludePatterns = (raw.excludePatterns as string[]).filter( + (p) => !RETIRED_AUTO_PATTERNS.has(p), + ); + } + + // Keep only fields declared in DEFAULT_SETTINGS — drops any other unknown keys. + const known: Record = {}; + for (const k of Object.keys(DEFAULT_SETTINGS)) { + if (k in raw) known[k] = raw[k]; + } + this.settings = Object.assign({}, DEFAULT_SETTINGS, known) as SftpSyncSettings; + } + + async saveSettings() { + await this.saveData(this.settings); + // Re-build matcher when settings change (toggles, exclude patterns). + if (this.exclude) { + this.exclude = new ExcludeMatcher(this.settings); + this.scanner = new Scanner(this.app, this.index, this.exclude); + } + // Re-create the debouncer so a changed delay takes effect. + this.rebuildAutoSyncDebouncer(); + } + + private rebuildAutoSyncDebouncer(): void { + this.autoSyncDebouncer?.cancel?.(); + const delayMs = Math.max(2, this.settings?.autoSyncDebounceSeconds ?? 10) * 1000; + this.autoSyncDebouncer = debounce( + () => { void this.autoSync("change"); }, + delayMs, + false, + ); + } + + /** Schedule the on-change auto-sync (does nothing if (c) trigger is disabled). */ + private scheduleAutoSync(): void { + if (!this.startupComplete) return; // ignore events during startup + if (this.syncInProgress) return; // engine updates index directly + if (!this.settings.autoSyncOnChange) return; + this.autoSyncDebouncer?.(); + } + + private async startupRoutine(): Promise { + await this.initialScan(); + this.startupComplete = true; + if (this.settings.autoSyncOnStartup) { + // Brief delay so any open files settle before we start poking the network. + setTimeout(() => { void this.autoSync("startup"); }, 1000); + } + } + + /** Auto-sync: like syncNow, but quieter and auto-cancels bulk-delete prompts. */ + private async autoSync(reason: "startup" | "change"): Promise { + if (this.syncInProgress) { + console.log(`Vault Bridge: auto-sync (${reason}) skipped — already running`); + return; + } + this.syncInProgress = true; + this.setStatus("syncing"); + console.log(`Vault Bridge: auto-sync triggered (${reason})`); + try { + const engine = new SyncEngine( + this.app, this.settings, this.deviceStore, + this.index, this.scanner, this.exclude, this.lastSynced, + ); + const result = await engine.syncBoth({ + onProgress: (p) => { + this.setStatus(p.total === 0 ? "syncing" : `syncing ${p.processed}/${p.total}`); + }, + // For unattended syncs we never want to block on a modal — auto-cancel and tell the user. + confirmBulkDelete: async (info) => { + new Notice( + `Vault Bridge: auto-sync paused — ${info.outgoingDeletes.length + info.incomingDeletes.length} pending deletions need review. Run "Sync now" manually.`, + 10000, + ); + return "cancel"; + }, + }); + + const c = result.counts; + const secs = (result.took / 1000).toFixed(1); + // Quiet: only show Notice if something actually changed or we hit a special state. + if (result.cancelled) { + // Bulk-delete cancellation already showed its own Notice; nothing more. + } else if (c.ioOps > 0 || result.conflictCopies.length > 0) { + const parts: string[] = []; + if (c.push) parts.push(`pushed ${c.push}`); + if (c.pull) parts.push(`pulled ${c.pull}`); + if (c.deleteRemote) parts.push(`del-remote ${c.deleteRemote}`); + if (c.deleteLocal) parts.push(`del-local ${c.deleteLocal}`); + if (c.conflict) parts.push(`conflicts ${c.conflict}`); + if (c.restore) parts.push(`restored ${c.restore}`); + let msg = `Vault Bridge (${reason}): ${parts.join(", ")} (${secs}s)`; + if (result.conflictCopies.length) { + msg += `\nConflict copies: ${result.conflictCopies.length}`; + } + new Notice(msg, 6000); + } + // For "nothing to do" we don't spam the user; just status bar. + this.setStatus("ok"); + } catch (err) { + console.error(`Vault Bridge: auto-sync (${reason}) failed`, err); + new Notice(`Vault Bridge auto-sync failed — ${(err as Error).message}`, 8000); + this.setStatus("error"); + } finally { + this.syncInProgress = false; + } + } + + /** (b) On quit: push-only with hard timeout. No prompts, no pulls. */ + private async bestEffortQuitPush(timeoutMs: number): Promise { + const work = (async () => { + const engine = new PushEngine( + this.app, this.settings, this.deviceStore, + this.index, this.scanner, this.lastSynced, + ); + await engine.pushAll(); + })(); + const timeout = new Promise((_, reject) => + setTimeout(() => reject(new Error(`quit-push timed out after ${timeoutMs}ms`)), timeoutMs), + ); + await Promise.race([work, timeout]); + } + + // ────────────────────────────────────────────────────────────────────────── + + private async initialScan(): Promise { + // First-ever load: empty index → full scan and hash everything. + // Subsequent loads: index exists → cheap mtime check. + const isFirstScan = this.index.size() === 0; + if (isFirstScan) { + new Notice("Vault Bridge: building initial index…", 3000); + } + try { + const { entries, took } = await this.scanner.fullScan(); + console.log(`Vault Bridge: scan finished — ${entries.length} files in ${took}ms`); + if (isFirstScan) { + new Notice(`Vault Bridge: indexed ${entries.length} files (${took}ms)`, 4000); + } + } catch (err) { + console.error("Vault Bridge: initial scan failed", err); + new Notice(`Vault Bridge: initial scan failed — ${(err as Error).message}`, 8000); + } + } + + private registerVaultEvents(): void { + // Vault events fire only for files Obsidian tracks (markdown, canvas, attachments). + // Changes inside .obsidian/ are caught at sync time via a re-scan, not here. + this.registerEvent( + this.app.vault.on("create", (f) => { + this.queueRefresh(f); + this.scheduleAutoSync(); + }), + ); + this.registerEvent( + this.app.vault.on("modify", (f) => { + this.queueRefresh(f); + this.scheduleAutoSync(); + }), + ); + this.registerEvent( + this.app.vault.on("delete", (f) => { + if (this.exclude.isExcluded(f.path)) return; + // Cancel any pending refresh — file is gone. + this.pendingRefresh.get(f.path)?.cancel?.(); + this.pendingRefresh.delete(f.path); + this.index.remove(f.path); + this.scheduleAutoSync(); + }), + ); + this.registerEvent( + this.app.vault.on("rename", (f, oldPath: string) => { + if (this.exclude.isExcluded(f.path) && this.exclude.isExcluded(oldPath)) return; + this.pendingRefresh.get(oldPath)?.cancel?.(); + this.pendingRefresh.delete(oldPath); + if (this.exclude.isExcluded(f.path)) { + this.index.remove(oldPath); + } else { + this.index.rename(oldPath, f.path); + // mtime/size unchanged on rename, but queue a verify in case. + this.queueRefresh(f); + } + this.scheduleAutoSync(); + }), + ); + } + + private queueRefresh(f: TAbstractFile): void { + if (!(f instanceof TFile)) return; + // Don't react to our own writes during a sync. The engines update the index directly. + if (this.syncInProgress) return; + if (this.exclude.isExcluded(f.path)) return; + + let fn = this.pendingRefresh.get(f.path); + if (!fn) { + fn = debounce( + () => { + this.pendingRefresh.delete(f.path); + void this.scanner.refreshOne(f.path).catch((err) => + console.warn(`Vault Bridge: refresh failed for ${f.path}`, err), + ); + }, + 2000, + true, + ); + this.pendingRefresh.set(f.path, fn); + } + fn(); + } + + // ────────────────────────────────────────────────────────────────────────── + // Commands + + async testConnection(): Promise { + const client = new SftpClient(this.settings); + try { + await client.connect(); + await client.ensureRemoteRoot(); + new Notice("Vault Bridge: connection OK", 4000); + } catch (err) { + console.error("Vault Bridge: connection failed", err); + new Notice(`Vault Bridge: connection FAILED — ${(err as Error).message}`, 8000); + } finally { + await client.end(); + } + } + + /** Bidirectional sync — the everyday command. Uses SyncEngine (3-way diff). */ + async syncNow(): Promise { + if (this.syncInProgress) { + new Notice("Vault Bridge: already running", 3000); + return; + } + this.syncInProgress = true; + this.setStatus("syncing"); + try { + const engine = new SyncEngine( + this.app, + this.settings, + this.deviceStore, + this.index, + this.scanner, + this.exclude, + this.lastSynced, + ); + const result = await engine.syncBoth({ + onProgress: (p) => { + if (p.total === 0) { + this.setStatus("syncing"); + } else { + this.setStatus(`syncing ${p.processed}/${p.total}`); + } + }, + confirmBulkDelete: (info) => askBulkDeleteDecision(this.app, info), + }); + + const c = result.counts; + const secs = (result.took / 1000).toFixed(1); + + // Server-reset path: hand off to a recovery dialog instead of any normal Notice. + if (result.serverReset) { + await this.handleServerReset(result.serverReset); + this.setStatus("idle"); + return; + } + + if (result.cancelled) { + new Notice( + `Vault Bridge: cancelled by user (no changes made, ${secs}s)`, + 5000, + ); + } else if (result.noChanges || c.ioOps === 0) { + new Notice( + `Vault Bridge: nothing to do — already in sync (gen=${result.generation}, ${secs}s)`, + 5000, + ); + } else { + const parts: string[] = []; + if (c.push) parts.push(`pushed ${c.push}`); + if (c.pull) parts.push(`pulled ${c.pull}`); + if (c.deleteRemote) parts.push(`del-remote ${c.deleteRemote}`); + if (c.deleteLocal) parts.push(`del-local ${c.deleteLocal}`); + if (c.conflict) parts.push(`conflicts ${c.conflict}`); + if (c.restore) parts.push(`restored ${c.restore}`); + + let msg = `Vault Bridge: ${parts.join(", ")} (gen=${result.generation}, ${secs}s)`; + if (result.deletesSkipped) { + msg += "\n⚠️ Deletes were skipped at user request — re-run sync to address them."; + } + if (result.conflictCopies.length) { + msg += `\nConflict copies created:\n ${result.conflictCopies.join("\n ")}`; + } + if (result.selfHealedMissing?.length) { + msg += `\n⚠️ ${result.selfHealedMissing.length} file(s) were missing on server — dropped from manifest.`; + } + new Notice(msg, 10000); + } + + this.setStatus("ok"); + } catch (err) { + console.error("Vault Bridge: sync failed", err); + new Notice(`Vault Bridge: sync FAILED — ${(err as Error).message}`, 10000); + this.setStatus("error"); + } finally { + this.syncInProgress = false; + } + } + + /** Coordinate the server-reset recovery flow via a modal dialog. */ + private async handleServerReset(info: NonNullable>["serverReset"]>): Promise { + const choice = await askServerResetDecision(this.app, info); + if (choice === "cancel") { + new Notice("Vault Bridge: server-reset cancelled — no changes made.", 5000); + return; + } + if (choice === "force-push") { + // syncInProgress is already true here (from syncNow), so call the inner method + // directly — wrapping forcePush() would short-circuit on its in-progress guard. + new Notice("Vault Bridge: forcing push from local…", 3000); + try { + await this._runForcePush(); + } catch (err) { + // Already reported by _runForcePush; nothing more to do. + } + return; + } + if (choice === "reset-snapshot") { + await this.resetLocalSnapshot({ silent: true }); + new Notice( + "Vault Bridge: local snapshot reset. Next 'Sync now' will treat all files as fresh additions.", + 6000, + ); + } + } + + /** Wipe state/last-synced.json. Used by server-reset modal and as a manual recovery command. */ + async resetLocalSnapshot(opts: { silent?: boolean } = {}): Promise { + const adapter = this.app.vault.adapter; + try { + if ((await adapter.exists(STATE_PATHS.lastSynced)) === true) { + await adapter.remove(STATE_PATHS.lastSynced); + } + await this.lastSynced.load(); // re-loads as empty + if (!opts.silent) { + new Notice("Vault Bridge: local snapshot reset.", 5000); + } + } catch (err) { + console.error("Vault Bridge: snapshot reset failed", err); + new Notice(`Vault Bridge: snapshot reset failed — ${(err as Error).message}`, 8000); + } + } + + /** Walk the remote vault, hash every file, rewrite manifest.json. Recovery for scenario 1. */ + async rebuildRemoteManifestCmd(): Promise { + if (this.syncInProgress) { + new Notice("Vault Bridge: another operation is running, try again later", 4000); + return; + } + this.syncInProgress = true; + this.setStatus("rebuilding"); + try { + new Notice("Vault Bridge: walking server and rebuilding manifest — this can take a while…", 4000); + const result = await rebuildRemoteManifest( + this.settings, + this.deviceStore, + this.exclude, + (p) => { + if (p.total != null) this.setStatus(`rebuild ${p.scanned}/${p.total}`); + else this.setStatus("rebuild …"); + }, + ); + const mb = (result.totalBytes / (1024 * 1024)).toFixed(2); + const secs = (result.took / 1000).toFixed(1); + new Notice( + `Vault Bridge: manifest rebuilt — ${result.filesIndexed} files (${mb} MB) in ${secs}s (gen=${result.generation}). Run 'Sync now' to reconcile locally.`, + 8000, + ); + this.setStatus("ok"); + } catch (err) { + console.error("Vault Bridge: rebuild failed", err); + new Notice(`Vault Bridge: rebuild FAILED — ${(err as Error).message}`, 10000); + this.setStatus("error"); + } finally { + this.syncInProgress = false; + } + } + + /** Force-push: re-upload everything without 3-way diff. Use after manifest corruption. */ + async forcePush(): Promise { + if (this.syncInProgress) { + new Notice("Vault Bridge: already running", 3000); + return; + } + this.syncInProgress = true; + try { + await this._runForcePush(); + } finally { + this.syncInProgress = false; + } + } + + /** Inner force-push body (no syncInProgress handling). Reused by handleServerReset. */ + private async _runForcePush(): Promise { + this.setStatus("force-pushing"); + try { + const engine = new PushEngine( + this.app, + this.settings, + this.deviceStore, + this.index, + this.scanner, + this.lastSynced, + ); + const result = await engine.pushAll({ + forceUpload: true, + onProgress: (p) => { + const toUpload = p.total - p.skipped; + this.setStatus(`force-push ${p.uploaded}/${toUpload}`); + }, + }); + const mb = (result.totalBytes / (1024 * 1024)).toFixed(2); + const secs = (result.took / 1000).toFixed(1); + new Notice( + `Vault Bridge: force-pushed ${result.uploaded} files (${mb} MB) in ${secs}s (gen=${result.generation})`, + 6000, + ); + this.setStatus("ok"); + } catch (err) { + console.error("Vault Bridge: force-push failed", err); + new Notice(`Vault Bridge: force-push FAILED — ${(err as Error).message}`, 10000); + this.setStatus("error"); + throw err; + } + } + + async pullNow(opts: { forceDownload?: boolean } = {}): Promise { + if (this.syncInProgress) { + new Notice("Vault Bridge: already running", 3000); + return; + } + this.syncInProgress = true; + this.setStatus(opts.forceDownload ? "force-pulling" : "pulling"); + try { + const engine = new PullEngine( + this.app, + this.settings, + this.deviceStore, + this.index, + this.scanner, + this.exclude, + this.lastSynced, + ); + const result = await engine.pullAll({ + forceDownload: opts.forceDownload, + onProgress: (p) => { + const toDownload = p.total - p.skipped; + this.setStatus(`pulling ${p.downloaded}/${toDownload}`); + }, + }); + const mb = (result.totalBytes / (1024 * 1024)).toFixed(2); + const secs = (result.took / 1000).toFixed(1); + const msg = + result.downloaded === 0 + ? `Vault Bridge: nothing to pull (${result.skipped} already up to date, ${secs}s, gen=${result.generation})` + : `Vault Bridge: pulled ${result.downloaded} files (${mb} MB), skipped ${result.skipped} up-to-date, in ${secs}s (gen=${result.generation})`; + new Notice(msg, 6000); + this.setStatus("ok"); + } catch (err) { + console.error("Vault Bridge: pull failed", err); + new Notice(`Vault Bridge: pull FAILED — ${(err as Error).message}`, 10000); + this.setStatus("error"); + } finally { + this.syncInProgress = false; + } + } + + private setStatus(s: string): void { + if (!this.statusBar) return; + const icon = + s === "idle" ? "↻" : + s === "ok" ? "✓" : + s === "error" ? "✗" : + "⟳"; + this.statusBar.setText(`SFTP ${icon} ${s}`); + } + + async rebuildIndex(): Promise { + new Notice("Vault Bridge: rebuilding local index…", 3000); + try { + const { entries, took } = await this.scanner.fullScan({ forceRehash: true }); + new Notice(`Vault Bridge: rebuilt — ${entries.length} files (${took}ms)`, 4000); + } catch (err) { + console.error("Vault Bridge: rebuild failed", err); + new Notice(`Vault Bridge: rebuild failed — ${(err as Error).message}`, 8000); + } + } + + showIndexStats(): void { + const n = this.index.size(); + const mb = (this.index.totalBytes() / (1024 * 1024)).toFixed(2); + const last = this.index.lastScannedAt(); + const ago = last === 0 ? "never" : `${Math.round((Date.now() - last) / 1000)}s ago`; + new Notice(`Vault Bridge: ${n} files, ${mb} MB, last full scan: ${ago}`, 6000); + } + + /** Connect, read manifest + lock, print summary. Used to verify Phase 3. */ + async inspectRemoteState(): Promise { + const client = new SftpClient(this.settings); + try { + await client.connect(); + await client.ensureRemoteRoot(); + const remote = new RemoteState( + client, + this.settings.remoteRoot, + this.deviceStore.id, + this.deviceStore.label, + ); + await remote.ensureSyncDir(); + + const manifest = await remote.readManifest(); + const lock = await remote.readLock(); + + const manifestSummary = manifest.generation === 0 + ? "manifest: empty (no sync yet)" + : `manifest: gen=${manifest.generation}, ${Object.keys(manifest.entries).length} files, last writer: ${manifest.lastWriterLabel} at ${new Date(manifest.updatedAt).toLocaleString()}`; + + const lockSummary = lock + ? `lock: HELD by ${lock.deviceLabel} (${lock.deviceId.slice(0, 6)}), age ${Math.round((Date.now() - lock.acquiredAt) / 1000)}s` + : "lock: free"; + + new Notice(`Vault Bridge remote state\n${manifestSummary}\n${lockSummary}`, 10000); + console.log("Vault Bridge remote state", { manifest, lock }); + } catch (err) { + console.error("Vault Bridge: inspect failed", err); + new Notice(`Vault Bridge: inspect failed — ${(err as Error).message}`, 8000); + } finally { + await client.end(); + } + } + + /** Manually break the lock (only ours; foreign locks are refused). Use after a crash. */ + async forceReleaseLock(): Promise { + const client = new SftpClient(this.settings); + try { + await client.connect(); + const remote = new RemoteState( + client, + this.settings.remoteRoot, + this.deviceStore.id, + this.deviceStore.label, + ); + const before = await remote.readLock(); + if (!before) { + new Notice("Vault Bridge: no lock to release", 4000); + return; + } + if (before.deviceId !== this.deviceStore.id) { + new Notice( + `Vault Bridge: lock is held by ${before.deviceLabel}, not us. Won't touch it. (Wait until it goes stale, ~5min.)`, + 8000, + ); + return; + } + await remote.releaseLock(); + new Notice("Vault Bridge: lock released", 4000); + } catch (err) { + console.error("Vault Bridge: force release failed", err); + new Notice(`Vault Bridge: force release failed — ${(err as Error).message}`, 8000); + } finally { + await client.end(); + } + } +} diff --git a/src/settings.ts b/src/settings.ts new file mode 100644 index 0000000..04b6585 --- /dev/null +++ b/src/settings.ts @@ -0,0 +1,307 @@ +import { App, PluginSettingTab, Setting, Notice } from "obsidian"; +import type SftpSyncPlugin from "./main"; + +export type AuthMethod = "password" | "key"; + +export interface SftpSyncSettings { + host: string; + port: number; + username: string; + authMethod: AuthMethod; + password: string; + privateKeyPath: string; + passphrase: string; + remoteRoot: string; + + syncEverything: boolean; // sync .obsidian/ too (default ON, per user request) + syncWorkspaceJson: boolean; // sync workspace.json (default OFF — flapping risk) + excludePatterns: string[]; // gitignore-style, user-editable + + autoSyncOnStartup: boolean; // (a) full sync after Obsidian opens + autoSyncOnQuit: boolean; // (b) push on close (best-effort, with timeout) + autoSyncOnChange: boolean; // (c) debounced sync after vault changes + autoSyncDebounceSeconds: number; // debounce window for (c). Default 10. +} +// NOTE: deviceId / deviceLabel are NOT here — they live in state/device.json +// so that data.json can be safely shared across devices via sync. + +// Default soft excludes — user can edit these. +// workspace.json is handled by a separate toggle, NOT listed here. +export const DEFAULT_SOFT_EXCLUDES = [ + ".trash/**", +]; + +export const DEFAULT_SETTINGS: SftpSyncSettings = { + host: "", + port: 22, + username: "", + authMethod: "password", + password: "", + privateKeyPath: "", + passphrase: "", + remoteRoot: "", + syncEverything: true, + syncWorkspaceJson: false, + excludePatterns: [...DEFAULT_SOFT_EXCLUDES], + autoSyncOnStartup: true, + autoSyncOnQuit: true, + autoSyncOnChange: true, + autoSyncDebounceSeconds: 10, +}; + +export class SftpSyncSettingTab extends PluginSettingTab { + plugin: SftpSyncPlugin; + + constructor(app: App, plugin: SftpSyncPlugin) { + super(app, plugin); + this.plugin = plugin; + } + + display(): void { + const { containerEl } = this; + containerEl.empty(); + + containerEl.createEl("h2", { text: "Vault Bridge SFTP" }); + + // ─── Connection ─────────────────────────────────────────────────────── + containerEl.createEl("h3", { text: "Connection" }); + + new Setting(containerEl) + .setName("Host") + .setDesc("SFTP server hostname or IP address.") + .addText((t) => + t + .setPlaceholder("example.com") + .setValue(this.plugin.settings.host) + .onChange(async (v) => { + this.plugin.settings.host = v.trim(); + await this.plugin.saveSettings(); + }), + ); + + new Setting(containerEl) + .setName("Port") + .setDesc("Default 22.") + .addText((t) => + t + .setPlaceholder("22") + .setValue(String(this.plugin.settings.port)) + .onChange(async (v) => { + const n = parseInt(v, 10); + this.plugin.settings.port = Number.isFinite(n) && n > 0 ? n : 22; + await this.plugin.saveSettings(); + }), + ); + + new Setting(containerEl) + .setName("Username") + .addText((t) => + t + .setValue(this.plugin.settings.username) + .onChange(async (v) => { + this.plugin.settings.username = v.trim(); + await this.plugin.saveSettings(); + }), + ); + + new Setting(containerEl) + .setName("Authentication") + .setDesc("Password or SSH private key. Host fingerprint is NOT verified by user request.") + .addDropdown((d) => + d + .addOption("password", "Password") + .addOption("key", "Private key") + .setValue(this.plugin.settings.authMethod) + .onChange(async (v) => { + this.plugin.settings.authMethod = v as AuthMethod; + await this.plugin.saveSettings(); + this.display(); + }), + ); + + if (this.plugin.settings.authMethod === "password") { + new Setting(containerEl) + .setName("Password") + .setDesc("Stored in plain text inside data.json — be aware on shared machines.") + .addText((t) => { + t.inputEl.type = "password"; + t.setValue(this.plugin.settings.password).onChange(async (v) => { + this.plugin.settings.password = v; + await this.plugin.saveSettings(); + }); + }); + } else { + new Setting(containerEl) + .setName("Private key path") + .setDesc("Absolute filesystem path, e.g. /home/user/.ssh/id_ed25519.") + .addText((t) => + t + .setPlaceholder("/home/user/.ssh/id_ed25519") + .setValue(this.plugin.settings.privateKeyPath) + .onChange(async (v) => { + this.plugin.settings.privateKeyPath = v.trim(); + await this.plugin.saveSettings(); + }), + ); + + new Setting(containerEl) + .setName("Key passphrase") + .setDesc("Empty if the key is unencrypted.") + .addText((t) => { + t.inputEl.type = "password"; + t.setValue(this.plugin.settings.passphrase).onChange(async (v) => { + this.plugin.settings.passphrase = v; + await this.plugin.saveSettings(); + }); + }); + } + + new Setting(containerEl) + .setName("Remote root") + .setDesc("Absolute path on the server, e.g. /home/user/obsidian-vault. Created if it doesn't exist.") + .addText((t) => + t + .setPlaceholder("/home/user/obsidian-vault") + .setValue(this.plugin.settings.remoteRoot) + .onChange(async (v) => { + this.plugin.settings.remoteRoot = v.trim(); + await this.plugin.saveSettings(); + }), + ); + + // ─── Sync scope ─────────────────────────────────────────────────────── + containerEl.createEl("h3", { text: "Sync scope" }); + + new Setting(containerEl) + .setName("Sync everything (.obsidian too)") + .setDesc("When ON, plugins/themes/snippets/hotkeys are synced so all devices look identical.") + .addToggle((t) => + t.setValue(this.plugin.settings.syncEverything).onChange(async (v) => { + this.plugin.settings.syncEverything = v; + await this.plugin.saveSettings(); + }), + ); + + new Setting(containerEl) + .setName("Sync workspace.json") + .setDesc("OFF by default. Workspace files describe open tabs/panels for this specific device. Turning this ON will cause flapping if you work on two devices at once.") + .addToggle((t) => + t.setValue(this.plugin.settings.syncWorkspaceJson).onChange(async (v) => { + this.plugin.settings.syncWorkspaceJson = v; + await this.plugin.saveSettings(); + }), + ); + + new Setting(containerEl) + .setName("Exclude patterns") + .setDesc("One pattern per line. Gitignore-style. The plugin's own state/ directory is ALWAYS excluded regardless.") + .addTextArea((t) => { + t.inputEl.rows = 6; + t.inputEl.style.width = "100%"; + t.setValue(this.plugin.settings.excludePatterns.join("\n")).onChange(async (v) => { + this.plugin.settings.excludePatterns = v + .split("\n") + .map((s) => s.trim()) + .filter((s) => s.length > 0); + await this.plugin.saveSettings(); + }); + }); + + // ─── Auto-sync triggers ─────────────────────────────────────────────── + containerEl.createEl("h3", { text: "Auto-sync" }); + + new Setting(containerEl) + .setName("Sync on startup") + .setDesc("Run a full bidirectional sync once Obsidian finishes loading.") + .addToggle((t) => + t.setValue(this.plugin.settings.autoSyncOnStartup).onChange(async (v) => { + this.plugin.settings.autoSyncOnStartup = v; + await this.plugin.saveSettings(); + }), + ); + + new Setting(containerEl) + .setName("Sync on quit") + .setDesc("Best-effort push when Obsidian closes (5s timeout, push-only — no prompts).") + .addToggle((t) => + t.setValue(this.plugin.settings.autoSyncOnQuit).onChange(async (v) => { + this.plugin.settings.autoSyncOnQuit = v; + await this.plugin.saveSettings(); + }), + ); + + new Setting(containerEl) + .setName("Sync after changes") + .setDesc("Debounced sync triggered by vault edits (create / modify / delete / rename).") + .addToggle((t) => + t.setValue(this.plugin.settings.autoSyncOnChange).onChange(async (v) => { + this.plugin.settings.autoSyncOnChange = v; + await this.plugin.saveSettings(); + }), + ); + + new Setting(containerEl) + .setName("Debounce delay (seconds)") + .setDesc("How long to wait after the last edit before auto-syncing. Lower = more sync traffic.") + .addText((t) => + t + .setValue(String(this.plugin.settings.autoSyncDebounceSeconds)) + .onChange(async (v) => { + const n = parseInt(v, 10); + this.plugin.settings.autoSyncDebounceSeconds = + Number.isFinite(n) && n >= 2 && n <= 600 ? n : 10; + await this.plugin.saveSettings(); + }), + ); + + // ─── Actions ────────────────────────────────────────────────────────── + containerEl.createEl("h3", { text: "Actions" }); + + new Setting(containerEl) + .setName("Test connection") + .setDesc("Connect, create the remote root if missing, then disconnect.") + .addButton((b) => + b + .setButtonText("Test connection") + .setCta() + .onClick(async () => { + b.setDisabled(true).setButtonText("Testing…"); + try { + await this.plugin.testConnection(); + } finally { + b.setDisabled(false).setButtonText("Test connection"); + } + }), + ); + + new Setting(containerEl) + .setName("Sync now") + .setDesc("Run a full sync immediately. (Available once Phase 4+ ships.)") + .addButton((b) => + b.setButtonText("Sync now").onClick(() => this.plugin.syncNow()), + ); + + // ─── Device ─────────────────────────────────────────────────────────── + containerEl.createEl("h3", { text: "Device (local only, not synced)" }); + + new Setting(containerEl) + .setName("Device label") + .setDesc("Used in conflict-copy filenames so you can tell which device the conflicting edit came from. Lives in state/device.json — each machine has its own.") + .addText((t) => + t + .setPlaceholder("home-desktop") + .setValue(this.plugin.deviceStore.label) + .onChange(async (v) => { + await this.plugin.deviceStore.setLabel(v); + }), + ); + + new Setting(containerEl) + .setName("Device ID") + .setDesc("Random per-device identifier. Read-only.") + .addText((t) => { + t.setValue(this.plugin.deviceStore.id); + t.setDisabled(true); + }); + } +} diff --git a/src/sftp/client.ts b/src/sftp/client.ts new file mode 100644 index 0000000..6ba48b1 --- /dev/null +++ b/src/sftp/client.ts @@ -0,0 +1,69 @@ +import SftpClientLib from "ssh2-sftp-client"; +import { readFileSync } from "fs"; +import type { SftpSyncSettings } from "../settings"; + +export class SftpClient { + private client: SftpClientLib; + private connected = false; + + constructor(private settings: SftpSyncSettings) { + this.client = new SftpClientLib(); + } + + async connect(): Promise { + if (this.connected) return; + if (!this.settings.host) throw new Error("Host is empty"); + if (!this.settings.username) throw new Error("Username is empty"); + if (!this.settings.remoteRoot) throw new Error("Remote root is empty"); + + const config: Parameters[0] = { + host: this.settings.host, + port: this.settings.port || 22, + username: this.settings.username, + readyTimeout: 15000, + // No host fingerprint check — by user request. + }; + + if (this.settings.authMethod === "password") { + if (!this.settings.password) throw new Error("Password is empty"); + config.password = this.settings.password; + } else { + if (!this.settings.privateKeyPath) throw new Error("Private key path is empty"); + try { + config.privateKey = readFileSync(this.settings.privateKeyPath); + } catch (err) { + throw new Error(`Cannot read private key at ${this.settings.privateKeyPath}: ${(err as Error).message}`); + } + if (this.settings.passphrase) config.passphrase = this.settings.passphrase; + } + + await this.client.connect(config); + this.connected = true; + } + + async end(): Promise { + if (!this.connected) return; + try { + await this.client.end(); + } catch { + // ignore — we're tearing down + } + this.connected = false; + } + + /** Create remote root directory if it doesn't already exist. */ + async ensureRemoteRoot(): Promise { + const root = this.settings.remoteRoot; + const exists = await this.client.exists(root); + if (exists === false) { + await this.client.mkdir(root, true); + } else if (exists !== "d") { + throw new Error(`Remote path ${root} exists but is not a directory (type=${exists})`); + } + } + + /** Direct access to the underlying ssh2-sftp-client. Used by sync engine in later phases. */ + get raw(): SftpClientLib { + return this.client; + } +} diff --git a/src/sftp/remote-state.ts b/src/sftp/remote-state.ts new file mode 100644 index 0000000..8bb1834 --- /dev/null +++ b/src/sftp/remote-state.ts @@ -0,0 +1,194 @@ +import type { SftpClient } from "./client"; + +const SYNC_DIR_NAME = ".sync"; +const MANIFEST_NAME = "manifest.json"; +const LOCK_NAME = "lock.json"; + +const STALE_LOCK_MS = 5 * 60 * 1000; // 5 minutes +const RACE_VERIFY_MS = 500; // re-read after writing lock to detect contention + +export interface ManifestEntry { + mtime: number; + size: number; + sha1: string; +} + +export interface RemoteManifest { + schemaVersion: 1; + generation: number; + lastWriter: string; // deviceId + lastWriterLabel: string; // human-readable + updatedAt: number; + entries: Record; +} + +export interface SyncLock { + deviceId: string; + deviceLabel: string; + acquiredAt: number; + pid?: number; +} + +export const EMPTY_MANIFEST: RemoteManifest = { + schemaVersion: 1, + generation: 0, + lastWriter: "", + lastWriterLabel: "", + updatedAt: 0, + entries: {}, +}; + +/** Helper: build paths inside .sync/ on the remote. */ +function syncPaths(remoteRoot: string) { + const root = remoteRoot.replace(/\/+$/, ""); + return { + syncDir: `${root}/${SYNC_DIR_NAME}`, + manifest: `${root}/${SYNC_DIR_NAME}/${MANIFEST_NAME}`, + lock: `${root}/${SYNC_DIR_NAME}/${LOCK_NAME}`, + }; +} + +export class RemoteState { + constructor( + private sftp: SftpClient, + private remoteRoot: string, + private deviceId: string, + private deviceLabel: string, + ) {} + + /** Make sure the remoteRoot/.sync/ directory exists. */ + async ensureSyncDir(): Promise { + const { syncDir } = syncPaths(this.remoteRoot); + const ex = await this.sftp.raw.exists(syncDir); + if (ex === false) { + await this.sftp.raw.mkdir(syncDir, true); + } else if (ex !== "d") { + throw new Error(`Remote ${syncDir} exists but is not a directory`); + } + } + + // ─── Manifest ─────────────────────────────────────────────────────────── + + async readManifest(): Promise { + const { manifest } = syncPaths(this.remoteRoot); + const ex = await this.sftp.raw.exists(manifest); + if (ex === false) return { ...EMPTY_MANIFEST }; + try { + const buf = (await this.sftp.raw.get(manifest)) as Buffer; + const parsed = JSON.parse(buf.toString("utf8")) as RemoteManifest; + if (parsed.schemaVersion !== 1) throw new Error("unsupported manifest schema"); + return parsed; + } catch (err) { + throw new Error(`Cannot read remote manifest at ${manifest}: ${(err as Error).message}`); + } + } + + /** Write a new manifest atomically (tmp + rename). Caller is responsible for bumping generation. */ + async writeManifest(m: RemoteManifest): Promise { + await this.ensureSyncDir(); + const { manifest } = syncPaths(this.remoteRoot); + const tmp = `${manifest}.tmp.${Date.now()}.${Math.random().toString(36).slice(2, 8)}`; + const json = JSON.stringify(m); + const buf = Buffer.from(json, "utf8"); + await this.sftp.raw.put(buf, tmp); + // ssh2-sftp-client's posixRename is atomic when both paths are on the same filesystem. + // Fallback to delete+rename if posixRename isn't supported. + try { + await this.sftp.raw.posixRename(tmp, manifest); + } catch { + if (await this.sftp.raw.exists(manifest)) { + await this.sftp.raw.delete(manifest); + } + await this.sftp.raw.rename(tmp, manifest); + } + } + + // ─── Lock ─────────────────────────────────────────────────────────────── + + async readLock(): Promise { + const { lock } = syncPaths(this.remoteRoot); + if ((await this.sftp.raw.exists(lock)) === false) return null; + try { + const buf = (await this.sftp.raw.get(lock)) as Buffer; + return JSON.parse(buf.toString("utf8")) as SyncLock; + } catch { + return null; + } + } + + /** + * Try to acquire the sync lock. + * Returns the lock we wrote on success; null on contention. + * Stale locks (older than STALE_LOCK_MS) are forcibly broken. + */ + async acquireLock(): Promise { + await this.ensureSyncDir(); + const { lock: lockPath } = syncPaths(this.remoteRoot); + + const existing = await this.readLock(); + if (existing) { + const age = Date.now() - existing.acquiredAt; + const isOurs = existing.deviceId === this.deviceId; + const isStale = age >= STALE_LOCK_MS; + + if (!isOurs && !isStale) { + // Active lock held by another device. + return null; + } + if (isStale && !isOurs) { + console.warn( + `Vault Bridge: breaking stale lock from "${existing.deviceLabel}" (age ${Math.round(age / 1000)}s)`, + ); + } + // Either ours (re-entry) or stale — fall through to overwrite. + } + + const our: SyncLock = { + deviceId: this.deviceId, + deviceLabel: this.deviceLabel, + acquiredAt: Date.now(), + pid: typeof process !== "undefined" ? process.pid : undefined, + }; + await this.sftp.raw.put(Buffer.from(JSON.stringify(our), "utf8"), lockPath); + + // Race verification: pause briefly then re-read to make sure no other client + // wrote on top of us during the window. + await new Promise((r) => setTimeout(r, RACE_VERIFY_MS)); + const after = await this.readLock(); + if (!after || after.deviceId !== our.deviceId || after.acquiredAt !== our.acquiredAt) { + return null; + } + return our; + } + + /** Release the lock — only if it still belongs to us. */ + async releaseLock(): Promise { + const { lock: lockPath } = syncPaths(this.remoteRoot); + const existing = await this.readLock(); + if (!existing) return; + if (existing.deviceId !== this.deviceId) { + console.warn("Vault Bridge: refusing to release a lock owned by another device"); + return; + } + try { + await this.sftp.raw.delete(lockPath); + } catch (err) { + console.warn("Vault Bridge: failed to delete lock", err); + } + } + + /** Convenience: run `fn` while holding the lock. Throws if the lock cannot be acquired. */ + async withLock(fn: () => Promise): Promise { + const got = await this.acquireLock(); + if (!got) { + const other = await this.readLock(); + const who = other ? `${other.deviceLabel} (${other.deviceId.slice(0, 6)})` : "another device"; + throw new Error(`Sync lock is held by ${who}; try again later.`); + } + try { + return await fn(); + } finally { + await this.releaseLock(); + } + } +} diff --git a/src/sftp/transfer.ts b/src/sftp/transfer.ts new file mode 100644 index 0000000..06b0fa2 --- /dev/null +++ b/src/sftp/transfer.ts @@ -0,0 +1,119 @@ +import type { DataAdapter } from "obsidian"; +import { SftpClient } from "./client"; +import { remotePathOf, parentDir } from "../sync/path-utils"; +import { STATE_PATHS } from "../state/paths"; + +/** Cache of remote directories already known to exist within a single sync transaction. */ +export class RemoteDirCache { + private known: Set; + + constructor(remoteRoot: string) { + this.known = new Set([remoteRoot.replace(/\/+$/, "")]); + } + + async ensureParentOf(client: SftpClient, remoteFilePath: string): Promise { + const parent = parentDir(remoteFilePath); + if (!parent || this.known.has(parent)) return; + if ((await client.raw.exists(parent)) === false) { + await client.raw.mkdir(parent, true); + } + this.known.add(parent); + // mark ancestors as ensured too — mkdir(..., recursive) created them + let p = parent; + while (true) { + const grand = parentDir(p); + if (!grand || this.known.has(grand)) break; + this.known.add(grand); + p = grand; + } + } +} + +/** Atomic remote upload of an in-memory buffer: put → posix-rename onto target. */ +export async function uploadBuffer( + client: SftpClient, + remoteRoot: string, + vaultPath: string, + content: Buffer, + dirs: RemoteDirCache, +): Promise { + const remotePath = remotePathOf(remoteRoot, vaultPath); + await dirs.ensureParentOf(client, remotePath); + + const tmp = `${remotePath}.tmp.${Date.now().toString(36)}.${Math.random().toString(36).slice(2, 8)}`; + await client.raw.put(content, tmp); + try { + await client.raw.posixRename(tmp, remotePath); + } catch { + if ((await client.raw.exists(remotePath)) !== false) { + await client.raw.delete(remotePath); + } + await client.raw.rename(tmp, remotePath); + } +} + +/** Read a local vault file as a Buffer. */ +export async function readLocalAsBuffer(adapter: DataAdapter, vaultPath: string): Promise { + const ab = await adapter.readBinary(vaultPath); + return Buffer.from(ab); +} + +/** Read a remote file as a Buffer (full content in memory). */ +export async function downloadToBuffer( + client: SftpClient, + remoteRoot: string, + vaultPath: string, +): Promise { + const remotePath = remotePathOf(remoteRoot, vaultPath); + const buf = (await client.raw.get(remotePath)) as Buffer; + return buf; +} + +/** Write a Buffer into the vault at `vaultPath`, atomically via state/tmp/. */ +export async function writeBufferToVault( + adapter: DataAdapter, + vaultPath: string, + buf: Buffer, +): Promise { + const parent = parentDir(vaultPath); + if (parent && (await adapter.exists(parent)) === false) { + await adapter.mkdir(parent); + } + if ((await adapter.exists(STATE_PATHS.tmp)) === false) { + await adapter.mkdir(STATE_PATHS.tmp); + } + const tmp = `${STATE_PATHS.tmp}/dl.${Date.now().toString(36)}.${Math.random().toString(36).slice(2, 8)}`; + const ab = buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength) as ArrayBuffer; + await adapter.writeBinary(tmp, ab); + try { + if ((await adapter.exists(vaultPath)) === true) { + await adapter.remove(vaultPath); + } + await adapter.rename(tmp, vaultPath); + } catch (err) { + try { await adapter.remove(tmp); } catch {} + throw err; + } +} + +/** Delete a file on the remote. No-op if it doesn't exist. */ +export async function deleteRemoteFile( + client: SftpClient, + remoteRoot: string, + vaultPath: string, +): Promise { + const remotePath = remotePathOf(remoteRoot, vaultPath); + if ((await client.raw.exists(remotePath)) !== false) { + await client.raw.delete(remotePath); + } +} + +/** Delete a file inside the vault. No-op if it doesn't exist. */ +export async function deleteLocalFile( + adapter: DataAdapter, + vaultPath: string, +): Promise { + if ((await adapter.exists(vaultPath)) === true) { + await adapter.remove(vaultPath); + } +} diff --git a/src/state/device-store.ts b/src/state/device-store.ts new file mode 100644 index 0000000..2e8b58c --- /dev/null +++ b/src/state/device-store.ts @@ -0,0 +1,59 @@ +import type { App, DataAdapter } from "obsidian"; +import { randomBytes } from "crypto"; +import { STATE_PATHS } from "./paths"; + +interface DeviceInfo { + id: string; + label: string; +} + +/** + * Per-device identity. Lives in state/device.json so it is NEVER synced — + * each machine must have its own id even when the rest of data.json is shared. + */ +export class DeviceStore { + private adapter: DataAdapter; + private info: DeviceInfo = { id: "", label: "" }; + + constructor(app: App) { + this.adapter = app.vault.adapter; + } + + get id(): string { return this.info.id; } + get label(): string { return this.info.label; } + + async load(): Promise { + let needSave = false; + try { + if (await this.adapter.exists(STATE_PATHS.device)) { + const raw = await this.adapter.read(STATE_PATHS.device); + this.info = JSON.parse(raw); + } + } catch (err) { + console.warn("Vault Bridge: failed to load device info, regenerating", err); + this.info = { id: "", label: "" }; + } + if (!this.info.id) { + this.info.id = randomBytes(8).toString("hex"); + needSave = true; + } + if (!this.info.label) { + this.info.label = `device-${this.info.id.slice(0, 6)}`; + needSave = true; + } + if (needSave) await this.save(); + } + + async setLabel(label: string): Promise { + const trimmed = label.trim(); + this.info.label = trimmed || `device-${this.info.id.slice(0, 6)}`; + await this.save(); + } + + private async save(): Promise { + if (!(await this.adapter.exists(STATE_PATHS.dir))) { + await this.adapter.mkdir(STATE_PATHS.dir); + } + await this.adapter.write(STATE_PATHS.device, JSON.stringify(this.info, null, 2)); + } +} diff --git a/src/state/paths.ts b/src/state/paths.ts new file mode 100644 index 0000000..97b3bbd --- /dev/null +++ b/src/state/paths.ts @@ -0,0 +1,17 @@ +// All paths returned here are vault-relative, suitable for app.vault.adapter.* calls. + +export const PLUGIN_ID = "vault-bridge-sftp"; + +const STATE_DIR = `.obsidian/plugins/${PLUGIN_ID}/state`; + +export const STATE_PATHS = { + dir: STATE_DIR, + index: `${STATE_DIR}/index.json`, + lastSynced: `${STATE_DIR}/last-synced.json`, + device: `${STATE_DIR}/device.json`, + tmp: `${STATE_DIR}/tmp`, + log: `${STATE_DIR}/log.jsonl`, +} as const; + +/** Hard-coded path prefix that must NEVER be synced — recursion would corrupt the index. */ +export const STATE_DIR_PREFIX = STATE_DIR + "/"; diff --git a/src/sync/diff.ts b/src/sync/diff.ts new file mode 100644 index 0000000..0f07287 --- /dev/null +++ b/src/sync/diff.ts @@ -0,0 +1,160 @@ +import type { ManifestEntry } from "../sftp/remote-state"; +import type { IndexEntry } from "./index-store"; + +export type SideChange = "unchanged" | "added" | "modified" | "deleted"; + +export type ActionType = + | "skip" // both unchanged → no work, entry preserved in new manifest + | "push" // local newer/added, remote unchanged → upload + | "pull" // remote newer/added, local unchanged → download + | "delete-remote" // local deleted, remote unchanged → delete on server + | "delete-local" // remote deleted, local unchanged → delete locally + | "conflict" // both changed differently → conflict-copy + winner installed + | "restore-keep-remote" // local deleted, remote also changed → bring remote back locally + | "restore-keep-local" // remote deleted, local also changed → push local back + | "merge-converged" // both changed to identical content → just update snapshot + | "drop-from-snapshot"; // both deleted → remove from snapshot + +export interface SyncOp { + path: string; + action: ActionType; + localState: SideChange; + remoteState: SideChange; + local?: IndexEntry; + remote?: ManifestEntry; + snapshot?: ManifestEntry; + /** For action=conflict only: who wins by mtime (kept on `path`, loser becomes a conflict-copy). */ + winner?: "local" | "remote"; +} + +export interface PlanCounts { + push: number; + pull: number; + deleteRemote: number; + deleteLocal: number; + conflict: number; + restore: number; + converged: number; + skip: number; + dropped: number; + /** push + pull + delete* + conflict + restore — work that involves remote/local I/O. */ + ioOps: number; +} + +export interface SyncPlan { + ops: SyncOp[]; + counts: PlanCounts; +} + +function classify(currentHash: string | undefined, snapshotHash: string | undefined): SideChange { + if (currentHash !== undefined && snapshotHash !== undefined) { + return currentHash === snapshotHash ? "unchanged" : "modified"; + } + if (currentHash !== undefined) return "added"; + if (snapshotHash !== undefined) return "deleted"; + return "unchanged"; // not in current, not in snapshot — nothing to do +} + +/** + * 3-way diff: produce per-path actions from local index, remote manifest, and last-synced snapshot. + * Pure function — no I/O. + */ +export function buildPlan( + local: Map, + remote: Record, + snapshot: Record, + isExcluded: (path: string) => boolean, +): SyncPlan { + const allPaths = new Set(); + for (const k of local.keys()) allPaths.add(k); + for (const k of Object.keys(remote)) allPaths.add(k); + for (const k of Object.keys(snapshot)) allPaths.add(k); + + const ops: SyncOp[] = []; + const counts: PlanCounts = { + push: 0, pull: 0, + deleteRemote: 0, deleteLocal: 0, + conflict: 0, restore: 0, + converged: 0, skip: 0, dropped: 0, + ioOps: 0, + }; + + for (const path of allPaths) { + if (isExcluded(path)) continue; + + const lEntry = local.get(path); + const rEntry = remote[path]; + const sEntry = snapshot[path]; + + const localState = classify(lEntry?.sha1, sEntry?.sha1); + const remoteState = classify(rEntry?.sha1, sEntry?.sha1); + + let action: ActionType = "skip"; + let winner: "local" | "remote" | undefined; + + if (localState === "unchanged" && remoteState === "unchanged") { + action = "skip"; + } else if (localState === "deleted" && remoteState === "deleted") { + action = "drop-from-snapshot"; + } else if ((localState === "added" || localState === "modified") && remoteState === "unchanged") { + action = "push"; + } else if ((remoteState === "added" || remoteState === "modified") && localState === "unchanged") { + action = "pull"; + } else if (localState === "deleted" && remoteState === "unchanged") { + action = "delete-remote"; + } else if (remoteState === "deleted" && localState === "unchanged") { + action = "delete-local"; + } else if ( + (localState === "added" || localState === "modified") && + (remoteState === "added" || remoteState === "modified") + ) { + if (lEntry && rEntry && lEntry.sha1 === rEntry.sha1) { + action = "merge-converged"; + } else { + const lMtime = lEntry?.mtime ?? 0; + const rMtime = rEntry?.mtime ?? 0; + winner = lMtime >= rMtime ? "local" : "remote"; + action = "conflict"; + } + } else if (localState === "deleted" && (remoteState === "added" || remoteState === "modified")) { + action = "restore-keep-remote"; + } else if ((localState === "added" || localState === "modified") && remoteState === "deleted") { + action = "restore-keep-local"; + } + + ops.push({ + path, action, localState, remoteState, + local: lEntry, remote: rEntry, snapshot: sEntry, + winner, + }); + + switch (action) { + case "push": counts.push++; counts.ioOps++; break; + case "pull": counts.pull++; counts.ioOps++; break; + case "delete-remote": counts.deleteRemote++; counts.ioOps++; break; + case "delete-local": counts.deleteLocal++; counts.ioOps++; break; + case "conflict": counts.conflict++; counts.ioOps++; break; + case "restore-keep-remote": + case "restore-keep-local": counts.restore++; counts.ioOps++; break; + case "merge-converged": counts.converged++; break; + case "drop-from-snapshot": counts.dropped++; break; + default: counts.skip++; break; + } + } + + return { ops, counts }; +} + +/** Build a conflict-copy filename: `notes/foo.md` → `notes/foo (conflict from 2026-04-28 14-30).md`. */ +export function conflictCopyName(path: string, fromDevice: string, ts: number): string { + const d = new Date(ts); + const stamp = + `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")} ` + + `${String(d.getHours()).padStart(2, "0")}-${String(d.getMinutes()).padStart(2, "0")}`; + const slashIdx = path.lastIndexOf("/"); + const dotIdx = path.lastIndexOf("."); + if (dotIdx > slashIdx && dotIdx !== -1) { + return `${path.substring(0, dotIdx)} (conflict from ${fromDevice} ${stamp})${path.substring(dotIdx)}`; + } + return `${path} (conflict from ${fromDevice} ${stamp})`; +} diff --git a/src/sync/dry-run.ts b/src/sync/dry-run.ts new file mode 100644 index 0000000..cb71d2e --- /dev/null +++ b/src/sync/dry-run.ts @@ -0,0 +1,69 @@ +import type { App } from "obsidian"; +import { SftpClient } from "../sftp/client"; +import { RemoteState } from "../sftp/remote-state"; +import { buildPlan, SyncPlan } from "./diff"; +import type { IndexStore, IndexEntry } from "./index-store"; +import type { LastSyncedStore } from "./last-synced"; +import type { Scanner } from "./scanner"; +import type { ExcludeMatcher } from "./exclude"; +import type { DeviceStore } from "../state/device-store"; +import type { SftpSyncSettings } from "../settings"; + +export interface DryRunReport { + plan: SyncPlan; + localCount: number; + remoteCount: number; + snapshotCount: number; + remoteGeneration: number; + snapshotGeneration: number; +} + +/** Compute the sync plan without executing anything. Useful for diagnostics. */ +export async function dryRun( + app: App, + settings: SftpSyncSettings, + deviceStore: DeviceStore, + index: IndexStore, + scanner: Scanner, + exclude: ExcludeMatcher, + lastSynced: LastSyncedStore, +): Promise { + await scanner.fullScan(); + + const client = new SftpClient(settings); + await client.connect(); + try { + await client.ensureRemoteRoot(); + const remote = new RemoteState( + client, + settings.remoteRoot, + deviceStore.id, + deviceStore.label, + ); + await remote.ensureSyncDir(); + + const manifest = await remote.readManifest(); + const snapshot = lastSynced.snapshot; + + const localMap = new Map(); + for (const e of index.all) localMap.set(e.path, e); + + const plan = buildPlan( + localMap, + manifest.entries, + snapshot.entries, + (p) => exclude.isExcluded(p), + ); + + return { + plan, + localCount: localMap.size, + remoteCount: Object.keys(manifest.entries).length, + snapshotCount: Object.keys(snapshot.entries).length, + remoteGeneration: manifest.generation, + snapshotGeneration: snapshot.generation, + }; + } finally { + await client.end(); + } +} diff --git a/src/sync/exclude.ts b/src/sync/exclude.ts new file mode 100644 index 0000000..faef29f --- /dev/null +++ b/src/sync/exclude.ts @@ -0,0 +1,67 @@ +import { STATE_DIR_PREFIX } from "../state/paths"; +import type { SftpSyncSettings } from "../settings"; + +/** + * Convert a gitignore-style glob to a RegExp anchored to the full path. + * Supported: `**` (any path including /), `*` (any non-/), `?` (one non-/), literal segments. + */ +function globToRegex(glob: string): RegExp { + let re = ""; + let i = 0; + while (i < glob.length) { + const c = glob[i]; + if (c === "*" && glob[i + 1] === "*") { + re += ".*"; + i += 2; + if (glob[i] === "/") i++; // consume the slash after ** + } else if (c === "*") { + re += "[^/]*"; + i++; + } else if (c === "?") { + re += "[^/]"; + i++; + } else if ("\\^$.|+(){}[]".includes(c)) { + re += "\\" + c; + i++; + } else { + re += c; + i++; + } + } + return new RegExp("^" + re + "$"); +} + +export class ExcludeMatcher { + private patterns: RegExp[]; + private excludeObsidianFolder: boolean; + private excludeWorkspaceJson: boolean; + + constructor(settings: SftpSyncSettings) { + this.patterns = settings.excludePatterns.map(globToRegex); + this.excludeObsidianFolder = !settings.syncEverything; + this.excludeWorkspaceJson = !settings.syncWorkspaceJson; + } + + /** Returns true if the given vault-relative path should NOT be synced. */ + isExcluded(path: string): boolean { + // 1. Hard-coded: our own state directory. + if (path === STATE_DIR_PREFIX.slice(0, -1) || path.startsWith(STATE_DIR_PREFIX)) { + return true; + } + // 2. .obsidian folder excluded entirely if user opted out. + if (this.excludeObsidianFolder && (path === ".obsidian" || path.startsWith(".obsidian/"))) { + return true; + } + // 3. workspace.json — separate opt-in because user-asked-for full sync still excludes these. + if (this.excludeWorkspaceJson) { + if (path === ".obsidian/workspace.json" || path === ".obsidian/workspace-mobile.json") { + return true; + } + } + // 4. User-defined soft excludes. + for (const re of this.patterns) { + if (re.test(path)) return true; + } + return false; + } +} diff --git a/src/sync/hash.ts b/src/sync/hash.ts new file mode 100644 index 0000000..2df51cf --- /dev/null +++ b/src/sync/hash.ts @@ -0,0 +1,11 @@ +import { createHash } from "crypto"; + +export function sha1OfBuffer(buf: ArrayBuffer | Uint8Array): string { + const h = createHash("sha1"); + h.update(buf instanceof Uint8Array ? buf : new Uint8Array(buf)); + return h.digest("hex"); +} + +export function sha1OfString(s: string): string { + return createHash("sha1").update(s, "utf8").digest("hex"); +} diff --git a/src/sync/index-store.ts b/src/sync/index-store.ts new file mode 100644 index 0000000..f3d86c0 --- /dev/null +++ b/src/sync/index-store.ts @@ -0,0 +1,124 @@ +import type { App, DataAdapter } from "obsidian"; +import { STATE_PATHS } from "../state/paths"; + +export interface IndexEntry { + path: string; // vault-relative, forward slashes + mtime: number; // ms since epoch + size: number; // bytes + sha1: string; // content hash, lowercase hex +} + +export interface Index { + entries: Record; + lastScannedAt: number; + schemaVersion: 1; +} + +const EMPTY_INDEX: Index = { entries: {}, lastScannedAt: 0, schemaVersion: 1 }; + +export class IndexStore { + private data: Index = structuredClone(EMPTY_INDEX); + private dirty = false; + private flushTimer: ReturnType | null = null; + private adapter: DataAdapter; + + constructor(app: App) { + this.adapter = app.vault.adapter; + } + + get all(): IndexEntry[] { + return Object.values(this.data.entries); + } + + get(path: string): IndexEntry | undefined { + return this.data.entries[path]; + } + + /** Bulk replace (used after a full rescan). */ + replaceAll(entries: IndexEntry[]): void { + const map: Record = {}; + for (const e of entries) map[e.path] = e; + this.data.entries = map; + this.data.lastScannedAt = Date.now(); + this.markDirty(); + } + + upsert(entry: IndexEntry): void { + this.data.entries[entry.path] = entry; + this.markDirty(); + } + + remove(path: string): void { + if (path in this.data.entries) { + delete this.data.entries[path]; + this.markDirty(); + } + } + + rename(oldPath: string, newPath: string): void { + const e = this.data.entries[oldPath]; + if (!e) return; + delete this.data.entries[oldPath]; + this.data.entries[newPath] = { ...e, path: newPath }; + this.markDirty(); + } + + size(): number { + return Object.keys(this.data.entries).length; + } + + totalBytes(): number { + let n = 0; + for (const e of Object.values(this.data.entries)) n += e.size; + return n; + } + + lastScannedAt(): number { + return this.data.lastScannedAt; + } + + async load(): Promise { + try { + if (!(await this.adapter.exists(STATE_PATHS.index))) { + this.data = structuredClone(EMPTY_INDEX); + return; + } + const raw = await this.adapter.read(STATE_PATHS.index); + const parsed = JSON.parse(raw) as Index; + if (parsed.schemaVersion !== 1) throw new Error("unsupported schema"); + this.data = parsed; + this.dirty = false; + } catch (err) { + console.warn("Vault Bridge: failed to load index, starting fresh", err); + this.data = structuredClone(EMPTY_INDEX); + } + } + + /** Force-write index to disk, regardless of dirty flag. Cancels any pending debounced flush. */ + async flush(): Promise { + if (this.flushTimer) { + clearTimeout(this.flushTimer); + this.flushTimer = null; + } + await this.ensureStateDir(); + await this.adapter.write(STATE_PATHS.index, JSON.stringify(this.data)); + this.dirty = false; + } + + private markDirty(): void { + this.dirty = true; + if (this.flushTimer) return; + this.flushTimer = setTimeout(() => { + this.flushTimer = null; + if (this.dirty) { + void this.flush().catch((err) => console.error("Vault Bridge: index flush failed", err)); + } + }, 5000); + } + + private async ensureStateDir(): Promise { + if (!(await this.adapter.exists(STATE_PATHS.dir))) { + await this.adapter.mkdir(STATE_PATHS.dir); + } + } +} diff --git a/src/sync/last-synced.ts b/src/sync/last-synced.ts new file mode 100644 index 0000000..e99cc85 --- /dev/null +++ b/src/sync/last-synced.ts @@ -0,0 +1,55 @@ +import type { App, DataAdapter } from "obsidian"; +import { STATE_PATHS } from "../state/paths"; +import type { ManifestEntry } from "../sftp/remote-state"; + +/** Snapshot S — what the world looked like at the end of the last successful sync. */ +export interface LastSyncedSnapshot { + schemaVersion: 1; + generation: number; + syncedAt: number; + entries: Record; +} + +const EMPTY: LastSyncedSnapshot = { + schemaVersion: 1, + generation: 0, + syncedAt: 0, + entries: {}, +}; + +export class LastSyncedStore { + private adapter: DataAdapter; + private data: LastSyncedSnapshot = structuredClone(EMPTY); + + constructor(app: App) { + this.adapter = app.vault.adapter; + } + + get snapshot(): LastSyncedSnapshot { + return this.data; + } + + async load(): Promise { + try { + if (!(await this.adapter.exists(STATE_PATHS.lastSynced))) { + this.data = structuredClone(EMPTY); + return; + } + const raw = await this.adapter.read(STATE_PATHS.lastSynced); + const parsed = JSON.parse(raw) as LastSyncedSnapshot; + if (parsed.schemaVersion !== 1) throw new Error("unsupported schema"); + this.data = parsed; + } catch (err) { + console.warn("Vault Bridge: cannot read last-synced snapshot, starting fresh", err); + this.data = structuredClone(EMPTY); + } + } + + async save(snapshot: LastSyncedSnapshot): Promise { + this.data = snapshot; + if (!(await this.adapter.exists(STATE_PATHS.dir))) { + await this.adapter.mkdir(STATE_PATHS.dir); + } + await this.adapter.write(STATE_PATHS.lastSynced, JSON.stringify(this.data)); + } +} diff --git a/src/sync/manifest-rebuilder.ts b/src/sync/manifest-rebuilder.ts new file mode 100644 index 0000000..0d2468f --- /dev/null +++ b/src/sync/manifest-rebuilder.ts @@ -0,0 +1,132 @@ +import { SftpClient } from "../sftp/client"; +import { RemoteState, RemoteManifest, ManifestEntry } from "../sftp/remote-state"; +import { downloadToBuffer } from "../sftp/transfer"; +import { sha1OfBuffer } from "./hash"; +import type { ExcludeMatcher } from "./exclude"; +import type { DeviceStore } from "../state/device-store"; +import type { SftpSyncSettings } from "../settings"; + +export interface RebuildProgress { + scanned: number; + hashed: number; + total: number | null; // null while still discovering + currentPath: string | null; +} + +export interface RebuildResult { + filesIndexed: number; + totalBytes: number; + generation: number; + took: number; +} + +/** + * Walk the remote vault directory, download every file, compute sha1, and rewrite manifest.json. + * Use this to recover after manual file changes on the server (the manifest gets out of sync + * with the actual filesystem). Generation is bumped so other clients re-evaluate against truth. + */ +export async function rebuildRemoteManifest( + settings: SftpSyncSettings, + deviceStore: DeviceStore, + exclude: ExcludeMatcher, + onProgress?: (p: RebuildProgress) => void, +): Promise { + const t0 = Date.now(); + const client = new SftpClient(settings); + await client.connect(); + try { + await client.ensureRemoteRoot(); + const remote = new RemoteState( + client, + settings.remoteRoot, + deviceStore.id, + deviceStore.label, + ); + await remote.ensureSyncDir(); + + return await remote.withLock(async () => { + const oldManifest = await remote.readManifest(); + + // 1. Walk the remote directory tree. + onProgress?.({ scanned: 0, hashed: 0, total: null, currentPath: null }); + const paths = await listRemoteFiles(client, settings.remoteRoot, exclude); + + // 2. Download + hash each file. + const entries: Record = {}; + let totalBytes = 0; + for (let i = 0; i < paths.length; i++) { + const path = paths[i]; + onProgress?.({ scanned: i, hashed: i, total: paths.length, currentPath: path }); + try { + const buf = await downloadToBuffer(client, settings.remoteRoot, path); + const sha1 = sha1OfBuffer(buf); + entries[path] = { + mtime: Date.now(), // reset stamps — sha1 is what diff actually uses + size: buf.length, + sha1, + }; + totalBytes += buf.length; + } catch (err) { + console.warn(`Vault Bridge: skipping ${path} during rebuild — ${(err as Error).message}`); + } + } + onProgress?.({ scanned: paths.length, hashed: paths.length, total: paths.length, currentPath: null }); + + // 3. Write the new manifest with bumped generation. + const newManifest: RemoteManifest = { + schemaVersion: 1, + generation: oldManifest.generation + 1, + lastWriter: deviceStore.id, + lastWriterLabel: deviceStore.label, + updatedAt: Date.now(), + entries, + }; + await remote.writeManifest(newManifest); + + return { + filesIndexed: Object.keys(entries).length, + totalBytes, + generation: newManifest.generation, + took: Date.now() - t0, + }; + }); + } finally { + await client.end(); + } +} + +/** Walk the remote vault root, returning vault-relative paths of files (skipping `.sync/` and excludes). */ +async function listRemoteFiles( + client: SftpClient, + remoteRoot: string, + exclude: ExcludeMatcher, +): Promise { + const root = remoteRoot.replace(/\/+$/, ""); + const out: string[] = []; + const stack: string[] = [""]; // relative to root, "" = root itself + + while (stack.length) { + const rel = stack.pop()!; + const dirAbs = rel ? `${root}/${rel}` : root; + let listing; + try { + listing = await client.raw.list(dirAbs); + } catch (err) { + console.warn(`Vault Bridge: cannot list ${dirAbs} — ${(err as Error).message}`); + continue; + } + for (const entry of listing) { + const childRel = rel ? `${rel}/${entry.name}` : entry.name; + // Always skip our own metadata directory. + if (childRel === ".sync" || childRel.startsWith(".sync/")) continue; + if (exclude.isExcluded(childRel)) continue; + if (entry.type === "d") { + stack.push(childRel); + } else if (entry.type === "-") { + out.push(childRel); + } + // ignore symlinks, sockets, etc. + } + } + return out; +} diff --git a/src/sync/path-utils.ts b/src/sync/path-utils.ts new file mode 100644 index 0000000..7762e2e --- /dev/null +++ b/src/sync/path-utils.ts @@ -0,0 +1,12 @@ +/** Join a remote root with a vault-relative path, normalizing slashes. */ +export function remotePathOf(remoteRoot: string, vaultRelative: string): string { + const root = remoteRoot.replace(/\/+$/, ""); + const rel = vaultRelative.replace(/^\/+/, ""); + return `${root}/${rel}`; +} + +/** Get parent directory portion (no trailing slash). Returns "" if no slash. */ +export function parentDir(path: string): string { + const i = path.lastIndexOf("/"); + return i < 0 ? "" : path.substring(0, i); +} diff --git a/src/sync/pull-engine.ts b/src/sync/pull-engine.ts new file mode 100644 index 0000000..87be81a --- /dev/null +++ b/src/sync/pull-engine.ts @@ -0,0 +1,204 @@ +import type { App, DataAdapter } from "obsidian"; +import { SftpClient } from "../sftp/client"; +import { RemoteState } from "../sftp/remote-state"; +import { remotePathOf, parentDir } from "./path-utils"; +import type { IndexStore } from "./index-store"; +import type { LastSyncedStore } from "./last-synced"; +import type { Scanner } from "./scanner"; +import type { ExcludeMatcher } from "./exclude"; +import type { DeviceStore } from "../state/device-store"; +import type { SftpSyncSettings } from "../settings"; +import { STATE_PATHS } from "../state/paths"; + +export interface PullProgress { + processed: number; // files considered so far (downloaded + skipped) + downloaded: number; // files actually transferred + skipped: number; // already-correct local files + total: number; // total entries in remote manifest (after exclude filter) + currentFile: string | null; + bytesDownloaded: number; +} + +export type PullProgressFn = (p: PullProgress) => void; + +export interface PullOptions { + /** Re-download every file even if local sha1 matches. Used after local-index corruption. */ + forceDownload?: boolean; + onProgress?: PullProgressFn; +} + +export interface PullResult { + downloaded: number; + skipped: number; + totalBytes: number; + generation: number; + took: number; +} + +/** + * Phase 5: pull-only — apply the remote manifest to the local vault. + * Conservative semantics: ADDs and UPDATEs only. Local-only files are NOT deleted. + * Wholesale reconciliation including deletes is the job of Phase 6 bidirectional sync. + */ +export class PullEngine { + constructor( + private app: App, + private settings: SftpSyncSettings, + private deviceStore: DeviceStore, + private index: IndexStore, + private scanner: Scanner, + private exclude: ExcludeMatcher, + private lastSynced: LastSyncedStore, + ) {} + + async pullAll(opts: PullOptions = {}): Promise { + const t0 = Date.now(); + const adapter: DataAdapter = this.app.vault.adapter; + const onProgress = opts.onProgress; + const force = opts.forceDownload === true; + + // Refresh local index so our diff is accurate. + await this.scanner.fullScan(); + + const client = new SftpClient(this.settings); + await client.connect(); + try { + await client.ensureRemoteRoot(); + const remote = new RemoteState( + client, + this.settings.remoteRoot, + this.deviceStore.id, + this.deviceStore.label, + ); + await remote.ensureSyncDir(); + + return await remote.withLock(async () => { + const manifest = await remote.readManifest(); + if (manifest.generation === 0) { + throw new Error("Remote has no manifest yet — push from another device first."); + } + + // Decide what needs downloading. sha1 mismatch (or absence locally) → download. + // Excluded paths are skipped (e.g. our own state/ if it somehow appeared there). + const toDownload: Array<[string, { mtime: number; size: number; sha1: string }]> = []; + let alreadyOk = 0; + let totalConsidered = 0; + + for (const [path, mEntry] of Object.entries(manifest.entries)) { + if (this.exclude.isExcluded(path)) continue; + totalConsidered++; + const local = this.index.get(path); + if (!force && local && local.sha1 === mEntry.sha1) { + alreadyOk++; + continue; + } + toDownload.push([path, mEntry]); + } + + // Initial progress tick — already shows skipped count. + onProgress?.({ + processed: alreadyOk, + downloaded: 0, + skipped: alreadyOk, + total: totalConsidered, + currentFile: null, + bytesDownloaded: 0, + }); + + let downloaded = 0; + let totalBytes = 0; + await this.ensureLocalDir(adapter, STATE_PATHS.tmp); + + for (const [path, mEntry] of toDownload) { + onProgress?.({ + processed: alreadyOk + downloaded, + downloaded, + skipped: alreadyOk, + total: totalConsidered, + currentFile: path, + bytesDownloaded: totalBytes, + }); + + await this.downloadOne(client, adapter, path); + + // Refresh the index entry so any late-firing vault event becomes a no-op. + await this.scanner.refreshOne(path); + downloaded++; + totalBytes += mEntry.size; + } + + // Final progress tick. + onProgress?.({ + processed: alreadyOk + downloaded, + downloaded, + skipped: alreadyOk, + total: totalConsidered, + currentFile: null, + bytesDownloaded: totalBytes, + }); + + // Snapshot S now reflects what we just pulled — generation matches the manifest's. + await this.lastSynced.save({ + schemaVersion: 1, + generation: manifest.generation, + syncedAt: Date.now(), + entries: { ...manifest.entries }, + }); + + return { + downloaded, + skipped: alreadyOk, + totalBytes, + generation: manifest.generation, + took: Date.now() - t0, + }; + }); + } finally { + await client.end(); + } + } + + private async downloadOne( + client: SftpClient, + adapter: DataAdapter, + vaultPath: string, + ): Promise { + const remotePath = remotePathOf(this.settings.remoteRoot, vaultPath); + + // Get full file content. ssh2-sftp-client.get returns a Buffer when no stream destination is given. + let buf: Buffer; + try { + buf = (await client.raw.get(remotePath)) as Buffer; + } catch (err) { + throw new Error(`Cannot fetch remote ${remotePath}: ${(err as Error).message}`); + } + + // Make sure local parent dirs exist (vault-relative). + const parent = parentDir(vaultPath); + if (parent) await this.ensureLocalDir(adapter, parent); + + // Download to a tmp staging file in our state/tmp/, then rename in place. + // This avoids leaving a corrupted file at the target if writeBinary is interrupted. + const tmp = `${STATE_PATHS.tmp}/dl.${Date.now().toString(36)}.${Math.random().toString(36).slice(2, 8)}`; + // adapter.writeBinary expects ArrayBuffer; Buffer.buffer + slice gives a fresh ArrayBuffer copy. + const ab = buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength) as ArrayBuffer; + await adapter.writeBinary(tmp, ab); + try { + if ((await adapter.exists(vaultPath)) === true) { + // adapter.rename may not overwrite on Windows — be explicit. + await adapter.remove(vaultPath); + } + await adapter.rename(tmp, vaultPath); + } catch (err) { + // Best-effort cleanup of the staging file if the rename fails. + try { await adapter.remove(tmp); } catch {} + throw err; + } + } + + private async ensureLocalDir(adapter: DataAdapter, dir: string): Promise { + if ((await adapter.exists(dir)) === false) { + await adapter.mkdir(dir); + } + } +} diff --git a/src/sync/push-engine.ts b/src/sync/push-engine.ts new file mode 100644 index 0000000..fa1b675 --- /dev/null +++ b/src/sync/push-engine.ts @@ -0,0 +1,240 @@ +import type { App, DataAdapter } from "obsidian"; +import { SftpClient } from "../sftp/client"; +import { RemoteState, RemoteManifest, ManifestEntry } from "../sftp/remote-state"; +import { remotePathOf, parentDir } from "./path-utils"; +import type { IndexStore, IndexEntry } from "./index-store"; +import type { LastSyncedStore } from "./last-synced"; +import type { Scanner } from "./scanner"; +import type { DeviceStore } from "../state/device-store"; +import type { SftpSyncSettings } from "../settings"; + +export interface PushProgress { + processed: number; // files considered so far (uploaded + skipped) + uploaded: number; // files actually transferred + skipped: number; // files unchanged vs remote manifest + total: number; + currentFile: string | null; + bytesUploaded: number; +} + +export type PushProgressFn = (p: PushProgress) => void; + +export interface PushOptions { + /** Re-upload every file regardless of remote manifest. Use after manifest corruption. */ + forceUpload?: boolean; + onProgress?: PushProgressFn; +} + +export interface PushResult { + uploaded: number; // files actually transferred + skipped: number; // unchanged files that were not re-uploaded + totalBytes: number; // bytes of UPLOADED files (skipped don't count) + generation: number; + took: number; +} + +/** + * Phase 4: "Save All" — upload every file in the local index to the remote root. + * Holds the remote lock for the duration. On success, writes a fresh manifest + * with bumped generation and saves the matching local snapshot. + */ +export class PushEngine { + constructor( + private app: App, + private settings: SftpSyncSettings, + private deviceStore: DeviceStore, + private index: IndexStore, + private scanner: Scanner, + private lastSynced: LastSyncedStore, + ) {} + + async pushAll(opts: PushOptions = {}): Promise { + const t0 = Date.now(); + const adapter: DataAdapter = this.app.vault.adapter; + const onProgress = opts.onProgress; + const force = opts.forceUpload === true; + + // Make sure the index is current. Cheap if nothing changed. + await this.scanner.fullScan(); + const localEntries = this.index.all; + + if (localEntries.length === 0) { + throw new Error("Local index is empty — nothing to push."); + } + + const client = new SftpClient(this.settings); + await client.connect(); + try { + await client.ensureRemoteRoot(); + const remote = new RemoteState( + client, + this.settings.remoteRoot, + this.deviceStore.id, + this.deviceStore.label, + ); + await remote.ensureSyncDir(); + + // Acquire lock; throws on contention. + return await remote.withLock(async () => { + const oldManifest = await remote.readManifest(); + const remoteEntries = oldManifest.entries; + + // Decide which local files actually need uploading. + // sha1 match against the remote manifest = file is identical → skip transfer + // but still include it in the new manifest. mtime/size are NOT used here + // because mtime is set by the SFTP server on upload and never matches local. + const toUpload: IndexEntry[] = []; + const toSkip: IndexEntry[] = []; + for (const e of localEntries) { + const remoteEntry = remoteEntries[e.path]; + if (!force && remoteEntry && remoteEntry.sha1 === e.sha1) { + toSkip.push(e); + } else { + toUpload.push(e); + } + } + + const newManifest: RemoteManifest = { + schemaVersion: 1, + generation: oldManifest.generation + 1, + lastWriter: this.deviceStore.id, + lastWriterLabel: this.deviceStore.label, + updatedAt: Date.now(), + entries: {}, + }; + // Carry over skipped entries unchanged. + for (const e of toSkip) { + newManifest.entries[e.path] = { mtime: e.mtime, size: e.size, sha1: e.sha1 }; + } + + const total = localEntries.length; + let uploaded = 0; + const skipped = toSkip.length; + let totalBytes = 0; + const ensuredDirs = new Set(); + ensuredDirs.add(this.settings.remoteRoot.replace(/\/+$/, "")); + + // Initial progress tick — already shows skipped count. + onProgress?.({ + processed: skipped, + uploaded: 0, + skipped, + total, + currentFile: null, + bytesUploaded: 0, + }); + + for (const entry of toUpload) { + onProgress?.({ + processed: skipped + uploaded, + uploaded, + skipped, + total, + currentFile: entry.path, + bytesUploaded: totalBytes, + }); + + await this.uploadOne(client, adapter, entry, ensuredDirs); + + newManifest.entries[entry.path] = { + mtime: entry.mtime, + size: entry.size, + sha1: entry.sha1, + }; + uploaded++; + totalBytes += entry.size; + } + + // Final progress tick. + onProgress?.({ + processed: skipped + uploaded, + uploaded, + skipped, + total, + currentFile: null, + bytesUploaded: totalBytes, + }); + + // Only bump generation + rewrite manifest if anything actually changed. + // (Saves a write and avoids spurious gen-bumps on no-op syncs.) + const changed = uploaded > 0 || Object.keys(remoteEntries).length !== Object.keys(newManifest.entries).length; + if (changed) { + await remote.writeManifest(newManifest); + } else { + newManifest.generation = oldManifest.generation; + newManifest.updatedAt = oldManifest.updatedAt || Date.now(); + } + + // Persist local snapshot S regardless (cheap; reflects current truth). + await this.lastSynced.save({ + schemaVersion: 1, + generation: newManifest.generation, + syncedAt: newManifest.updatedAt, + entries: { ...newManifest.entries }, + }); + + return { + uploaded, + skipped, + totalBytes, + generation: newManifest.generation, + took: Date.now() - t0, + }; + }); + } finally { + await client.end(); + } + } + + private async uploadOne( + client: SftpClient, + adapter: DataAdapter, + entry: IndexEntry, + ensuredDirs: Set, + ): Promise { + const remotePath = remotePathOf(this.settings.remoteRoot, entry.path); + await this.ensureRemoteParents(client, remotePath, ensuredDirs); + + let buf: Buffer; + try { + const ab = await adapter.readBinary(entry.path); + buf = Buffer.from(ab); + } catch (err) { + throw new Error(`Cannot read local ${entry.path}: ${(err as Error).message}`); + } + + const tmp = `${remotePath}.tmp.${Date.now().toString(36)}.${Math.random().toString(36).slice(2, 8)}`; + await client.raw.put(buf, tmp); + try { + await client.raw.posixRename(tmp, remotePath); + } catch { + // Some servers don't support posix-rename; fall back to delete+rename. + if ((await client.raw.exists(remotePath)) !== false) { + await client.raw.delete(remotePath); + } + await client.raw.rename(tmp, remotePath); + } + } + + private async ensureRemoteParents( + client: SftpClient, + remoteFilePath: string, + ensured: Set, + ): Promise { + const parent = parentDir(remoteFilePath); + if (!parent || ensured.has(parent)) return; + // Climb up until we hit a known-existing dir, then create the chain top-down via mkdir(true). + if ((await client.raw.exists(parent)) === false) { + await client.raw.mkdir(parent, true); + } + ensured.add(parent); + // Mark parents-of-parent as ensured too — they must exist if mkdir succeeded. + let p = parent; + while (true) { + const grand = parentDir(p); + if (!grand || ensured.has(grand)) break; + ensured.add(grand); + p = grand; + } + } +} diff --git a/src/sync/scanner.ts b/src/sync/scanner.ts new file mode 100644 index 0000000..e964aee --- /dev/null +++ b/src/sync/scanner.ts @@ -0,0 +1,123 @@ +import type { App, DataAdapter } from "obsidian"; +import { ExcludeMatcher } from "./exclude"; +import { IndexStore, IndexEntry } from "./index-store"; +import { sha1OfBuffer } from "./hash"; + +export interface ScanProgress { + scanned: number; + hashed: number; + total: number | null; // null while still discovering +} + +export interface ScanOptions { + /** If true, recompute hashes even when mtime/size match. Used by "Rebuild local index". */ + forceRehash?: boolean; + onProgress?: (p: ScanProgress) => void; +} + +export class Scanner { + private adapter: DataAdapter; + + constructor( + private app: App, + private store: IndexStore, + private exclude: ExcludeMatcher, + ) { + this.adapter = app.vault.adapter; + } + + /** + * Walk the entire vault, build a fresh index, replace the store contents. + * Files whose (mtime,size) match the existing index keep their old hash — saves a lot of I/O on big vaults. + */ + async fullScan(opts: ScanOptions = {}): Promise<{ entries: IndexEntry[]; took: number }> { + const t0 = Date.now(); + const paths = await this.listAllFiles(); + const entries: IndexEntry[] = []; + let hashed = 0; + + for (let i = 0; i < paths.length; i++) { + const path = paths[i]; + const entry = await this.statAndMaybeHash(path, opts.forceRehash === true); + if (entry) { + entries.push(entry); + if (this.didHash) hashed++; + } + opts.onProgress?.({ scanned: i + 1, hashed, total: paths.length }); + } + + this.store.replaceAll(entries); + await this.store.flush(); + return { entries, took: Date.now() - t0 }; + } + + /** Update one path in the index from disk state (for vault events). */ + async refreshOne(path: string): Promise { + if (this.exclude.isExcluded(path)) return; + if (!(await this.adapter.exists(path))) { + this.store.remove(path); + return; + } + const entry = await this.statAndMaybeHash(path, false); + if (entry) this.store.upsert(entry); + } + + /** Internal — set by statAndMaybeHash for progress accounting. */ + private didHash = false; + + private async statAndMaybeHash(path: string, force: boolean): Promise { + this.didHash = false; + let stat; + try { + stat = await this.adapter.stat(path); + } catch { + return null; + } + if (!stat || stat.type !== "file") return null; + + const existing = this.store.get(path); + if ( + !force && + existing && + existing.mtime === stat.mtime && + existing.size === stat.size + ) { + return existing; + } + + let hash: string; + try { + const buf = await this.adapter.readBinary(path); + hash = sha1OfBuffer(buf); + this.didHash = true; + } catch (err) { + console.warn(`Vault Bridge: cannot read ${path} for hashing`, err); + return null; + } + return { path, mtime: stat.mtime, size: stat.size, sha1: hash }; + } + + /** Walk the vault, returning vault-relative file paths, applying excludes. */ + private async listAllFiles(): Promise { + const out: string[] = []; + await this.walk("", out); + return out; + } + + private async walk(dir: string, out: string[]): Promise { + let listing; + try { + listing = await this.adapter.list(dir); + } catch (err) { + console.warn(`Vault Bridge: list(${dir}) failed`, err); + return; + } + for (const f of listing.files) { + if (!this.exclude.isExcluded(f)) out.push(f); + } + for (const d of listing.folders) { + if (this.exclude.isExcluded(d)) continue; + await this.walk(d, out); + } + } +} diff --git a/src/sync/sync-engine.ts b/src/sync/sync-engine.ts new file mode 100644 index 0000000..3732a64 --- /dev/null +++ b/src/sync/sync-engine.ts @@ -0,0 +1,453 @@ +import type { App, DataAdapter } from "obsidian"; +import { SftpClient } from "../sftp/client"; +import { RemoteState, RemoteManifest, ManifestEntry } from "../sftp/remote-state"; +import { + RemoteDirCache, + uploadBuffer, + readLocalAsBuffer, + downloadToBuffer, + writeBufferToVault, + deleteRemoteFile, + deleteLocalFile, +} from "../sftp/transfer"; +import { buildPlan, conflictCopyName, SyncOp, SyncPlan } from "./diff"; +import type { IndexStore, IndexEntry } from "./index-store"; +import type { LastSyncedStore } from "./last-synced"; +import type { Scanner } from "./scanner"; +import type { ExcludeMatcher } from "./exclude"; +import type { DeviceStore } from "../state/device-store"; +import type { SftpSyncSettings } from "../settings"; + +export interface SyncProgress { + processed: number; + total: number; + currentPath: string | null; + currentAction: string | null; +} + +export type SyncProgressFn = (p: SyncProgress) => void; + +export type BulkDeleteDecision = "continue" | "skip-deletes" | "cancel"; + +export interface BulkDeleteInfo { + outgoingDeletes: SyncOp[]; + incomingDeletes: SyncOp[]; + totalFiles: number; +} + +export interface SyncOptions { + onProgress?: SyncProgressFn; + /** + * Called when the plan would delete a large number of files. + * Resolve with "continue" to proceed as planned, "skip-deletes" to drop deletes, + * or "cancel" to abort the sync entirely. + */ + confirmBulkDelete?: (info: BulkDeleteInfo) => Promise; + /** Trigger confirmation when one side's delete count >= this absolute threshold. Default 20. */ + bulkDeleteAbsThreshold?: number; + /** Trigger confirmation when one side's delete count / totalFiles >= this fraction. Default 0.05. */ + bulkDeleteRelThreshold?: number; +} + +export interface ServerResetInfo { + lastSnapshotGeneration: number; + snapshotFileCount: number; + remoteGeneration: number; + remoteFileCount: number; +} + +export interface SyncOutcome { + generation: number; + took: number; + counts: SyncPlan["counts"]; + conflictCopies: string[]; // paths of conflict-copy files created + noChanges: boolean; // true if nothing happened + cancelled?: boolean; // user picked "cancel" — no work performed + deletesSkipped?: boolean; // user picked "skip-deletes" — deletes filtered out + serverReset?: ServerResetInfo; // server manifest looks reset/wiped — engine refused to proceed + selfHealedMissing?: string[]; // paths whose remote-side download 404'd; manifest entry dropped +} + +/** + * Phase 6: bidirectional sync via 3-way diff (L vs S, R vs S). + * Holds the remote lock for the duration. Updates remote manifest + local snapshot atomically at the end. + */ +export class SyncEngine { + constructor( + private app: App, + private settings: SftpSyncSettings, + private deviceStore: DeviceStore, + private index: IndexStore, + private scanner: Scanner, + private exclude: ExcludeMatcher, + private lastSynced: LastSyncedStore, + ) {} + + async syncBoth(opts: SyncOptions = {}): Promise { + const t0 = Date.now(); + const adapter: DataAdapter = this.app.vault.adapter; + const onProgress = opts.onProgress; + + // Make sure local index reflects current disk state. + await this.scanner.fullScan(); + + const client = new SftpClient(this.settings); + await client.connect(); + try { + await client.ensureRemoteRoot(); + const remote = new RemoteState( + client, + this.settings.remoteRoot, + this.deviceStore.id, + this.deviceStore.label, + ); + await remote.ensureSyncDir(); + + return await remote.withLock(async () => { + const manifest = await remote.readManifest(); + const snapshot = this.lastSynced.snapshot; + + // ⚠ Server-reset detection: if this device successfully synced before (S.gen > 0) + // but the remote manifest is empty (gen 0), someone wiped the server. Refuse to + // proceed — otherwise we'd happily delete every local file. + if (manifest.generation === 0 && snapshot.generation > 0) { + console.warn("Vault Bridge: server-reset detected", { + snapshotGeneration: snapshot.generation, + snapshotFileCount: Object.keys(snapshot.entries).length, + }); + return { + generation: 0, + took: Date.now() - t0, + counts: emptyCounts(), + conflictCopies: [], + noChanges: false, + cancelled: true, + serverReset: { + lastSnapshotGeneration: snapshot.generation, + snapshotFileCount: Object.keys(snapshot.entries).length, + remoteGeneration: manifest.generation, + remoteFileCount: Object.keys(manifest.entries).length, + }, + }; + } + + // Build the plan. + const localMap = new Map(); + for (const e of this.index.all) localMap.set(e.path, e); + const plan = buildPlan( + localMap, + manifest.entries, + snapshot.entries, + (p) => this.exclude.isExcluded(p), + ); + + // Diagnostics: show what we decided to do. + console.log("Vault Bridge plan:", { + counts: plan.counts, + localCount: localMap.size, + remoteCount: Object.keys(manifest.entries).length, + snapshotCount: Object.keys(snapshot.entries).length, + remoteGeneration: manifest.generation, + snapshotGeneration: snapshot.generation, + ioActions: plan.ops.filter(o => + o.action !== "skip" && o.action !== "drop-from-snapshot" && o.action !== "merge-converged" + ).map(o => ({ path: o.path, action: o.action, L: o.localState, R: o.remoteState })), + }); + + // Bulk-delete safeguard. + let deletesSkipped = false; + const totalFiles = Math.max( + localMap.size, + Object.keys(manifest.entries).length, + Object.keys(snapshot.entries).length, + ); + const absThreshold = opts.bulkDeleteAbsThreshold ?? 20; + const relThreshold = opts.bulkDeleteRelThreshold ?? 0.05; + const trippedAbs = + plan.counts.deleteRemote >= absThreshold || plan.counts.deleteLocal >= absThreshold; + const trippedRel = + totalFiles > 0 && + (plan.counts.deleteRemote / totalFiles >= relThreshold || + plan.counts.deleteLocal / totalFiles >= relThreshold); + + if ((trippedAbs || trippedRel) && opts.confirmBulkDelete) { + const outgoing = plan.ops.filter((o) => o.action === "delete-remote"); + const incoming = plan.ops.filter((o) => o.action === "delete-local"); + console.log( + "Vault Bridge: bulk-delete threshold tripped — asking user", + { outgoing: outgoing.length, incoming: incoming.length, totalFiles }, + ); + const decision = await opts.confirmBulkDelete({ + outgoingDeletes: outgoing, + incomingDeletes: incoming, + totalFiles, + }); + if (decision === "cancel") { + return { + generation: manifest.generation, + took: Date.now() - t0, + counts: plan.counts, + conflictCopies: [], + noChanges: false, + cancelled: true, + }; + } + if (decision === "skip-deletes") { + deletesSkipped = true; + // Convert all delete-* ops to skip; entry retention falls out of the existing skip handler: + // delete-remote → op.remote is defined → server entry preserved (re-prompt next sync) + // delete-local → op.local is defined → manifest entry preserved (server inconsistency + // that next sync will catch and re-prompt — safe but noisy) + for (const op of plan.ops) { + if (op.action === "delete-remote" || op.action === "delete-local") { + op.action = "skip"; + } + } + // Adjust counts so the result reflects what we actually did. + plan.counts.skip += plan.counts.deleteRemote + plan.counts.deleteLocal; + plan.counts.ioOps -= plan.counts.deleteRemote + plan.counts.deleteLocal; + plan.counts.deleteRemote = 0; + plan.counts.deleteLocal = 0; + } + } + + const noChanges = + plan.counts.ioOps === 0 && + plan.counts.dropped === 0 && + plan.counts.converged === 0 && + // also no entries to migrate from snapshot to new manifest? still need to write S=manifest + plan.counts.skip === Object.keys(snapshot.entries).length; + + // We always rebuild the new manifest entries from the plan + local index. + const newEntries: Record = {}; + const dirs = new RemoteDirCache(this.settings.remoteRoot); + const conflictCopies: string[] = []; + const selfHealedMissing: string[] = []; + + let processed = 0; + const total = plan.counts.ioOps; + + for (const op of plan.ops) { + // Bookkeeping-only actions (no I/O). + if (op.action === "skip") { + // entry survives unchanged — pick local if present, else remote, else snapshot + const e = op.local ?? op.remote ?? op.snapshot; + if (e) newEntries[op.path] = manifestEntryOf(e); + continue; + } + if (op.action === "merge-converged") { + const e = op.local!; // both sides agree, local is fine + newEntries[op.path] = manifestEntryOf(e); + continue; + } + if (op.action === "drop-from-snapshot") { + // entry gone — do nothing (not added to newEntries) + continue; + } + + processed++; + onProgress?.({ processed, total, currentPath: op.path, currentAction: op.action }); + + try { + await this.executeOp(client, adapter, op, dirs, newEntries, conflictCopies); + } catch (err) { + // Self-heal: if the missing file is on a pull-side action, drop it from the manifest + // and continue. The local filesystem is left alone — next sync will re-evaluate. + if ( + isRemoteFileMissing(err) && + (op.action === "pull" || op.action === "restore-keep-remote") + ) { + console.warn( + `Vault Bridge: ${op.path} missing on server — dropping manifest entry (self-heal)`, + ); + selfHealedMissing.push(op.path); + // Do NOT add to newEntries — manifest will reflect the file no longer exists. + continue; + } + throw new Error(`Vault Bridge: failed on ${op.action} ${op.path} — ${(err as Error).message}`); + } + } + + // Final progress tick. + onProgress?.({ processed, total, currentPath: null, currentAction: null }); + + // Write the new manifest only if anything actually changed. + const anythingChanged = + plan.counts.ioOps > 0 || + plan.counts.dropped > 0 || + plan.counts.converged > 0 || + // safety: always write if entry sets differ + Object.keys(newEntries).length !== Object.keys(manifest.entries).length; + + const newManifest: RemoteManifest = { + schemaVersion: 1, + generation: anythingChanged ? manifest.generation + 1 : manifest.generation, + lastWriter: this.deviceStore.id, + lastWriterLabel: this.deviceStore.label, + updatedAt: Date.now(), + entries: newEntries, + }; + if (anythingChanged) { + await remote.writeManifest(newManifest); + } else { + // keep generation, but reflect updatedAt for visibility + newManifest.updatedAt = manifest.updatedAt || Date.now(); + } + + // Save local snapshot. + await this.lastSynced.save({ + schemaVersion: 1, + generation: newManifest.generation, + syncedAt: newManifest.updatedAt, + entries: { ...newEntries }, + }); + + return { + generation: newManifest.generation, + took: Date.now() - t0, + counts: plan.counts, + conflictCopies, + noChanges, + deletesSkipped, + selfHealedMissing: selfHealedMissing.length > 0 ? selfHealedMissing : undefined, + }; + }); + } finally { + await client.end(); + } + } + + // ────────────────────────────────────────────────────────────────────────── + + private async executeOp( + client: SftpClient, + adapter: DataAdapter, + op: SyncOp, + dirs: RemoteDirCache, + newEntries: Record, + conflictCopies: string[], + ): Promise { + switch (op.action) { + case "push": + case "restore-keep-local": { + // Local has the truth; upload to remote. + const buf = await readLocalAsBuffer(adapter, op.path); + await uploadBuffer(client, this.settings.remoteRoot, op.path, buf, dirs); + newEntries[op.path] = manifestEntryOf(op.local!); + return; + } + + case "pull": + case "restore-keep-remote": { + // Remote has the truth; download to local. + const buf = await downloadToBuffer(client, this.settings.remoteRoot, op.path); + await writeBufferToVault(adapter, op.path, buf); + await this.scanner.refreshOne(op.path); + // Use the freshly-rescanned local entry — sha1 must match remote, but mtime is now-local. + const fresh = this.index.get(op.path); + newEntries[op.path] = fresh ? manifestEntryOf(fresh) : { ...op.remote! }; + return; + } + + case "delete-remote": { + await deleteRemoteFile(client, this.settings.remoteRoot, op.path); + // entry NOT added to newEntries + return; + } + + case "delete-local": { + await deleteLocalFile(adapter, op.path); + this.index.remove(op.path); + // entry NOT added to newEntries + return; + } + + case "conflict": { + await this.resolveConflict(client, adapter, op, dirs, newEntries, conflictCopies); + return; + } + + default: + // skip / merge-converged / drop-from-snapshot handled by caller + return; + } + } + + /** + * Conflict resolution: keep the winner at `path`, save the loser as a conflict-copy. + * Both files end up at both sides (local + server). Winner is the one with the newer mtime. + */ + private async resolveConflict( + client: SftpClient, + adapter: DataAdapter, + op: SyncOp, + dirs: RemoteDirCache, + newEntries: Record, + conflictCopies: string[], + ): Promise { + const winnerIsLocal = op.winner === "local"; + const loserDeviceLabel = winnerIsLocal + ? "remote" /* unknown — could be any device */ + : this.deviceStore.label; + const copyPath = conflictCopyName(op.path, loserDeviceLabel, Date.now()); + conflictCopies.push(copyPath); + + if (winnerIsLocal) { + // Local wins → keep local on `path`. Loser content (remote) becomes the conflict-copy. + const remoteBuf = await downloadToBuffer(client, this.settings.remoteRoot, op.path); + // Save loser locally as the conflict-copy. + await writeBufferToVault(adapter, copyPath, remoteBuf); + await this.scanner.refreshOne(copyPath); + // Push conflict-copy to remote so other devices see it. + await uploadBuffer(client, this.settings.remoteRoot, copyPath, remoteBuf, dirs); + // Push winner (local) to remote (overwrite). + const localBuf = await readLocalAsBuffer(adapter, op.path); + await uploadBuffer(client, this.settings.remoteRoot, op.path, localBuf, dirs); + + newEntries[op.path] = manifestEntryOf(op.local!); + const copyEntry = this.index.get(copyPath); + if (copyEntry) newEntries[copyPath] = manifestEntryOf(copyEntry); + } else { + // Remote wins → keep remote on `path`. Loser content (local) becomes the conflict-copy. + const localBuf = await readLocalAsBuffer(adapter, op.path); + // Save loser locally as the conflict-copy. + await writeBufferToVault(adapter, copyPath, localBuf); + await this.scanner.refreshOne(copyPath); + // Push conflict-copy to remote so other devices see it. + await uploadBuffer(client, this.settings.remoteRoot, copyPath, localBuf, dirs); + // Pull winner (remote) into local (overwrite). + const remoteBuf = await downloadToBuffer(client, this.settings.remoteRoot, op.path); + await writeBufferToVault(adapter, op.path, remoteBuf); + await this.scanner.refreshOne(op.path); + + const winnerEntry = this.index.get(op.path); + newEntries[op.path] = winnerEntry ? manifestEntryOf(winnerEntry) : { ...op.remote! }; + const copyEntry = this.index.get(copyPath); + if (copyEntry) newEntries[copyPath] = manifestEntryOf(copyEntry); + } + } +} + +function manifestEntryOf(e: { mtime: number; size: number; sha1: string }): ManifestEntry { + return { mtime: e.mtime, size: e.size, sha1: e.sha1 }; +} + +/** Detect a "remote file missing" SFTP error across the various error shapes that ssh2 emits. */ +function isRemoteFileMissing(err: unknown): boolean { + if (!err || typeof err !== "object") return false; + const e = err as { code?: unknown; message?: unknown }; + if (typeof e.code === "number" && e.code === 2 /* SFTP_STATUS_NO_SUCH_FILE */) return true; + if (typeof e.code === "string" && (e.code === "ENOENT" || e.code === "ERR_NO_SUCH_FILE")) return true; + const msg = String(e.message ?? "").toLowerCase(); + return msg.includes("no such file") || msg.includes("not found"); +} + +function emptyCounts(): SyncPlan["counts"] { + return { + push: 0, pull: 0, + deleteRemote: 0, deleteLocal: 0, + conflict: 0, restore: 0, + converged: 0, skip: 0, dropped: 0, + ioOps: 0, + }; +} diff --git a/src/ui/bulk-delete-modal.ts b/src/ui/bulk-delete-modal.ts new file mode 100644 index 0000000..c394081 --- /dev/null +++ b/src/ui/bulk-delete-modal.ts @@ -0,0 +1,116 @@ +import { App, Modal } from "obsidian"; +import type { SyncOp } from "../sync/diff"; + +export type BulkDeleteDecision = "continue" | "skip-deletes" | "cancel"; + +export interface BulkDeleteWarning { + outgoingDeletes: SyncOp[]; // delete-remote ops (files about to disappear from server) + incomingDeletes: SyncOp[]; // delete-local ops (files about to disappear from local vault) + totalFiles: number; +} + +const PREVIEW_LIMIT = 15; + +export class BulkDeleteConfirmModal extends Modal { + private decision: BulkDeleteDecision = "cancel"; + resolveFn?: (decision: BulkDeleteDecision) => void; + + constructor(app: App, private warning: BulkDeleteWarning) { + super(app); + } + + onOpen(): void { + const { contentEl } = this; + contentEl.empty(); + + contentEl.createEl("h2", { text: "⚠️ Vault Bridge: bulk deletion warning" }); + + const out = this.warning.outgoingDeletes.length; + const inc = this.warning.incomingDeletes.length; + const tot = this.warning.totalFiles; + + const summaryEl = contentEl.createEl("p"); + summaryEl.appendText("This sync would delete "); + summaryEl.createEl("strong", { text: `${out} file(s) on the server` }); + summaryEl.appendText(" and "); + summaryEl.createEl("strong", { text: `${inc} file(s) locally` }); + summaryEl.appendText(` (out of ${tot} tracked files). Please review before continuing.`); + + if (out > 0) { + contentEl.createEl("h3", { text: `Files to be DELETED ON SERVER (${out}):` }); + this.renderList(contentEl, this.warning.outgoingDeletes); + } + if (inc > 0) { + contentEl.createEl("h3", { text: `Files to be DELETED LOCALLY (${inc}):` }); + this.renderList(contentEl, this.warning.incomingDeletes); + } + + const note = contentEl.createEl("p", { + cls: "mod-warning", + }); + note.style.fontSize = "0.85em"; + note.style.opacity = "0.8"; + note.appendText( + "Choosing \"Skip deletes\" will perform pushes/pulls/conflict-copies but leave deletions for review later — the next sync will re-detect them.", + ); + + const buttons = contentEl.createDiv({ cls: "modal-button-container" }); + buttons.style.display = "flex"; + buttons.style.gap = "0.5em"; + buttons.style.justifyContent = "flex-end"; + buttons.style.marginTop = "1em"; + + const cancelBtn = buttons.createEl("button", { text: "Cancel sync" }); + cancelBtn.addEventListener("click", () => { + this.decision = "cancel"; + this.close(); + }); + + const skipBtn = buttons.createEl("button", { text: "Skip deletes" }); + skipBtn.addEventListener("click", () => { + this.decision = "skip-deletes"; + this.close(); + }); + + const continueBtn = buttons.createEl("button", { text: "Continue (delete)" }); + continueBtn.addClass("mod-warning"); + continueBtn.addEventListener("click", () => { + this.decision = "continue"; + this.close(); + }); + } + + onClose(): void { + this.contentEl.empty(); + this.resolveFn?.(this.decision); + } + + private renderList(container: HTMLElement, ops: SyncOp[]): void { + const ul = container.createEl("ul"); + ul.style.maxHeight = "200px"; + ul.style.overflowY = "auto"; + ul.style.fontFamily = "var(--font-monospace, monospace)"; + ul.style.fontSize = "0.85em"; + + const shown = ops.slice(0, PREVIEW_LIMIT); + for (const op of shown) { + ul.createEl("li", { text: op.path }); + } + if (ops.length > PREVIEW_LIMIT) { + ul.createEl("li", { + text: `… and ${ops.length - PREVIEW_LIMIT} more (full list in DevTools console)`, + }); + } + } +} + +export function askBulkDeleteDecision( + app: App, + warning: BulkDeleteWarning, +): Promise { + return new Promise((resolve) => { + const modal = new BulkDeleteConfirmModal(app, warning); + modal.resolveFn = resolve; + modal.open(); + }); +} diff --git a/src/ui/server-reset-modal.ts b/src/ui/server-reset-modal.ts new file mode 100644 index 0000000..7f283e7 --- /dev/null +++ b/src/ui/server-reset-modal.ts @@ -0,0 +1,90 @@ +import { App, Modal } from "obsidian"; + +export type ServerResetChoice = "force-push" | "reset-snapshot" | "cancel"; + +export interface ServerResetInfo { + lastSnapshotGeneration: number; + snapshotFileCount: number; + remoteGeneration: number; + remoteFileCount: number; +} + +export class ServerResetModal extends Modal { + private decision: ServerResetChoice = "cancel"; + resolveFn?: (decision: ServerResetChoice) => void; + + constructor(app: App, private info: ServerResetInfo) { + super(app); + } + + onOpen(): void { + const { contentEl } = this; + contentEl.empty(); + + contentEl.createEl("h2", { text: "⚠️ Server reset detected" }); + + const p = contentEl.createEl("p"); + p.appendText("The remote manifest looks like it has been wiped or recreated."); + p.createEl("br"); + p.appendText( + `Last successful sync from this device was generation ${this.info.lastSnapshotGeneration} ` + + `(${this.info.snapshotFileCount} files). Server now reports generation ${this.info.remoteGeneration} ` + + `(${this.info.remoteFileCount} files).`, + ); + + const warn = contentEl.createEl("p"); + warn.style.fontWeight = "bold"; + warn.appendText( + "If we proceed with a normal sync, the engine will see your local files as \"deleted on remote\" and try to delete them locally.", + ); + warn.createEl("br"); + warn.appendText("This is almost certainly NOT what you want — pick one of the safe options below."); + + const ul = contentEl.createEl("ul"); + ul.style.fontSize = "0.9em"; + + const liA = ul.createEl("li"); + liA.createEl("strong", { text: "Force push from local" }); + liA.appendText(" — re-upload all local files to server, rewriting the manifest. Use if you trust the local copy as the source of truth."); + + const liB = ul.createEl("li"); + liB.createEl("strong", { text: "Reset local snapshot only" }); + liB.appendText(" — clear this device's idea of \"last sync\" so a normal sync treats local files as fresh additions. Useful if other devices have already pushed up-to-date content."); + + const liC = ul.createEl("li"); + liC.createEl("strong", { text: "Cancel" }); + liC.appendText(" — investigate manually before doing anything."); + + const buttons = contentEl.createDiv({ cls: "modal-button-container" }); + buttons.style.display = "flex"; + buttons.style.gap = "0.5em"; + buttons.style.justifyContent = "flex-end"; + buttons.style.marginTop = "1em"; + + const cancelBtn = buttons.createEl("button", { text: "Cancel" }); + cancelBtn.addEventListener("click", () => { this.decision = "cancel"; this.close(); }); + + const resetBtn = buttons.createEl("button", { text: "Reset snapshot" }); + resetBtn.addEventListener("click", () => { this.decision = "reset-snapshot"; this.close(); }); + + const pushBtn = buttons.createEl("button", { text: "Force push from local" }); + pushBtn.addClass("mod-cta"); + pushBtn.addEventListener("click", () => { this.decision = "force-push"; this.close(); }); + } + + onClose(): void { + this.contentEl.empty(); + this.resolveFn?.(this.decision); + } +} + +export function askServerResetDecision( + app: App, + info: ServerResetInfo, +): Promise { + return new Promise((resolve) => { + const modal = new ServerResetModal(app, info); + modal.resolveFn = resolve; + modal.open(); + }); +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..bd1b82d --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "inlineSourceMap": true, + "inlineSources": true, + "module": "ESNext", + "target": "ES2020", + "allowJs": true, + "noImplicitAny": true, + "moduleResolution": "node", + "importHelpers": true, + "isolatedModules": true, + "strictNullChecks": true, + "esModuleInterop": true, + "skipLibCheck": true, + "lib": ["DOM", "ES2020"] + }, + "include": ["src/**/*.ts"] +} diff --git a/versions.json b/versions.json new file mode 100644 index 0000000..708016d --- /dev/null +++ b/versions.json @@ -0,0 +1,3 @@ +{ + "0.1.0": "1.5.0" +}