Initial commit: Vault Bridge SFTP v0.9.0

This commit is contained in:
Andrew Kopylev 2026-04-28 07:48:41 +03:00
commit 19f1791421
32 changed files with 4735 additions and 0 deletions

9
.gitignore vendored Normal file
View file

@ -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

21
LICENSE Normal file
View file

@ -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.

268
README.md Normal file
View file

@ -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 `<vault>/.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 (`<remoteRoot>/.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 `<remoteRoot>/.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 `<vault>/.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 <vault> # copy main.js + manifest.json into <vault>/.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).

51
esbuild.config.mjs Normal file
View file

@ -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();
}

63
install.sh Executable file
View file

@ -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 <vault>/.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 <<EOF
Usage: $0 <vault-path>
Example:
$0 /home/user/Documents/MyVault
Copies main.js + manifest.json into:
<vault>/.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."

9
manifest.json Normal file
View file

@ -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
}

857
package-lock.json generated Normal file
View file

@ -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
}
}
}

25
package.json Normal file
View file

@ -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"
}
}

51
save2git.sh Executable file
View file

@ -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"

738
src/main.ts Normal file
View file

@ -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<string, ReturnType<typeof debounce>>();
// Global debounced auto-sync trigger. Re-created when debounce setting changes.
private autoSyncDebouncer: ReturnType<typeof debounce> | 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<string, unknown>;
// 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<string, unknown> = {};
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<void> {
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<void> {
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<void> {
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<never>((_, reject) =>
setTimeout(() => reject(new Error(`quit-push timed out after ${timeoutMs}ms`)), timeoutMs),
);
await Promise.race([work, timeout]);
}
// ──────────────────────────────────────────────────────────────────────────
private async initialScan(): Promise<void> {
// 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<void> {
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<void> {
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<Awaited<ReturnType<SyncEngine["syncBoth"]>>["serverReset"]>): Promise<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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();
}
}
}

307
src/settings.ts Normal file
View file

@ -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);
});
}
}

69
src/sftp/client.ts Normal file
View file

@ -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<void> {
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<SftpClientLib["connect"]>[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<void> {
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<void> {
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;
}
}

194
src/sftp/remote-state.ts Normal file
View file

@ -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<string, ManifestEntry>;
}
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<void> {
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<RemoteManifest> {
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<void> {
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<SyncLock | null> {
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<SyncLock | null> {
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<void> {
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<T>(fn: () => Promise<T>): Promise<T> {
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();
}
}
}

119
src/sftp/transfer.ts Normal file
View file

@ -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<string>;
constructor(remoteRoot: string) {
this.known = new Set([remoteRoot.replace(/\/+$/, "")]);
}
async ensureParentOf(client: SftpClient, remoteFilePath: string): Promise<void> {
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<void> {
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<Buffer> {
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<Buffer> {
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<void> {
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<void> {
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<void> {
if ((await adapter.exists(vaultPath)) === true) {
await adapter.remove(vaultPath);
}
}

59
src/state/device-store.ts Normal file
View file

@ -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<void> {
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<void> {
const trimmed = label.trim();
this.info.label = trimmed || `device-${this.info.id.slice(0, 6)}`;
await this.save();
}
private async save(): Promise<void> {
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));
}
}

17
src/state/paths.ts Normal file
View file

@ -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 + "/";

160
src/sync/diff.ts Normal file
View file

@ -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<string, IndexEntry>,
remote: Record<string, ManifestEntry>,
snapshot: Record<string, ManifestEntry>,
isExcluded: (path: string) => boolean,
): SyncPlan {
const allPaths = new Set<string>();
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 <device> 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})`;
}

69
src/sync/dry-run.ts Normal file
View file

@ -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<DryRunReport> {
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<string, IndexEntry>();
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();
}
}

67
src/sync/exclude.ts Normal file
View file

@ -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;
}
}

11
src/sync/hash.ts Normal file
View file

@ -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");
}

124
src/sync/index-store.ts Normal file
View file

@ -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<string, IndexEntry>;
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<typeof setTimeout> | 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<string, IndexEntry> = {};
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<void> {
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<void> {
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<void> {
if (!(await this.adapter.exists(STATE_PATHS.dir))) {
await this.adapter.mkdir(STATE_PATHS.dir);
}
}
}

55
src/sync/last-synced.ts Normal file
View file

@ -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<string, ManifestEntry>;
}
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<void> {
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<void> {
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));
}
}

View file

@ -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<RebuildResult> {
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<string, ManifestEntry> = {};
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<string[]> {
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;
}

12
src/sync/path-utils.ts Normal file
View file

@ -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);
}

204
src/sync/pull-engine.ts Normal file
View file

@ -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<PullResult> {
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<void> {
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<void> {
if ((await adapter.exists(dir)) === false) {
await adapter.mkdir(dir);
}
}
}

240
src/sync/push-engine.ts Normal file
View file

@ -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<PushResult> {
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<string>();
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<string>,
): Promise<void> {
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<string>,
): Promise<void> {
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;
}
}
}

123
src/sync/scanner.ts Normal file
View file

@ -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<void> {
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<IndexEntry | null> {
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<string[]> {
const out: string[] = [];
await this.walk("", out);
return out;
}
private async walk(dir: string, out: string[]): Promise<void> {
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);
}
}
}

453
src/sync/sync-engine.ts Normal file
View file

@ -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<BulkDeleteDecision>;
/** 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<SyncOutcome> {
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<string, IndexEntry>();
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<string, ManifestEntry> = {};
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<string, ManifestEntry>,
conflictCopies: string[],
): Promise<void> {
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<string, ManifestEntry>,
conflictCopies: string[],
): Promise<void> {
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,
};
}

116
src/ui/bulk-delete-modal.ts Normal file
View file

@ -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<BulkDeleteDecision> {
return new Promise((resolve) => {
const modal = new BulkDeleteConfirmModal(app, warning);
modal.resolveFn = resolve;
modal.open();
});
}

View file

@ -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<ServerResetChoice> {
return new Promise((resolve) => {
const modal = new ServerResetModal(app, info);
modal.resolveFn = resolve;
modal.open();
});
}

19
tsconfig.json Normal file
View file

@ -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"]
}

3
versions.json Normal file
View file

@ -0,0 +1,3 @@
{
"0.1.0": "1.5.0"
}