mirror of
https://github.com/fangface-hub/ObsidianSharedVault.git
synced 2026-07-22 08:33:01 +00:00
Fixed the false conflict detection on my own node.
This commit is contained in:
parent
94e3cbc521
commit
1a5e40d303
6 changed files with 221 additions and 59 deletions
32
README.md
32
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__
|
||||
|
||||

|
||||
|
||||
> [!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/
|
||||
|
|
|
|||
BIN
Screenshot.gif
Normal file
BIN
Screenshot.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 MiB |
|
|
@ -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",
|
||||
|
|
|
|||
64
package.json
64
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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<LegacyCacheCandidate[]> {
|
||||
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<LegacyCacheCandidate> {
|
||||
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<CacheState> {
|
||||
const processedOpIds = new Set<string>();
|
||||
|
||||
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<void> {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue