mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-06-26 21:39:43 +02:00
feat: implement cross-device deduplication for Obsidian connectors using vault fingerprinting and enhance connector management
This commit is contained in:
parent
2d90ed0fec
commit
54ce2666f5
10 changed files with 486 additions and 92 deletions
|
|
@ -110,6 +110,7 @@ export class SurfSenseApiClient {
|
|||
searchSpaceId: number;
|
||||
vaultId: string;
|
||||
vaultName: string;
|
||||
vaultFingerprint: string;
|
||||
}): Promise<ConnectResponse> {
|
||||
return await this.request<ConnectResponse>(
|
||||
"POST",
|
||||
|
|
@ -118,6 +119,7 @@ export class SurfSenseApiClient {
|
|||
vault_id: input.vaultId,
|
||||
vault_name: input.vaultName,
|
||||
search_space_id: input.searchSpaceId,
|
||||
vault_fingerprint: input.vaultFingerprint,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import {
|
|||
type StatusState,
|
||||
type SurfsensePluginSettings,
|
||||
} from "./types";
|
||||
import { generateVaultUuid } from "./vault-identity";
|
||||
|
||||
/** SurfSense plugin entry point. */
|
||||
export default class SurfSensePlugin extends Plugin {
|
||||
|
|
@ -167,6 +168,25 @@ export default class SurfSensePlugin extends Plugin {
|
|||
this.queue?.requestStop();
|
||||
}
|
||||
|
||||
/**
|
||||
* Obsidian fires this when another device rewrites our data.json.
|
||||
* If the synced vault_id differs from ours, adopt it and
|
||||
* re-handshake so the server routes us to the right row.
|
||||
*/
|
||||
async onExternalSettingsChange(): Promise<void> {
|
||||
const previousVaultId = this.settings.vaultId;
|
||||
const previousConnectorId = this.settings.connectorId;
|
||||
await this.loadSettings();
|
||||
const changed =
|
||||
this.settings.vaultId !== previousVaultId ||
|
||||
this.settings.connectorId !== previousConnectorId;
|
||||
if (!changed) return;
|
||||
this.notifyStatusChange();
|
||||
if (this.settings.searchSpaceId !== null) {
|
||||
void this.engine.ensureConnected();
|
||||
}
|
||||
}
|
||||
|
||||
get queueDepth(): number {
|
||||
return this.queue?.size ?? 0;
|
||||
}
|
||||
|
|
@ -238,10 +258,15 @@ export default class SurfSensePlugin extends Plugin {
|
|||
await this.saveData(this.settings);
|
||||
}
|
||||
|
||||
/** Mint vault_id (in data.json, travels with the vault) on first run. */
|
||||
/**
|
||||
* Mint a tentative vault_id locally on first run. The server's
|
||||
* fingerprint dedup (see /obsidian/connect) may overwrite it on the
|
||||
* first /connect when another device of the same vault has already
|
||||
* registered; we always trust the server's response.
|
||||
*/
|
||||
private seedIdentity(): void {
|
||||
if (!this.settings.vaultId) {
|
||||
this.settings.vaultId = generateUuid();
|
||||
this.settings.vaultId = generateVaultUuid();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -252,17 +277,3 @@ interface NetworkConnection {
|
|||
addEventListener?: (event: string, handler: () => void) => void;
|
||||
removeEventListener?: (event: string, handler: () => void) => void;
|
||||
}
|
||||
|
||||
function generateUuid(): string {
|
||||
const c = globalThis.crypto;
|
||||
if (c?.randomUUID) return c.randomUUID();
|
||||
const buf = new Uint8Array(16);
|
||||
c.getRandomValues(buf);
|
||||
buf[6] = ((buf[6] ?? 0) & 0x0f) | 0x40;
|
||||
buf[8] = ((buf[8] ?? 0) & 0x3f) | 0x80;
|
||||
const hex = Array.from(buf, (b) => b.toString(16).padStart(2, "0")).join("");
|
||||
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(
|
||||
16,
|
||||
20,
|
||||
)}-${hex.slice(20)}`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import type {
|
|||
StatusKind,
|
||||
StatusState,
|
||||
} from "./types";
|
||||
import { computeVaultFingerprint } from "./vault-identity";
|
||||
|
||||
/**
|
||||
* Owner of "what does the vault look like vs the server" reasoning.
|
||||
|
|
@ -110,7 +111,14 @@ export class SyncEngine {
|
|||
this.setStatus(this.queueStatusKind(), undefined);
|
||||
}
|
||||
|
||||
/** Public entry point used after settings save to (re)connect the vault. */
|
||||
/**
|
||||
* (Re)register the vault with the server.
|
||||
*
|
||||
* Always trusts the server's response: when fingerprint dedup routes
|
||||
* us to another device's connector, ``resp.vault_id`` may differ from
|
||||
* what we sent and we adopt it locally so future /sync calls land on
|
||||
* the right row.
|
||||
*/
|
||||
async ensureConnected(): Promise<void> {
|
||||
const settings = this.deps.getSettings();
|
||||
if (!settings.searchSpaceId) {
|
||||
|
|
@ -118,13 +126,16 @@ export class SyncEngine {
|
|||
return;
|
||||
}
|
||||
try {
|
||||
const fingerprint = await computeVaultFingerprint(this.deps.app);
|
||||
const resp = await this.deps.apiClient.connect({
|
||||
searchSpaceId: settings.searchSpaceId,
|
||||
vaultId: settings.vaultId,
|
||||
vaultName: this.deps.app.vault.getName(),
|
||||
vaultFingerprint: fingerprint,
|
||||
});
|
||||
this.applyHealth(resp);
|
||||
await this.deps.saveSettings((s) => {
|
||||
s.vaultId = resp.vault_id;
|
||||
s.connectorId = resp.connector_id;
|
||||
});
|
||||
} catch (err) {
|
||||
|
|
@ -385,11 +396,19 @@ export class SyncEngine {
|
|||
if (Date.now() - settings.lastReconcileAt < RECONCILE_MIN_INTERVAL_MS) return;
|
||||
}
|
||||
|
||||
// Re-handshake first so the server sees this device's current
|
||||
// fingerprint. If the vault grew since last connect and now
|
||||
// matches another device's row, the server merges and routes us
|
||||
// to the survivor; subsequent /manifest call uses the adopted id.
|
||||
await this.ensureConnected();
|
||||
const refreshed = this.deps.getSettings();
|
||||
if (!refreshed.connectorId) return;
|
||||
|
||||
this.setStatus("syncing", "Reconciling vault with server…");
|
||||
try {
|
||||
const manifest = await this.deps.apiClient.getManifest(settings.vaultId);
|
||||
const manifest = await this.deps.apiClient.getManifest(refreshed.vaultId);
|
||||
const remote = manifest.items ?? {};
|
||||
const enqueued = this.diffAndQueue(settings, remote);
|
||||
const enqueued = this.diffAndQueue(refreshed, remote);
|
||||
await this.deps.saveSettings((s) => {
|
||||
s.lastReconcileAt = Date.now();
|
||||
s.tombstones = pruneTombstones(s.tombstones);
|
||||
|
|
|
|||
43
surfsense_obsidian/src/vault-identity.ts
Normal file
43
surfsense_obsidian/src/vault-identity.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import type { App } from "obsidian";
|
||||
|
||||
/**
|
||||
* Deterministic SHA-256 over the vault name + sorted markdown paths.
|
||||
*
|
||||
* Two devices observing the same vault content compute the same value,
|
||||
* regardless of how it was synced (iCloud, Syncthing, Obsidian Sync, …).
|
||||
* The server uses this as the cross-device dedup key on /connect.
|
||||
*/
|
||||
export async function computeVaultFingerprint(app: App): Promise<string> {
|
||||
const vaultName = app.vault.getName();
|
||||
const paths = app.vault
|
||||
.getMarkdownFiles()
|
||||
.map((f) => f.path)
|
||||
.sort();
|
||||
const payload = `${vaultName}\n${paths.join("\n")}`;
|
||||
const bytes = new TextEncoder().encode(payload);
|
||||
const digest = await crypto.subtle.digest("SHA-256", bytes);
|
||||
return bufferToHex(digest);
|
||||
}
|
||||
|
||||
function bufferToHex(buf: ArrayBuffer): string {
|
||||
const view = new Uint8Array(buf);
|
||||
let hex = "";
|
||||
for (let i = 0; i < view.length; i++) {
|
||||
hex += (view[i] ?? 0).toString(16).padStart(2, "0");
|
||||
}
|
||||
return hex;
|
||||
}
|
||||
|
||||
export function generateVaultUuid(): string {
|
||||
const c = globalThis.crypto;
|
||||
if (c?.randomUUID) return c.randomUUID();
|
||||
const buf = new Uint8Array(16);
|
||||
c.getRandomValues(buf);
|
||||
buf[6] = ((buf[6] ?? 0) & 0x0f) | 0x40;
|
||||
buf[8] = ((buf[8] ?? 0) & 0x3f) | 0x80;
|
||||
const hex = Array.from(buf, (b) => b.toString(16).padStart(2, "0")).join("");
|
||||
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(
|
||||
16,
|
||||
20,
|
||||
)}-${hex.slice(20)}`;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue