mirror of
https://github.com/andrewkopylev/vaultbridge.git
synced 2026-07-22 06:50:15 +00:00
Initial commit: Vault Bridge SFTP v0.9.0
This commit is contained in:
commit
19f1791421
32 changed files with 4735 additions and 0 deletions
9
.gitignore
vendored
Normal file
9
.gitignore
vendored
Normal 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
21
LICENSE
Normal 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
268
README.md
Normal 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
51
esbuild.config.mjs
Normal 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
63
install.sh
Executable 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
9
manifest.json
Normal 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
857
package-lock.json
generated
Normal 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
25
package.json
Normal 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
51
save2git.sh
Executable 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
738
src/main.ts
Normal 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
307
src/settings.ts
Normal 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
69
src/sftp/client.ts
Normal 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
194
src/sftp/remote-state.ts
Normal 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
119
src/sftp/transfer.ts
Normal 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
59
src/state/device-store.ts
Normal 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
17
src/state/paths.ts
Normal 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
160
src/sync/diff.ts
Normal 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
69
src/sync/dry-run.ts
Normal 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
67
src/sync/exclude.ts
Normal 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
11
src/sync/hash.ts
Normal 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
124
src/sync/index-store.ts
Normal 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
55
src/sync/last-synced.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
132
src/sync/manifest-rebuilder.ts
Normal file
132
src/sync/manifest-rebuilder.ts
Normal 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
12
src/sync/path-utils.ts
Normal 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
204
src/sync/pull-engine.ts
Normal 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
240
src/sync/push-engine.ts
Normal 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
123
src/sync/scanner.ts
Normal 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
453
src/sync/sync-engine.ts
Normal 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
116
src/ui/bulk-delete-modal.ts
Normal 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();
|
||||
});
|
||||
}
|
||||
90
src/ui/server-reset-modal.ts
Normal file
90
src/ui/server-reset-modal.ts
Normal 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
19
tsconfig.json
Normal 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
3
versions.json
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"0.1.0": "1.5.0"
|
||||
}
|
||||
Loading…
Reference in a new issue