diff --git a/README.md b/README.md index 866c497..3d6ae4a 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,38 @@ SharedVault is a serverless, conflict-free collaboration layer for Obsidian. It stores Yjs-compatible operation files inside the vault, keeps local CRDT cache state under .obsidian/cache/{vault-id}/{node-id}/, and applies remote changes later without a dedicated server. +```mermaid +flowchart LR + subgraph Node["Each device node"] + Editor["Markdown files in vault"] + Cache["Local CRDT cache\n.obsidian/cache/{vault-id}/{node-id}/docs"] + State["Processed state\n.obsidian/cache/{vault-id}/{node-id}/state.json"] + LocalData["Local plugin data\n.obsidian/cache/{vault-id}/local-plugin-data.json"] + end + + subgraph Shared["Shared vault storage"] + Ops["Operation cache\n.obsidian/shared-vault/operation-cache/"] + Registry["Node registry\n.obsidian/shared-vault/node-registry/"] + Snapshots["Snapshots\n.obsidian/shared-vault/snapshots/"] + end + + Editor -->|local diff| Cache + Cache -->|Yjs update files| Ops + Ops -->|poll and apply| Cache + Cache -->|write merged markdown| Editor + Cache --> State + LocalData -->|persist nodeId| Cache + Editor -->|first local edit / heartbeat| Registry + Cache -->|snapshot export| Snapshots +``` + +> __An image of editing this README.md on my own node and viewing it from another node__ + +![An image of editing this README.md on my own node and viewing it from another nod](./Screenshot.gif) + +> [!NOTE] +> The mouse cursor on the other node is positioned at the bottom‑right, showing that it is reflecting the edits made on my own node. + ## Current implementation - Shared operation log in .obsidian/shared-vault/operation-cache/ diff --git a/Screenshot.gif b/Screenshot.gif new file mode 100644 index 0000000..6819376 Binary files /dev/null and b/Screenshot.gif differ diff --git a/manifest.json b/manifest.json index a78aa03..13f0e16 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "id": "shared-vault", "name": "Shared Vault", - "version": "0.1.3", + "version": "0.2.0", "minAppVersion": "1.5.0", "description": "Serverless, conflict-free collaboration layer for shared vaults.", "author": "fangface", diff --git a/package.json b/package.json index 34a1712..46cf0e9 100644 --- a/package.json +++ b/package.json @@ -1,32 +1,32 @@ -{ - "name": "obsidian-shared-vault", - "version": "0.1.3", - "description": "Serverless conflict-free collaboration layer for Obsidian shared vaults.", - "private": true, - "type": "module", - "scripts": { - "build": "node esbuild.config.mjs production", - "dev": "node esbuild.config.mjs", - "check": "tsc --noEmit", - "lint": "eslint src/main.ts --max-warnings=0", - "lint:fix": "eslint src/main.ts --fix --max-warnings=0", - "generate:release-notes": "node scripts/generate-release-notes.mjs", - "version:major": "node scripts/bump-major-version.mjs", - "version:minor": "node scripts/bump-minor-version.mjs", - "version:patch": "node scripts/bump-patch-version.mjs" - }, - "devDependencies": { - "@typescript-eslint/parser": "^8.57.2", - "@types/diff-match-patch": "latest", - "@types/node": "latest", - "esbuild": "latest", - "eslint": "^9.39.4", - "eslint-plugin-obsidianmd": "^0.4.1", - "obsidian": "latest", - "typescript": "latest" - }, - "dependencies": { - "diff-match-patch": "latest", - "yjs": "latest" - } -} +{ + "name": "obsidian-shared-vault", + "version": "0.2.0", + "description": "Serverless conflict-free collaboration layer for Obsidian shared vaults.", + "private": true, + "type": "module", + "scripts": { + "build": "node esbuild.config.mjs production", + "dev": "node esbuild.config.mjs", + "check": "tsc --noEmit", + "lint": "eslint src/main.ts --max-warnings=0", + "lint:fix": "eslint src/main.ts --fix --max-warnings=0", + "generate:release-notes": "node scripts/generate-release-notes.mjs", + "version:major": "node scripts/bump-major-version.mjs", + "version:minor": "node scripts/bump-minor-version.mjs", + "version:patch": "node scripts/bump-patch-version.mjs" + }, + "devDependencies": { + "@typescript-eslint/parser": "^8.57.2", + "@types/diff-match-patch": "latest", + "@types/node": "latest", + "esbuild": "latest", + "eslint": "^9.39.4", + "eslint-plugin-obsidianmd": "^0.4.1", + "obsidian": "latest", + "typescript": "latest" + }, + "dependencies": { + "diff-match-patch": "latest", + "yjs": "latest" + } +} diff --git a/src/shared-vault-plugin.ts b/src/shared-vault-plugin.ts index fd3b23f..77ef5c5 100644 --- a/src/shared-vault-plugin.ts +++ b/src/shared-vault-plugin.ts @@ -10,8 +10,19 @@ import { import { SharedVaultEngine } from "./shared-vault-engine"; import { SharedVaultNodeRegistryModal } from "./shared-vault-modals"; import { SharedVaultSettingTab } from "./shared-vault-settings-tab"; -import type { NodeListEntry, NodeRegistryEntry, OperationFile, SharedVaultData, SharedVaultSettings } from "./shared-vault-types"; -import { hashText, safeMkdir } from "./shared-vault-utils"; +import type { CacheState, CachedDocument, NodeListEntry, NodeRegistryEntry, OperationFile, SharedVaultData, SharedVaultSettings } from "./shared-vault-types"; +import { base64ToText, hashText, safeMkdir } from "./shared-vault-utils"; + +interface LegacyCacheCandidate { + root: string; + statePath: string; + docRoot: string; + hasState: boolean; + hasDocs: boolean; + processedCount: number; + docCount: number; + alignedDocCount: number; +} export default class SharedVaultPlugin extends Plugin { settings: SharedVaultSettings = DEFAULT_SETTINGS; @@ -239,38 +250,156 @@ export default class SharedVaultPlugin extends Plugin { const modernStatePath = normalizePath(`${nodeCacheRoot}/state.json`); const modernDocRoot = normalizePath(`${nodeCacheRoot}/docs`); - if (await adapter.exists(modernStatePath) || await adapter.exists(modernDocRoot)) { + const listed = await adapter.list(nodeCacheRoot); + const legacyCandidates = await this.readLegacyCacheCandidates(listed.folders.sort()); + if (legacyCandidates.length === 0) { return; } - const listed = await adapter.list(nodeCacheRoot); - const legacyRoots = listed.folders.sort(); + const hasModernState = await adapter.exists(modernStatePath); + const hasModernDocs = await adapter.exists(modernDocRoot); + const modernCandidate = await this.inspectCacheCandidate(nodeCacheRoot); + const selectedLegacy = this.selectPreferredCacheCandidate(legacyCandidates); - for (const legacyRoot of legacyRoots) { - const legacyStatePath = normalizePath(`${legacyRoot}/state.json`); - const legacyDocRoot = normalizePath(`${legacyRoot}/docs`); - const hasLegacyState = await adapter.exists(legacyStatePath); - const hasLegacyDocs = await adapter.exists(legacyDocRoot); + const shouldRecoverModernCache = (hasModernState || hasModernDocs) + && modernCandidate.alignedDocCount === 0 + && selectedLegacy.alignedDocCount > 0; - if (!hasLegacyState && !hasLegacyDocs) { + if (!shouldRecoverModernCache && (hasModernState || hasModernDocs)) { + return; + } + + if (selectedLegacy.hasDocs) { + if (hasModernDocs) { + await adapter.rmdir(modernDocRoot, true); + } + + await adapter.rename(selectedLegacy.docRoot, modernDocRoot); + } + + const mergedState = await this.mergeProcessedState([ + ...(hasModernState ? [modernCandidate] : []), + ...legacyCandidates + ]); + + if (mergedState.processedOpIds.length > 0) { + await adapter.write(modernStatePath, JSON.stringify(mergedState, null, 2)); + } + + if (selectedLegacy.hasState && await adapter.exists(selectedLegacy.statePath)) { + await adapter.remove(selectedLegacy.statePath); + } + + for (const candidate of legacyCandidates) { + const rest = await adapter.list(candidate.root); + if (rest.files.length === 0 && rest.folders.length === 0) { + await adapter.rmdir(candidate.root, false); + } + } + } + + private async readLegacyCacheCandidates(roots: string[]): Promise { + const candidates: LegacyCacheCandidate[] = []; + + for (const root of roots) { + const candidate = await this.inspectCacheCandidate(root); + if (!candidate.hasState && !candidate.hasDocs) { continue; } - if (hasLegacyState && !(await adapter.exists(modernStatePath))) { - await adapter.rename(legacyStatePath, modernStatePath); - } - - if (hasLegacyDocs && !(await adapter.exists(modernDocRoot))) { - await adapter.rename(legacyDocRoot, modernDocRoot); - } - - const rest = await adapter.list(legacyRoot); - if (rest.files.length === 0 && rest.folders.length === 0) { - await adapter.rmdir(legacyRoot, false); - } - - return; + candidates.push(candidate); } + + return candidates; + } + + private async inspectCacheCandidate(root: string): Promise { + const adapter = this.app.vault.adapter; + const statePath = normalizePath(`${root}/state.json`); + const docRoot = normalizePath(`${root}/docs`); + const hasState = await adapter.exists(statePath); + const hasDocs = await adapter.exists(docRoot); + + let processedCount = 0; + if (hasState) { + const raw = await adapter.read(statePath); + const state = JSON.parse(raw) as CacheState; + processedCount = state.processedOpIds.length; + } + + let docCount = 0; + let alignedDocCount = 0; + if (hasDocs) { + const listed = await adapter.list(docRoot); + const docPaths = listed.files.filter((path) => path.endsWith(".json")).sort(); + docCount = docPaths.length; + + for (const path of docPaths) { + const raw = await adapter.read(path); + const document = JSON.parse(raw) as CachedDocument; + if (!document.vaultTextBase64) { + continue; + } + + const file = this.app.vault.getAbstractFileByPath(document.path); + if (!(file instanceof TFile)) { + continue; + } + + if (base64ToText(document.vaultTextBase64) === await this.app.vault.read(file)) { + alignedDocCount += 1; + } + } + } + + return { + root, + statePath, + docRoot, + hasState, + hasDocs, + processedCount, + docCount, + alignedDocCount + }; + } + + private selectPreferredCacheCandidate(candidates: LegacyCacheCandidate[]): LegacyCacheCandidate { + return candidates.slice().sort((left, right) => { + if (right.alignedDocCount !== left.alignedDocCount) { + return right.alignedDocCount - left.alignedDocCount; + } + + if (right.processedCount !== left.processedCount) { + return right.processedCount - left.processedCount; + } + + if (right.docCount !== left.docCount) { + return right.docCount - left.docCount; + } + + return left.root.localeCompare(right.root); + })[0]; + } + + private async mergeProcessedState(candidates: LegacyCacheCandidate[]): Promise { + const processedOpIds = new Set(); + + for (const candidate of candidates) { + if (!candidate.hasState || !(await this.app.vault.adapter.exists(candidate.statePath))) { + continue; + } + + const raw = await this.app.vault.adapter.read(candidate.statePath); + const state = JSON.parse(raw) as CacheState; + for (const operationId of state.processedOpIds) { + processedOpIds.add(operationId); + } + } + + return { + processedOpIds: Array.from(processedOpIds).sort().slice(-5000) + }; } private async prepareNodeParticipation(): Promise { diff --git a/versions.json b/versions.json index 7980820..50583c9 100644 --- a/versions.json +++ b/versions.json @@ -2,5 +2,6 @@ "0.1.0": "1.5.0", "0.1.1": "1.5.0", "0.1.2": "1.5.0", - "0.1.3": "1.5.0" + "0.1.3": "1.5.0", + "0.2.0": "1.5.0" }