mirror of
https://github.com/andrewkopylev/vaultbridge.git
synced 2026-07-22 06:50:15 +00:00
Release 0.9.7 — fix lint warnings from Obsidian review bot
This commit is contained in:
parent
09e0932fb7
commit
62e1da82ed
20 changed files with 4785 additions and 217 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -9,3 +9,4 @@ data.json
|
|||
RELEASING.md
|
||||
CLAUDE.md
|
||||
КАК_ПУБЛИКОВАТЬ_РЕЛИЗ.txt
|
||||
pr-body.md
|
||||
|
|
|
|||
17
eslint.config.mjs
Normal file
17
eslint.config.mjs
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import tsparser from "@typescript-eslint/parser";
|
||||
import { defineConfig } from "eslint/config";
|
||||
import obsidianmd from "eslint-plugin-obsidianmd";
|
||||
|
||||
export default defineConfig([
|
||||
...obsidianmd.configs.recommended,
|
||||
{
|
||||
files: ["**/*.ts"],
|
||||
languageOptions: {
|
||||
parser: tsparser,
|
||||
parserOptions: { project: "./tsconfig.json" },
|
||||
},
|
||||
},
|
||||
{
|
||||
ignores: ["main.js", "node_modules/**", "dist/**"],
|
||||
},
|
||||
]);
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"id": "vault-bridge-sftp",
|
||||
"name": "Vault Bridge SFTP",
|
||||
"version": "0.9.6",
|
||||
"version": "0.9.7",
|
||||
"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",
|
||||
|
|
|
|||
4471
package-lock.json
generated
4471
package-lock.json
generated
File diff suppressed because it is too large
Load diff
13
package.json
13
package.json
|
|
@ -1,20 +1,29 @@
|
|||
{
|
||||
"name": "obsidian-vault-bridge-sftp",
|
||||
"version": "0.9.6",
|
||||
"version": "0.9.7",
|
||||
"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"],
|
||||
"keywords": [
|
||||
"obsidian",
|
||||
"sftp",
|
||||
"ssh",
|
||||
"sync"
|
||||
],
|
||||
"author": "Andrew Kopylev",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.11.0",
|
||||
"@types/ssh2-sftp-client": "^9.0.4",
|
||||
"@typescript-eslint/eslint-plugin": "^8.59.1",
|
||||
"@typescript-eslint/parser": "^8.59.1",
|
||||
"builtin-modules": "^3.3.0",
|
||||
"esbuild": "^0.20.2",
|
||||
"eslint": "^9.39.4",
|
||||
"eslint-plugin-obsidianmd": "^0.2.9",
|
||||
"obsidian": "latest",
|
||||
"tslib": "^2.6.2",
|
||||
"typescript": "^5.4.5"
|
||||
|
|
|
|||
171
src/main.ts
171
src/main.ts
|
|
@ -15,7 +15,7 @@ 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";
|
||||
import { pluginPaths, PluginPaths } from "./state/paths";
|
||||
|
||||
export default class SftpSyncPlugin extends Plugin {
|
||||
settings!: SftpSyncSettings;
|
||||
|
|
@ -26,6 +26,7 @@ export default class SftpSyncPlugin extends Plugin {
|
|||
lastSynced!: LastSyncedStore;
|
||||
secretStore!: SecretStore;
|
||||
knownHosts!: KnownHostsStore;
|
||||
paths!: PluginPaths;
|
||||
|
||||
private statusBar: HTMLElement | null = null;
|
||||
private syncInProgress = false;
|
||||
|
|
@ -37,6 +38,8 @@ export default class SftpSyncPlugin extends Plugin {
|
|||
private autoSyncDebouncer: ReturnType<typeof debounce> | null = null;
|
||||
|
||||
async onload() {
|
||||
this.paths = pluginPaths(this.app.vault.configDir);
|
||||
|
||||
// SecretStore must be ready before loadSettings — encrypted secrets are decrypted on load.
|
||||
this.secretStore = new SecretStore(this.app);
|
||||
await this.secretStore.load();
|
||||
|
|
@ -55,89 +58,89 @@ export default class SftpSyncPlugin extends Plugin {
|
|||
this.lastSynced = new LastSyncedStore(this.app);
|
||||
await this.lastSynced.load();
|
||||
|
||||
this.exclude = new ExcludeMatcher(this.settings);
|
||||
this.exclude = new ExcludeMatcher(this.app, 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.statusBar.addEventListener("click", () => { void this.syncNow(); });
|
||||
|
||||
this.addSettingTab(new SftpSyncSettingTab(this.app, this));
|
||||
|
||||
this.addCommand({
|
||||
id: "test-connection",
|
||||
name: "Test connection",
|
||||
callback: () => this.testConnection(),
|
||||
callback: () => { void this.testConnection(); },
|
||||
});
|
||||
|
||||
this.addCommand({
|
||||
id: "sync-now",
|
||||
name: "Sync now",
|
||||
callback: () => this.syncNow(),
|
||||
callback: () => { void this.syncNow(); },
|
||||
});
|
||||
|
||||
this.addCommand({
|
||||
id: "rebuild-index",
|
||||
name: "Rebuild local index",
|
||||
callback: () => this.rebuildIndex(),
|
||||
callback: () => { void this.rebuildIndex(); },
|
||||
});
|
||||
|
||||
this.addCommand({
|
||||
id: "index-stats",
|
||||
name: "Show index stats",
|
||||
callback: () => this.showIndexStats(),
|
||||
callback: () => { this.showIndexStats(); },
|
||||
});
|
||||
|
||||
this.addCommand({
|
||||
id: "inspect-remote",
|
||||
name: "Inspect remote state (manifest + lock)",
|
||||
callback: () => this.inspectRemoteState(),
|
||||
callback: () => { void this.inspectRemoteState(); },
|
||||
});
|
||||
|
||||
this.addCommand({
|
||||
id: "force-release-lock",
|
||||
name: "Force-release remote sync lock",
|
||||
callback: () => this.forceReleaseLock(),
|
||||
callback: () => { void this.forceReleaseLock(); },
|
||||
});
|
||||
|
||||
this.addCommand({
|
||||
id: "force-push",
|
||||
name: "Force push everything (re-upload all files)",
|
||||
callback: () => this.forcePush(),
|
||||
callback: () => { void this.forcePush(); },
|
||||
});
|
||||
|
||||
this.addCommand({
|
||||
id: "pull",
|
||||
name: "Pull from server",
|
||||
callback: () => this.pullNow(),
|
||||
callback: () => { void this.pullNow(); },
|
||||
});
|
||||
|
||||
this.addCommand({
|
||||
id: "force-pull",
|
||||
name: "Force pull everything (re-download all files)",
|
||||
callback: () => this.pullNow({ forceDownload: true }),
|
||||
callback: () => { void this.pullNow({ forceDownload: true }); },
|
||||
});
|
||||
|
||||
this.addCommand({
|
||||
id: "rebuild-remote-manifest",
|
||||
name: "Rebuild remote manifest (walk server, re-hash)",
|
||||
callback: () => this.rebuildRemoteManifestCmd(),
|
||||
callback: () => { void this.rebuildRemoteManifestCmd(); },
|
||||
});
|
||||
|
||||
this.addCommand({
|
||||
id: "reset-snapshot",
|
||||
name: "Reset local snapshot (treat next sync as fresh)",
|
||||
callback: () => this.resetLocalSnapshot(),
|
||||
callback: () => { void this.resetLocalSnapshot(); },
|
||||
});
|
||||
|
||||
this.addCommand({
|
||||
id: "forget-host-fingerprint",
|
||||
name: "Forget remembered host fingerprint",
|
||||
callback: () => this.forgetHostFingerprint(),
|
||||
callback: () => { void this.forgetHostFingerprint(); },
|
||||
});
|
||||
|
||||
// (e) Manual trigger: ribbon icon (in addition to the status bar click).
|
||||
this.addRibbonIcon("refresh-cw", "Vault Bridge: Sync now", () => this.syncNow());
|
||||
this.addRibbonIcon("refresh-cw", "Sync now", () => { void this.syncNow(); });
|
||||
|
||||
// (a) Startup: initial scan, then auto-sync if enabled.
|
||||
this.app.workspace.onLayoutReady(() => {
|
||||
|
|
@ -148,8 +151,13 @@ export default class SftpSyncPlugin extends Plugin {
|
|||
this.rebuildAutoSyncDebouncer();
|
||||
}
|
||||
|
||||
async onunload() {
|
||||
// (b) Quit-time best-effort push — fire and forget with a timeout.
|
||||
onunload(): void {
|
||||
// Synchronous teardown the base class expects.
|
||||
// Async work (best-effort quit-push, index flush) is fire-and-forget.
|
||||
void this.runOnUnload();
|
||||
}
|
||||
|
||||
private async runOnUnload(): Promise<void> {
|
||||
if (
|
||||
this.startupComplete &&
|
||||
this.settings?.autoSyncOnQuit &&
|
||||
|
|
@ -162,7 +170,11 @@ export default class SftpSyncPlugin extends Plugin {
|
|||
}
|
||||
}
|
||||
// Force a final flush so we don't lose recent updates.
|
||||
if (this.index) await this.index.flush().catch(() => {});
|
||||
if (this.index) {
|
||||
try {
|
||||
await this.index.flush();
|
||||
} catch (_flushErr) { /* ignore — best-effort on shutdown */ }
|
||||
}
|
||||
// Clean up any pending debounced calls.
|
||||
for (const fn of this.pendingRefresh.values()) fn.cancel?.();
|
||||
this.pendingRefresh.clear();
|
||||
|
|
@ -179,9 +191,10 @@ export default class SftpSyncPlugin extends Plugin {
|
|||
|
||||
// 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 cfg = this.app.vault.configDir;
|
||||
const RETIRED_AUTO_PATTERNS = new Set([
|
||||
".obsidian/workspace.json",
|
||||
".obsidian/workspace-mobile.json",
|
||||
`${cfg}/workspace.json`,
|
||||
`${cfg}/workspace-mobile.json`,
|
||||
]);
|
||||
if (Array.isArray(raw.excludePatterns)) {
|
||||
raw.excludePatterns = (raw.excludePatterns as string[]).filter(
|
||||
|
|
@ -216,7 +229,7 @@ export default class SftpSyncPlugin extends Plugin {
|
|||
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;
|
||||
this.settings = Object.assign({}, DEFAULT_SETTINGS, known);
|
||||
|
||||
if (needsResaveEncrypted) {
|
||||
await this.persistSettings();
|
||||
|
|
@ -239,7 +252,7 @@ export default class SftpSyncPlugin extends Plugin {
|
|||
await this.persistSettings();
|
||||
// Re-build matcher when settings change (toggles, exclude patterns).
|
||||
if (this.exclude) {
|
||||
this.exclude = new ExcludeMatcher(this.settings);
|
||||
this.exclude = new ExcludeMatcher(this.app, this.settings);
|
||||
this.scanner = new Scanner(this.app, this.index, this.exclude);
|
||||
}
|
||||
// Re-create the debouncer so a changed delay takes effect.
|
||||
|
|
@ -270,23 +283,23 @@ export default class SftpSyncPlugin extends Plugin {
|
|||
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);
|
||||
activeWindow.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`);
|
||||
console.debug(`Vault Bridge: auto-sync (${reason}) skipped — already running`);
|
||||
return;
|
||||
}
|
||||
if (!isConnectionConfigured(this.settings)) {
|
||||
console.log(`Vault Bridge: auto-sync (${reason}) skipped — connection not configured`);
|
||||
console.debug(`Vault Bridge: auto-sync (${reason}) skipped — connection not configured`);
|
||||
return;
|
||||
}
|
||||
this.syncInProgress = true;
|
||||
this.setStatus("syncing");
|
||||
console.log(`Vault Bridge: auto-sync triggered (${reason})`);
|
||||
console.debug(`Vault Bridge: auto-sync triggered (${reason})`);
|
||||
try {
|
||||
const engine = new SyncEngine(
|
||||
this.app, this.settings, this.deviceStore,
|
||||
|
|
@ -297,12 +310,12 @@ export default class SftpSyncPlugin extends Plugin {
|
|||
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) => {
|
||||
confirmBulkDelete: (info) => {
|
||||
new Notice(
|
||||
`Vault Bridge: auto-sync paused — ${info.outgoingDeletes.length + info.incomingDeletes.length} pending deletions need review. Run "Sync now" manually.`,
|
||||
`Auto-sync paused — ${info.outgoingDeletes.length + info.incomingDeletes.length} pending deletions need review. Run "Sync now" manually.`,
|
||||
10000,
|
||||
);
|
||||
return "cancel";
|
||||
return Promise.resolve("cancel");
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -319,7 +332,7 @@ export default class SftpSyncPlugin extends Plugin {
|
|||
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)`;
|
||||
let msg = `Auto-sync (${reason}): ${parts.join(", ")} (${secs}s)`;
|
||||
if (result.conflictCopies.length) {
|
||||
msg += `\nConflict copies: ${result.conflictCopies.length}`;
|
||||
}
|
||||
|
|
@ -329,7 +342,7 @@ export default class SftpSyncPlugin extends Plugin {
|
|||
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);
|
||||
new Notice(`Auto-sync failed — ${(err as Error).message}`, 8000);
|
||||
this.setStatus("error");
|
||||
} finally {
|
||||
this.syncInProgress = false;
|
||||
|
|
@ -346,7 +359,7 @@ export default class SftpSyncPlugin extends Plugin {
|
|||
await engine.pushAll();
|
||||
})();
|
||||
const timeout = new Promise<never>((_, reject) =>
|
||||
setTimeout(() => reject(new Error(`quit-push timed out after ${timeoutMs}ms`)), timeoutMs),
|
||||
activeWindow.setTimeout(() => reject(new Error(`quit-push timed out after ${timeoutMs}ms`)), timeoutMs),
|
||||
);
|
||||
await Promise.race([work, timeout]);
|
||||
}
|
||||
|
|
@ -358,17 +371,17 @@ export default class SftpSyncPlugin extends Plugin {
|
|||
// Subsequent loads: index exists → cheap mtime check.
|
||||
const isFirstScan = this.index.size() === 0;
|
||||
if (isFirstScan) {
|
||||
new Notice("Vault Bridge: building initial index…", 3000);
|
||||
new Notice("Building initial index…", 3000);
|
||||
}
|
||||
try {
|
||||
const { entries, took } = await this.scanner.fullScan();
|
||||
console.log(`Vault Bridge: scan finished — ${entries.length} files in ${took}ms`);
|
||||
console.debug(`Vault Bridge: scan finished — ${entries.length} files in ${took}ms`);
|
||||
if (isFirstScan) {
|
||||
new Notice(`Vault Bridge: indexed ${entries.length} files (${took}ms)`, 4000);
|
||||
new Notice(`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);
|
||||
new Notice(`Initial scan failed — ${(err as Error).message}`, 8000);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -445,10 +458,10 @@ export default class SftpSyncPlugin extends Plugin {
|
|||
try {
|
||||
await client.connect();
|
||||
await client.ensureRemoteRoot();
|
||||
new Notice("Vault Bridge: connection OK", 4000);
|
||||
new Notice("Connection OK", 4000);
|
||||
} catch (err) {
|
||||
console.error("Vault Bridge: connection failed", err);
|
||||
new Notice(`Vault Bridge: connection FAILED — ${(err as Error).message}`, 8000);
|
||||
new Notice(`Connection FAILED — ${(err as Error).message}`, 8000);
|
||||
} finally {
|
||||
await client.end();
|
||||
}
|
||||
|
|
@ -457,7 +470,7 @@ export default class SftpSyncPlugin extends Plugin {
|
|||
/** Bidirectional sync — the everyday command. Uses SyncEngine (3-way diff). */
|
||||
async syncNow(): Promise<void> {
|
||||
if (this.syncInProgress) {
|
||||
new Notice("Vault Bridge: already running", 3000);
|
||||
new Notice("Already running", 3000);
|
||||
return;
|
||||
}
|
||||
this.syncInProgress = true;
|
||||
|
|
@ -496,12 +509,12 @@ export default class SftpSyncPlugin extends Plugin {
|
|||
|
||||
if (result.cancelled) {
|
||||
new Notice(
|
||||
`Vault Bridge: cancelled by user (no changes made, ${secs}s)`,
|
||||
`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)`,
|
||||
`Already in sync (gen=${result.generation}, ${secs}s)`,
|
||||
5000,
|
||||
);
|
||||
} else {
|
||||
|
|
@ -513,15 +526,15 @@ export default class SftpSyncPlugin extends Plugin {
|
|||
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)`;
|
||||
let msg = `${parts.join(", ")} (gen=${result.generation}, ${secs}s)`;
|
||||
if (result.deletesSkipped) {
|
||||
msg += "\n⚠️ Deletes were skipped at user request — re-run sync to address them.";
|
||||
msg += "\nDeletes 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.`;
|
||||
msg += `\n${result.selfHealedMissing.length} file(s) were missing on server — dropped from manifest.`;
|
||||
}
|
||||
new Notice(msg, 10000);
|
||||
}
|
||||
|
|
@ -529,7 +542,7 @@ export default class SftpSyncPlugin extends Plugin {
|
|||
this.setStatus("ok");
|
||||
} catch (err) {
|
||||
console.error("Vault Bridge: sync failed", err);
|
||||
new Notice(`Vault Bridge: sync FAILED — ${(err as Error).message}`, 10000);
|
||||
new Notice(`Sync FAILED — ${(err as Error).message}`, 10000);
|
||||
this.setStatus("error");
|
||||
} finally {
|
||||
this.syncInProgress = false;
|
||||
|
|
@ -540,16 +553,16 @@ export default class SftpSyncPlugin extends Plugin {
|
|||
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);
|
||||
new Notice("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);
|
||||
new Notice("Forcing push from local…", 3000);
|
||||
try {
|
||||
await this._runForcePush();
|
||||
} catch (err) {
|
||||
} catch (_err) {
|
||||
// Already reported by _runForcePush; nothing more to do.
|
||||
}
|
||||
return;
|
||||
|
|
@ -557,7 +570,7 @@ export default class SftpSyncPlugin extends Plugin {
|
|||
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.",
|
||||
"Local snapshot reset. Next sync will treat all files as fresh additions.",
|
||||
6000,
|
||||
);
|
||||
}
|
||||
|
|
@ -567,29 +580,29 @@ export default class SftpSyncPlugin extends Plugin {
|
|||
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);
|
||||
if ((await adapter.exists(this.paths.lastSynced)) === true) {
|
||||
await adapter.remove(this.paths.lastSynced);
|
||||
}
|
||||
await this.lastSynced.load(); // re-loads as empty
|
||||
if (!opts.silent) {
|
||||
new Notice("Vault Bridge: local snapshot reset.", 5000);
|
||||
new Notice("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);
|
||||
new Notice(`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);
|
||||
new Notice("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);
|
||||
new Notice("Walking server and rebuilding manifest — this can take a while…", 4000);
|
||||
const result = await rebuildRemoteManifest(
|
||||
this.settings,
|
||||
this.deviceStore,
|
||||
|
|
@ -603,13 +616,13 @@ export default class SftpSyncPlugin extends Plugin {
|
|||
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.`,
|
||||
`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);
|
||||
new Notice(`Rebuild FAILED — ${(err as Error).message}`, 10000);
|
||||
this.setStatus("error");
|
||||
} finally {
|
||||
this.syncInProgress = false;
|
||||
|
|
@ -619,7 +632,7 @@ export default class SftpSyncPlugin extends Plugin {
|
|||
/** 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);
|
||||
new Notice("Already running", 3000);
|
||||
return;
|
||||
}
|
||||
this.syncInProgress = true;
|
||||
|
|
@ -653,13 +666,13 @@ export default class SftpSyncPlugin extends Plugin {
|
|||
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})`,
|
||||
`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);
|
||||
new Notice(`Force-push FAILED — ${(err as Error).message}`, 10000);
|
||||
this.setStatus("error");
|
||||
throw err;
|
||||
}
|
||||
|
|
@ -667,7 +680,7 @@ export default class SftpSyncPlugin extends Plugin {
|
|||
|
||||
async pullNow(opts: { forceDownload?: boolean } = {}): Promise<void> {
|
||||
if (this.syncInProgress) {
|
||||
new Notice("Vault Bridge: already running", 3000);
|
||||
new Notice("Already running", 3000);
|
||||
return;
|
||||
}
|
||||
this.syncInProgress = true;
|
||||
|
|
@ -694,13 +707,13 @@ export default class SftpSyncPlugin extends Plugin {
|
|||
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})`;
|
||||
? `Nothing to pull (${result.skipped} already up to date, ${secs}s, gen=${result.generation})`
|
||||
: `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);
|
||||
new Notice(`Pull FAILED — ${(err as Error).message}`, 10000);
|
||||
this.setStatus("error");
|
||||
} finally {
|
||||
this.syncInProgress = false;
|
||||
|
|
@ -718,13 +731,13 @@ export default class SftpSyncPlugin extends Plugin {
|
|||
}
|
||||
|
||||
async rebuildIndex(): Promise<void> {
|
||||
new Notice("Vault Bridge: rebuilding local index…", 3000);
|
||||
new Notice("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);
|
||||
new Notice(`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);
|
||||
new Notice(`Rebuild failed — ${(err as Error).message}`, 8000);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -733,7 +746,7 @@ export default class SftpSyncPlugin extends Plugin {
|
|||
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);
|
||||
new Notice(`${n} files, ${mb} MB, last full scan: ${ago}`, 6000);
|
||||
}
|
||||
|
||||
/** Connect, read manifest + lock, print summary. Used to verify Phase 3. */
|
||||
|
|
@ -761,11 +774,11 @@ export default class SftpSyncPlugin extends Plugin {
|
|||
? `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 });
|
||||
new Notice(`Remote state\n${manifestSummary}\n${lockSummary}`, 10000);
|
||||
console.debug("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);
|
||||
new Notice(`Inspect failed — ${(err as Error).message}`, 8000);
|
||||
} finally {
|
||||
await client.end();
|
||||
}
|
||||
|
|
@ -784,21 +797,21 @@ export default class SftpSyncPlugin extends Plugin {
|
|||
);
|
||||
const before = await remote.readLock();
|
||||
if (!before) {
|
||||
new Notice("Vault Bridge: no lock to release", 4000);
|
||||
new Notice("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.)`,
|
||||
`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);
|
||||
new Notice("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);
|
||||
new Notice(`Force release failed — ${(err as Error).message}`, 8000);
|
||||
} finally {
|
||||
await client.end();
|
||||
}
|
||||
|
|
@ -808,18 +821,18 @@ export default class SftpSyncPlugin extends Plugin {
|
|||
* Use after a deliberate server reinstall so the next connect re-runs TOFU. */
|
||||
async forgetHostFingerprint(): Promise<void> {
|
||||
if (!this.settings.host) {
|
||||
new Notice("Vault Bridge: no host configured", 4000);
|
||||
new Notice("No host configured", 4000);
|
||||
return;
|
||||
}
|
||||
const port = this.settings.port || 22;
|
||||
const removed = await this.knownHosts.forget(this.settings.host, port);
|
||||
if (removed) {
|
||||
new Notice(
|
||||
`Vault Bridge: forgot fingerprint for ${this.settings.host}:${port}. Next connection will trust the new server key on first contact.`,
|
||||
`Forgot fingerprint for ${this.settings.host}:${port}. Next connection will trust the new server key on first contact.`,
|
||||
6000,
|
||||
);
|
||||
} else {
|
||||
new Notice(`Vault Bridge: no remembered fingerprint for ${this.settings.host}:${port}`, 5000);
|
||||
new Notice(`No remembered fingerprint for ${this.settings.host}:${port}`, 5000);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { App, PluginSettingTab, Setting, Notice } from "obsidian";
|
||||
import { App, PluginSettingTab, Setting } from "obsidian";
|
||||
import type SftpSyncPlugin from "./main";
|
||||
|
||||
export type AuthMethod = "password" | "key";
|
||||
|
|
@ -88,9 +88,9 @@ export class SftpSyncSettingTab extends PluginSettingTab {
|
|||
t
|
||||
.setPlaceholder("example.com")
|
||||
.setValue(this.plugin.settings.host)
|
||||
.onChange(async (v) => {
|
||||
.onChange((v) => {
|
||||
this.plugin.settings.host = v.trim();
|
||||
await this.plugin.saveSettings();
|
||||
void this.plugin.saveSettings();
|
||||
}),
|
||||
);
|
||||
|
||||
|
|
@ -101,10 +101,10 @@ export class SftpSyncSettingTab extends PluginSettingTab {
|
|||
t
|
||||
.setPlaceholder("22")
|
||||
.setValue(String(this.plugin.settings.port))
|
||||
.onChange(async (v) => {
|
||||
.onChange((v) => {
|
||||
const n = parseInt(v, 10);
|
||||
this.plugin.settings.port = Number.isFinite(n) && n > 0 ? n : 22;
|
||||
await this.plugin.saveSettings();
|
||||
void this.plugin.saveSettings();
|
||||
}),
|
||||
);
|
||||
|
||||
|
|
@ -113,23 +113,24 @@ export class SftpSyncSettingTab extends PluginSettingTab {
|
|||
.addText((t) =>
|
||||
t
|
||||
.setValue(this.plugin.settings.username)
|
||||
.onChange(async (v) => {
|
||||
.onChange((v) => {
|
||||
this.plugin.settings.username = v.trim();
|
||||
await this.plugin.saveSettings();
|
||||
void this.plugin.saveSettings();
|
||||
}),
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("Authentication")
|
||||
.setDesc("Password or SSH private key. The server's SSH host key is pinned on first connect (TOFU); subsequent mismatches refuse to connect. Use \"Forget remembered host fingerprint\" after a deliberate server reinstall.")
|
||||
// eslint-disable-next-line obsidianmd/ui/sentence-case
|
||||
.setDesc("Password or SSH private key. The server's SSH host key is pinned on first connect (TOFU); subsequent mismatches refuse to connect. Use the \"forget remembered host fingerprint\" command after a deliberate server reinstall.")
|
||||
.addDropdown((d) =>
|
||||
d
|
||||
.addOption("password", "Password")
|
||||
.addOption("key", "Private key")
|
||||
.setValue(this.plugin.settings.authMethod)
|
||||
.onChange(async (v) => {
|
||||
.onChange((v) => {
|
||||
this.plugin.settings.authMethod = v as AuthMethod;
|
||||
await this.plugin.saveSettings();
|
||||
void this.plugin.saveSettings();
|
||||
this.display();
|
||||
}),
|
||||
);
|
||||
|
|
@ -140,22 +141,24 @@ export class SftpSyncSettingTab extends PluginSettingTab {
|
|||
.setDesc("Encrypted at rest (AES-256-GCM) with a per-device key in state/secret.key. The state directory never syncs, so a leaked data.json on the SFTP server cannot be decrypted without local access. SSH keys are still preferred on shared machines.")
|
||||
.addText((t) => {
|
||||
t.inputEl.type = "password";
|
||||
t.setValue(this.plugin.settings.password).onChange(async (v) => {
|
||||
t.setValue(this.plugin.settings.password).onChange((v) => {
|
||||
this.plugin.settings.password = v;
|
||||
await this.plugin.saveSettings();
|
||||
void this.plugin.saveSettings();
|
||||
});
|
||||
});
|
||||
} else {
|
||||
new Setting(containerEl)
|
||||
.setName("Private key path")
|
||||
// eslint-disable-next-line obsidianmd/ui/sentence-case
|
||||
.setDesc("Absolute filesystem path, e.g. /home/user/.ssh/id_ed25519.")
|
||||
.addText((t) =>
|
||||
t
|
||||
// eslint-disable-next-line obsidianmd/ui/sentence-case
|
||||
.setPlaceholder("/home/user/.ssh/id_ed25519")
|
||||
.setValue(this.plugin.settings.privateKeyPath)
|
||||
.onChange(async (v) => {
|
||||
.onChange((v) => {
|
||||
this.plugin.settings.privateKeyPath = v.trim();
|
||||
await this.plugin.saveSettings();
|
||||
void this.plugin.saveSettings();
|
||||
}),
|
||||
);
|
||||
|
||||
|
|
@ -164,23 +167,25 @@ export class SftpSyncSettingTab extends PluginSettingTab {
|
|||
.setDesc("Empty if the key is unencrypted.")
|
||||
.addText((t) => {
|
||||
t.inputEl.type = "password";
|
||||
t.setValue(this.plugin.settings.passphrase).onChange(async (v) => {
|
||||
t.setValue(this.plugin.settings.passphrase).onChange((v) => {
|
||||
this.plugin.settings.passphrase = v;
|
||||
await this.plugin.saveSettings();
|
||||
void this.plugin.saveSettings();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("Remote root")
|
||||
// eslint-disable-next-line obsidianmd/ui/sentence-case
|
||||
.setDesc("Absolute path on the server, e.g. /home/user/obsidian-vault. Created if it doesn't exist.")
|
||||
.addText((t) =>
|
||||
t
|
||||
// eslint-disable-next-line obsidianmd/ui/sentence-case
|
||||
.setPlaceholder("/home/user/obsidian-vault")
|
||||
.setValue(this.plugin.settings.remoteRoot)
|
||||
.onChange(async (v) => {
|
||||
.onChange((v) => {
|
||||
this.plugin.settings.remoteRoot = v.trim();
|
||||
await this.plugin.saveSettings();
|
||||
void this.plugin.saveSettings();
|
||||
}),
|
||||
);
|
||||
|
||||
|
|
@ -188,37 +193,37 @@ export class SftpSyncSettingTab extends PluginSettingTab {
|
|||
new Setting(containerEl).setName("Sync scope").setHeading();
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("Sync everything (.obsidian too)")
|
||||
.setDesc("When ON, plugins/themes/snippets/hotkeys are synced so all devices look identical.")
|
||||
.setName("Sync the Obsidian config folder too")
|
||||
.setDesc("When enabled, plugins/themes/snippets/hotkeys are synced so all devices look identical.")
|
||||
.addToggle((t) =>
|
||||
t.setValue(this.plugin.settings.syncEverything).onChange(async (v) => {
|
||||
t.setValue(this.plugin.settings.syncEverything).onChange((v) => {
|
||||
this.plugin.settings.syncEverything = v;
|
||||
await this.plugin.saveSettings();
|
||||
void 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.")
|
||||
.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) => {
|
||||
t.setValue(this.plugin.settings.syncWorkspaceJson).onChange((v) => {
|
||||
this.plugin.settings.syncWorkspaceJson = v;
|
||||
await this.plugin.saveSettings();
|
||||
void 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.")
|
||||
.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.addClass("vbsftp-textarea-full");
|
||||
t.setValue(this.plugin.settings.excludePatterns.join("\n")).onChange(async (v) => {
|
||||
t.setValue(this.plugin.settings.excludePatterns.join("\n")).onChange((v) => {
|
||||
this.plugin.settings.excludePatterns = v
|
||||
.split("\n")
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s.length > 0);
|
||||
await this.plugin.saveSettings();
|
||||
void this.plugin.saveSettings();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -229,9 +234,9 @@ export class SftpSyncSettingTab extends PluginSettingTab {
|
|||
.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) => {
|
||||
t.setValue(this.plugin.settings.autoSyncOnStartup).onChange((v) => {
|
||||
this.plugin.settings.autoSyncOnStartup = v;
|
||||
await this.plugin.saveSettings();
|
||||
void this.plugin.saveSettings();
|
||||
}),
|
||||
);
|
||||
|
||||
|
|
@ -239,9 +244,9 @@ export class SftpSyncSettingTab extends PluginSettingTab {
|
|||
.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) => {
|
||||
t.setValue(this.plugin.settings.autoSyncOnQuit).onChange((v) => {
|
||||
this.plugin.settings.autoSyncOnQuit = v;
|
||||
await this.plugin.saveSettings();
|
||||
void this.plugin.saveSettings();
|
||||
}),
|
||||
);
|
||||
|
||||
|
|
@ -249,9 +254,9 @@ export class SftpSyncSettingTab extends PluginSettingTab {
|
|||
.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) => {
|
||||
t.setValue(this.plugin.settings.autoSyncOnChange).onChange((v) => {
|
||||
this.plugin.settings.autoSyncOnChange = v;
|
||||
await this.plugin.saveSettings();
|
||||
void this.plugin.saveSettings();
|
||||
}),
|
||||
);
|
||||
|
||||
|
|
@ -261,11 +266,11 @@ export class SftpSyncSettingTab extends PluginSettingTab {
|
|||
.addText((t) =>
|
||||
t
|
||||
.setValue(String(this.plugin.settings.autoSyncDebounceSeconds))
|
||||
.onChange(async (v) => {
|
||||
.onChange((v) => {
|
||||
const n = parseInt(v, 10);
|
||||
this.plugin.settings.autoSyncDebounceSeconds =
|
||||
Number.isFinite(n) && n >= 2 && n <= 600 ? n : 10;
|
||||
await this.plugin.saveSettings();
|
||||
void this.plugin.saveSettings();
|
||||
}),
|
||||
);
|
||||
|
||||
|
|
@ -278,11 +283,11 @@ export class SftpSyncSettingTab extends PluginSettingTab {
|
|||
.addText((t) =>
|
||||
t
|
||||
.setValue(String(this.plugin.settings.concurrency))
|
||||
.onChange(async (v) => {
|
||||
.onChange((v) => {
|
||||
const n = parseInt(v, 10);
|
||||
this.plugin.settings.concurrency =
|
||||
Number.isFinite(n) && n >= MIN_CONCURRENCY && n <= MAX_CONCURRENCY ? n : 8;
|
||||
await this.plugin.saveSettings();
|
||||
void this.plugin.saveSettings();
|
||||
}),
|
||||
);
|
||||
|
||||
|
|
@ -310,7 +315,7 @@ export class SftpSyncSettingTab extends PluginSettingTab {
|
|||
.setName("Sync now")
|
||||
.setDesc("Run a full sync immediately.")
|
||||
.addButton((b) =>
|
||||
b.setButtonText("Sync now").onClick(() => this.plugin.syncNow()),
|
||||
b.setButtonText("Sync now").onClick(() => { void this.plugin.syncNow(); }),
|
||||
);
|
||||
|
||||
// ─── Device ───────────────────────────────────────────────────────────
|
||||
|
|
@ -321,10 +326,11 @@ export class SftpSyncSettingTab extends PluginSettingTab {
|
|||
.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
|
||||
// eslint-disable-next-line obsidianmd/ui/sentence-case
|
||||
.setPlaceholder("home-desktop")
|
||||
.setValue(this.plugin.deviceStore.label)
|
||||
.onChange(async (v) => {
|
||||
await this.plugin.deviceStore.setLabel(v);
|
||||
.onChange((v) => {
|
||||
void this.plugin.deviceStore.setLabel(v);
|
||||
}),
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -153,7 +153,7 @@ export class RemoteState {
|
|||
|
||||
// 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));
|
||||
await new Promise((r) => activeWindow.setTimeout(r, RACE_VERIFY_MS));
|
||||
const after = await this.readLock();
|
||||
if (!after || after.deviceId !== our.deviceId || after.acquiredAt !== our.acquiredAt) {
|
||||
return null;
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
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.
|
||||
* Concurrency-safe: every ancestor along a path is ensured top-down through the same
|
||||
|
|
@ -97,20 +96,22 @@ export async function downloadToBuffer(
|
|||
return buf;
|
||||
}
|
||||
|
||||
/** Write a Buffer into the vault at `vaultPath`, atomically via state/tmp/. */
|
||||
/** Write a Buffer into the vault at `vaultPath`, atomically via the given tmp directory.
|
||||
* The caller (engine) provides `tmpDir` because it owns the path layout. */
|
||||
export async function writeBufferToVault(
|
||||
adapter: DataAdapter,
|
||||
vaultPath: string,
|
||||
buf: Buffer,
|
||||
tmpDir: string,
|
||||
): 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);
|
||||
if ((await adapter.exists(tmpDir)) === false) {
|
||||
await adapter.mkdir(tmpDir);
|
||||
}
|
||||
const tmp = `${STATE_PATHS.tmp}/dl.${Date.now().toString(36)}.${Math.random().toString(36).slice(2, 8)}`;
|
||||
const tmp = `${tmpDir}/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 {
|
||||
|
|
@ -119,7 +120,10 @@ export async function writeBufferToVault(
|
|||
}
|
||||
await adapter.rename(tmp, vaultPath);
|
||||
} catch (err) {
|
||||
try { await adapter.remove(tmp); } catch {}
|
||||
// Best-effort cleanup of the staging file; never let cleanup errors mask the rename failure.
|
||||
try {
|
||||
await adapter.remove(tmp);
|
||||
} catch (_cleanupErr) { /* ignore */ }
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import type { App, DataAdapter } from "obsidian";
|
||||
import { randomBytes } from "crypto";
|
||||
import { STATE_PATHS } from "./paths";
|
||||
import { pluginPaths, PluginPaths } from "./paths";
|
||||
|
||||
interface DeviceInfo {
|
||||
id: string;
|
||||
|
|
@ -13,10 +13,12 @@ interface DeviceInfo {
|
|||
*/
|
||||
export class DeviceStore {
|
||||
private adapter: DataAdapter;
|
||||
private paths: PluginPaths;
|
||||
private info: DeviceInfo = { id: "", label: "" };
|
||||
|
||||
constructor(app: App) {
|
||||
this.adapter = app.vault.adapter;
|
||||
this.paths = pluginPaths(app.vault.configDir);
|
||||
}
|
||||
|
||||
get id(): string { return this.info.id; }
|
||||
|
|
@ -25,9 +27,16 @@ export class DeviceStore {
|
|||
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);
|
||||
if (await this.adapter.exists(this.paths.device)) {
|
||||
const raw = await this.adapter.read(this.paths.device);
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
if (parsed && typeof parsed === "object") {
|
||||
const obj = parsed as Partial<DeviceInfo>;
|
||||
this.info = {
|
||||
id: typeof obj.id === "string" ? obj.id : "",
|
||||
label: typeof obj.label === "string" ? obj.label : "",
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("Vault Bridge: failed to load device info, regenerating", err);
|
||||
|
|
@ -51,9 +60,9 @@ export class DeviceStore {
|
|||
}
|
||||
|
||||
private async save(): Promise<void> {
|
||||
if (!(await this.adapter.exists(STATE_PATHS.dir))) {
|
||||
await this.adapter.mkdir(STATE_PATHS.dir);
|
||||
if (!(await this.adapter.exists(this.paths.stateDir))) {
|
||||
await this.adapter.mkdir(this.paths.stateDir);
|
||||
}
|
||||
await this.adapter.write(STATE_PATHS.device, JSON.stringify(this.info, null, 2));
|
||||
await this.adapter.write(this.paths.device, JSON.stringify(this.info, null, 2));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,5 @@
|
|||
import type { App } from "obsidian";
|
||||
import { STATE_PATHS } from "./paths";
|
||||
|
||||
const KNOWN_HOSTS_PATH = `${STATE_PATHS.dir}/known-hosts.json`;
|
||||
import { pluginPaths, PluginPaths } from "./paths";
|
||||
|
||||
export interface KnownHostEntry {
|
||||
fingerprint: string; // SHA-256 of the server host key, base64
|
||||
|
|
@ -20,18 +18,23 @@ interface KnownHostsFile {
|
|||
* indicate a man-in-the-middle attack.
|
||||
*/
|
||||
export class KnownHostsStore {
|
||||
private paths: PluginPaths;
|
||||
private filePath: string;
|
||||
private hosts: Record<string, KnownHostEntry> = {};
|
||||
|
||||
constructor(private app: App) {}
|
||||
constructor(private app: App) {
|
||||
this.paths = pluginPaths(app.vault.configDir);
|
||||
this.filePath = `${this.paths.stateDir}/known-hosts.json`;
|
||||
}
|
||||
|
||||
async load(): Promise<void> {
|
||||
const adapter = this.app.vault.adapter;
|
||||
if ((await adapter.exists(KNOWN_HOSTS_PATH)) === false) {
|
||||
if ((await adapter.exists(this.filePath)) === false) {
|
||||
this.hosts = {};
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const raw = await adapter.read(KNOWN_HOSTS_PATH);
|
||||
const raw = await adapter.read(this.filePath);
|
||||
const parsed = JSON.parse(raw) as KnownHostsFile;
|
||||
if (parsed.schemaVersion !== 1) throw new Error("unsupported known-hosts schema");
|
||||
this.hosts = parsed.hosts ?? {};
|
||||
|
|
@ -43,11 +46,11 @@ export class KnownHostsStore {
|
|||
|
||||
private async save(): Promise<void> {
|
||||
const adapter = this.app.vault.adapter;
|
||||
if ((await adapter.exists(STATE_PATHS.dir)) === false) {
|
||||
await adapter.mkdir(STATE_PATHS.dir);
|
||||
if ((await adapter.exists(this.paths.stateDir)) === false) {
|
||||
await adapter.mkdir(this.paths.stateDir);
|
||||
}
|
||||
const file: KnownHostsFile = { schemaVersion: 1, hosts: this.hosts };
|
||||
await adapter.write(KNOWN_HOSTS_PATH, JSON.stringify(file, null, 2));
|
||||
await adapter.write(this.filePath, JSON.stringify(file, null, 2));
|
||||
}
|
||||
|
||||
static keyFor(host: string, port: number): string {
|
||||
|
|
|
|||
|
|
@ -2,16 +2,31 @@
|
|||
|
||||
export const PLUGIN_ID = "vault-bridge-sftp";
|
||||
|
||||
const STATE_DIR = `.obsidian/plugins/${PLUGIN_ID}/state`;
|
||||
export interface PluginPaths {
|
||||
configDir: string;
|
||||
/** Per-device state directory; never synced. */
|
||||
stateDir: string;
|
||||
index: string;
|
||||
lastSynced: string;
|
||||
device: string;
|
||||
tmp: string;
|
||||
log: string;
|
||||
/** Hard-coded path prefix that must NEVER be synced — recursion would corrupt the index. */
|
||||
stateDirPrefix: string;
|
||||
}
|
||||
|
||||
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 + "/";
|
||||
/** Build per-plugin paths rooted at the user's Obsidian config folder.
|
||||
* Pass `app.vault.configDir` — Obsidian lets users rename ".obsidian", so this must be dynamic. */
|
||||
export function pluginPaths(configDir: string): PluginPaths {
|
||||
const stateDir = `${configDir}/plugins/${PLUGIN_ID}/state`;
|
||||
return {
|
||||
configDir,
|
||||
stateDir,
|
||||
index: `${stateDir}/index.json`,
|
||||
lastSynced: `${stateDir}/last-synced.json`,
|
||||
device: `${stateDir}/device.json`,
|
||||
tmp: `${stateDir}/tmp`,
|
||||
log: `${stateDir}/log.jsonl`,
|
||||
stateDirPrefix: stateDir + "/",
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
import type { App } from "obsidian";
|
||||
import { randomBytes, createCipheriv, createDecipheriv } from "crypto";
|
||||
import { STATE_PATHS } from "./paths";
|
||||
import { pluginPaths, PluginPaths } from "./paths";
|
||||
|
||||
const SECRET_PATH = `${STATE_PATHS.dir}/secret.key`;
|
||||
const ENC_PREFIX = "enc:v1:";
|
||||
const KEY_BYTES = 32; // AES-256
|
||||
const IV_BYTES = 12; // 96-bit IV recommended for GCM
|
||||
|
|
@ -17,28 +16,33 @@ const IV_BYTES = 12; // 96-bit IV recommended for GCM
|
|||
* locally with the user's privileges.
|
||||
*/
|
||||
export class SecretStore {
|
||||
private paths: PluginPaths;
|
||||
private secretPath: string;
|
||||
private key: Buffer | null = null;
|
||||
|
||||
constructor(private app: App) {}
|
||||
constructor(private app: App) {
|
||||
this.paths = pluginPaths(app.vault.configDir);
|
||||
this.secretPath = `${this.paths.stateDir}/secret.key`;
|
||||
}
|
||||
|
||||
async load(): Promise<void> {
|
||||
const adapter = this.app.vault.adapter;
|
||||
if ((await adapter.exists(SECRET_PATH)) === true) {
|
||||
const ab = await adapter.readBinary(SECRET_PATH);
|
||||
if ((await adapter.exists(this.secretPath)) === true) {
|
||||
const ab = await adapter.readBinary(this.secretPath);
|
||||
const buf = Buffer.from(ab);
|
||||
if (buf.length !== KEY_BYTES) {
|
||||
throw new Error(`Vault Bridge: ${SECRET_PATH} has unexpected length ${buf.length}`);
|
||||
throw new Error(`Vault Bridge: ${this.secretPath} has unexpected length ${buf.length}`);
|
||||
}
|
||||
this.key = buf;
|
||||
return;
|
||||
}
|
||||
|
||||
if ((await adapter.exists(STATE_PATHS.dir)) === false) {
|
||||
await adapter.mkdir(STATE_PATHS.dir);
|
||||
if ((await adapter.exists(this.paths.stateDir)) === false) {
|
||||
await adapter.mkdir(this.paths.stateDir);
|
||||
}
|
||||
const fresh = randomBytes(KEY_BYTES);
|
||||
const ab = fresh.buffer.slice(fresh.byteOffset, fresh.byteOffset + fresh.byteLength) as ArrayBuffer;
|
||||
await adapter.writeBinary(SECRET_PATH, ab);
|
||||
const ab = fresh.buffer.slice(fresh.byteOffset, fresh.byteOffset + fresh.byteLength);
|
||||
await adapter.writeBinary(this.secretPath, ab);
|
||||
this.key = fresh;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ export interface DryRunReport {
|
|||
|
||||
/** Compute the sync plan without executing anything. Useful for diagnostics. */
|
||||
export async function dryRun(
|
||||
app: App,
|
||||
_app: App,
|
||||
settings: SftpSyncSettings,
|
||||
deviceStore: DeviceStore,
|
||||
index: IndexStore,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { STATE_DIR_PREFIX } from "../state/paths";
|
||||
import type { App } from "obsidian";
|
||||
import { pluginPaths } from "../state/paths";
|
||||
import type { SftpSyncSettings } from "../settings";
|
||||
|
||||
/**
|
||||
|
|
@ -35,8 +36,18 @@ export class ExcludeMatcher {
|
|||
private patterns: RegExp[];
|
||||
private excludeObsidianFolder: boolean;
|
||||
private excludeWorkspaceJson: boolean;
|
||||
private configDir: string;
|
||||
private stateDirPrefix: string;
|
||||
private workspacePaths: string[];
|
||||
|
||||
constructor(settings: SftpSyncSettings) {
|
||||
constructor(app: App, settings: SftpSyncSettings) {
|
||||
const paths = pluginPaths(app.vault.configDir);
|
||||
this.configDir = paths.configDir;
|
||||
this.stateDirPrefix = paths.stateDirPrefix;
|
||||
this.workspacePaths = [
|
||||
`${this.configDir}/workspace.json`,
|
||||
`${this.configDir}/workspace-mobile.json`,
|
||||
];
|
||||
this.patterns = settings.excludePatterns.map(globToRegex);
|
||||
this.excludeObsidianFolder = !settings.syncEverything;
|
||||
this.excludeWorkspaceJson = !settings.syncWorkspaceJson;
|
||||
|
|
@ -45,18 +56,16 @@ export class ExcludeMatcher {
|
|||
/** 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)) {
|
||||
if (path === this.stateDirPrefix.slice(0, -1) || path.startsWith(this.stateDirPrefix)) {
|
||||
return true;
|
||||
}
|
||||
// 2. .obsidian folder excluded entirely if user opted out.
|
||||
if (this.excludeObsidianFolder && (path === ".obsidian" || path.startsWith(".obsidian/"))) {
|
||||
// 2. Obsidian config folder excluded entirely if user opted out.
|
||||
if (this.excludeObsidianFolder && (path === this.configDir || path.startsWith(this.configDir + "/"))) {
|
||||
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;
|
||||
}
|
||||
if (this.workspacePaths.includes(path)) return true;
|
||||
}
|
||||
// 4. User-defined soft excludes.
|
||||
for (const re of this.patterns) {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import type { App, DataAdapter } from "obsidian";
|
||||
import { STATE_PATHS } from "../state/paths";
|
||||
import { pluginPaths, PluginPaths } from "../state/paths";
|
||||
|
||||
export interface IndexEntry {
|
||||
path: string; // vault-relative, forward slashes
|
||||
|
|
@ -19,11 +19,13 @@ 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 flushTimer: number | null = null;
|
||||
private adapter: DataAdapter;
|
||||
private paths: PluginPaths;
|
||||
|
||||
constructor(app: App) {
|
||||
this.adapter = app.vault.adapter;
|
||||
this.paths = pluginPaths(app.vault.configDir);
|
||||
}
|
||||
|
||||
get all(): IndexEntry[] {
|
||||
|
|
@ -79,11 +81,11 @@ export class IndexStore {
|
|||
|
||||
async load(): Promise<void> {
|
||||
try {
|
||||
if (!(await this.adapter.exists(STATE_PATHS.index))) {
|
||||
if (!(await this.adapter.exists(this.paths.index))) {
|
||||
this.data = structuredClone(EMPTY_INDEX);
|
||||
return;
|
||||
}
|
||||
const raw = await this.adapter.read(STATE_PATHS.index);
|
||||
const raw = await this.adapter.read(this.paths.index);
|
||||
const parsed = JSON.parse(raw) as Index;
|
||||
if (parsed.schemaVersion !== 1) throw new Error("unsupported schema");
|
||||
this.data = parsed;
|
||||
|
|
@ -96,29 +98,29 @@ export class IndexStore {
|
|||
|
||||
/** Force-write index to disk, regardless of dirty flag. Cancels any pending debounced flush. */
|
||||
async flush(): Promise<void> {
|
||||
if (this.flushTimer) {
|
||||
clearTimeout(this.flushTimer);
|
||||
if (this.flushTimer !== null) {
|
||||
activeWindow.clearTimeout(this.flushTimer);
|
||||
this.flushTimer = null;
|
||||
}
|
||||
await this.ensureStateDir();
|
||||
await this.adapter.write(STATE_PATHS.index, JSON.stringify(this.data));
|
||||
await this.adapter.write(this.paths.index, JSON.stringify(this.data));
|
||||
this.dirty = false;
|
||||
}
|
||||
|
||||
private markDirty(): void {
|
||||
this.dirty = true;
|
||||
if (this.flushTimer) return;
|
||||
this.flushTimer = setTimeout(() => {
|
||||
if (this.flushTimer !== null) return;
|
||||
this.flushTimer = activeWindow.setTimeout(() => {
|
||||
this.flushTimer = null;
|
||||
if (this.dirty) {
|
||||
void this.flush().catch((err) => console.error("Vault Bridge: index flush failed", err));
|
||||
this.flush().catch((err: unknown) => 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);
|
||||
if (!(await this.adapter.exists(this.paths.stateDir))) {
|
||||
await this.adapter.mkdir(this.paths.stateDir);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import type { App, DataAdapter } from "obsidian";
|
||||
import { STATE_PATHS } from "../state/paths";
|
||||
import { pluginPaths, PluginPaths } 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. */
|
||||
|
|
@ -19,10 +19,12 @@ const EMPTY: LastSyncedSnapshot = {
|
|||
|
||||
export class LastSyncedStore {
|
||||
private adapter: DataAdapter;
|
||||
private paths: PluginPaths;
|
||||
private data: LastSyncedSnapshot = structuredClone(EMPTY);
|
||||
|
||||
constructor(app: App) {
|
||||
this.adapter = app.vault.adapter;
|
||||
this.paths = pluginPaths(app.vault.configDir);
|
||||
}
|
||||
|
||||
get snapshot(): LastSyncedSnapshot {
|
||||
|
|
@ -31,11 +33,11 @@ export class LastSyncedStore {
|
|||
|
||||
async load(): Promise<void> {
|
||||
try {
|
||||
if (!(await this.adapter.exists(STATE_PATHS.lastSynced))) {
|
||||
if (!(await this.adapter.exists(this.paths.lastSynced))) {
|
||||
this.data = structuredClone(EMPTY);
|
||||
return;
|
||||
}
|
||||
const raw = await this.adapter.read(STATE_PATHS.lastSynced);
|
||||
const raw = await this.adapter.read(this.paths.lastSynced);
|
||||
const parsed = JSON.parse(raw) as LastSyncedSnapshot;
|
||||
if (parsed.schemaVersion !== 1) throw new Error("unsupported schema");
|
||||
this.data = parsed;
|
||||
|
|
@ -47,9 +49,9 @@ export class LastSyncedStore {
|
|||
|
||||
async save(snapshot: LastSyncedSnapshot): Promise<void> {
|
||||
this.data = snapshot;
|
||||
if (!(await this.adapter.exists(STATE_PATHS.dir))) {
|
||||
await this.adapter.mkdir(STATE_PATHS.dir);
|
||||
if (!(await this.adapter.exists(this.paths.stateDir))) {
|
||||
await this.adapter.mkdir(this.paths.stateDir);
|
||||
}
|
||||
await this.adapter.write(STATE_PATHS.lastSynced, JSON.stringify(this.data));
|
||||
await this.adapter.write(this.paths.lastSynced, JSON.stringify(this.data));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import type { ExcludeMatcher } from "./exclude";
|
|||
import type { DeviceStore } from "../state/device-store";
|
||||
import type { KnownHostsStore } from "../state/known-hosts-store";
|
||||
import type { SftpSyncSettings } from "../settings";
|
||||
import { STATE_PATHS } from "../state/paths";
|
||||
import { pluginPaths, PluginPaths } from "../state/paths";
|
||||
|
||||
export interface PullProgress {
|
||||
processed: number; // files considered so far (downloaded + skipped)
|
||||
|
|
@ -43,6 +43,8 @@ export interface PullResult {
|
|||
* Wholesale reconciliation including deletes is the job of Phase 6 bidirectional sync.
|
||||
*/
|
||||
export class PullEngine {
|
||||
private paths: PluginPaths;
|
||||
|
||||
constructor(
|
||||
private app: App,
|
||||
private settings: SftpSyncSettings,
|
||||
|
|
@ -52,7 +54,9 @@ export class PullEngine {
|
|||
private exclude: ExcludeMatcher,
|
||||
private lastSynced: LastSyncedStore,
|
||||
private knownHosts: KnownHostsStore,
|
||||
) {}
|
||||
) {
|
||||
this.paths = pluginPaths(app.vault.configDir);
|
||||
}
|
||||
|
||||
async pullAll(opts: PullOptions = {}): Promise<PullResult> {
|
||||
const t0 = Date.now();
|
||||
|
|
@ -110,7 +114,7 @@ export class PullEngine {
|
|||
|
||||
let downloaded = 0;
|
||||
let totalBytes = 0;
|
||||
await this.ensureLocalDir(adapter, STATE_PATHS.tmp);
|
||||
await this.ensureLocalDir(adapter, this.paths.tmp);
|
||||
|
||||
// Parallel pool of downloads on the same SFTP session.
|
||||
// ssh2 multiplexes RPC requests over the channel; this hides RTT
|
||||
|
|
@ -184,7 +188,7 @@ export class PullEngine {
|
|||
|
||||
// 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)}`;
|
||||
const tmp = `${this.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);
|
||||
|
|
@ -195,8 +199,10 @@ export class PullEngine {
|
|||
}
|
||||
await adapter.rename(tmp, vaultPath);
|
||||
} catch (err) {
|
||||
// Best-effort cleanup of the staging file if the rename fails.
|
||||
try { await adapter.remove(tmp); } catch {}
|
||||
// Best-effort cleanup of the staging file; never let cleanup errors mask the rename failure.
|
||||
try {
|
||||
await adapter.remove(tmp);
|
||||
} catch (_cleanupErr) { /* ignore */ }
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ import type { Scanner } from "./scanner";
|
|||
import type { ExcludeMatcher } from "./exclude";
|
||||
import type { DeviceStore } from "../state/device-store";
|
||||
import type { KnownHostsStore } from "../state/known-hosts-store";
|
||||
import { STATE_PATHS } from "../state/paths";
|
||||
import { pluginPaths, PluginPaths } from "../state/paths";
|
||||
import type { SftpSyncSettings } from "../settings";
|
||||
|
||||
export interface SyncProgress {
|
||||
|
|
@ -76,6 +76,8 @@ export interface SyncOutcome {
|
|||
* Holds the remote lock for the duration. Updates remote manifest + local snapshot atomically at the end.
|
||||
*/
|
||||
export class SyncEngine {
|
||||
private paths: PluginPaths;
|
||||
|
||||
constructor(
|
||||
private app: App,
|
||||
private settings: SftpSyncSettings,
|
||||
|
|
@ -85,7 +87,9 @@ export class SyncEngine {
|
|||
private exclude: ExcludeMatcher,
|
||||
private lastSynced: LastSyncedStore,
|
||||
private knownHosts: KnownHostsStore,
|
||||
) {}
|
||||
) {
|
||||
this.paths = pluginPaths(app.vault.configDir);
|
||||
}
|
||||
|
||||
async syncBoth(opts: SyncOptions = {}): Promise<SyncOutcome> {
|
||||
const t0 = Date.now();
|
||||
|
|
@ -146,7 +150,7 @@ export class SyncEngine {
|
|||
);
|
||||
|
||||
// Diagnostics: show what we decided to do.
|
||||
console.log("Vault Bridge plan:", {
|
||||
console.debug("Vault Bridge plan:", {
|
||||
counts: plan.counts,
|
||||
localCount: localMap.size,
|
||||
remoteCount: Object.keys(manifest.entries).length,
|
||||
|
|
@ -177,7 +181,7 @@ export class SyncEngine {
|
|||
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(
|
||||
console.debug(
|
||||
"Vault Bridge: bulk-delete threshold tripped — asking user",
|
||||
{ outgoing: outgoing.length, incoming: incoming.length, totalFiles },
|
||||
);
|
||||
|
|
@ -230,8 +234,8 @@ export class SyncEngine {
|
|||
|
||||
// Pre-create the local download staging dir once. writeBufferToVault otherwise
|
||||
// races on `mkdir(state/tmp)` when several download workers run concurrently.
|
||||
if ((await adapter.exists(STATE_PATHS.tmp)) === false) {
|
||||
await adapter.mkdir(STATE_PATHS.tmp);
|
||||
if ((await adapter.exists(this.paths.tmp)) === false) {
|
||||
await adapter.mkdir(this.paths.tmp);
|
||||
}
|
||||
|
||||
// Phase A: bookkeeping-only ops (no I/O) — execute synchronously. Order doesn't matter.
|
||||
|
|
@ -357,7 +361,7 @@ export class SyncEngine {
|
|||
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 writeBufferToVault(adapter, op.path, buf, this.paths.tmp);
|
||||
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);
|
||||
|
|
@ -412,7 +416,7 @@ export class SyncEngine {
|
|||
// 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 writeBufferToVault(adapter, copyPath, remoteBuf, this.paths.tmp);
|
||||
await this.scanner.refreshOne(copyPath);
|
||||
// Push conflict-copy to remote so other devices see it.
|
||||
await uploadBuffer(client, this.settings.remoteRoot, copyPath, remoteBuf, dirs);
|
||||
|
|
@ -427,13 +431,13 @@ export class SyncEngine {
|
|||
// 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 writeBufferToVault(adapter, copyPath, localBuf, this.paths.tmp);
|
||||
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 writeBufferToVault(adapter, op.path, remoteBuf, this.paths.tmp);
|
||||
await this.scanner.refreshOne(op.path);
|
||||
|
||||
const winnerEntry = this.index.get(op.path);
|
||||
|
|
@ -454,7 +458,7 @@ function isRemoteFileMissing(err: unknown): boolean {
|
|||
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();
|
||||
const msg = typeof e.message === "string" ? e.message.toLowerCase() : "";
|
||||
return msg.includes("no such file") || msg.includes("not found");
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
{
|
||||
"0.9.6": "1.5.0"
|
||||
"0.9.6": "1.5.0",
|
||||
"0.9.7": "1.5.0"
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue