feat(git-sync): native-Obsidian format — phase 1 = page-file (frontmatter gitmost_id)
Pivot the thin-meta design to "the vault IS a native Obsidian vault": clean markdown + a minimal YAML frontmatter `gitmost_id:` (the durable pageId, travels with the file so identity survives any move); folders mirror the page tree with the parent's body as a folder-note `<Folder>/<Folder>.md` (LostPaul Folder Notes convention); links as `[[wikilinks]]` (basename-resolved → reparent never breaks a link, only retitle does); collisions disambiguated Obsidian-style; `.obsidian/` and non-page files left untouched (no .gitignore). Verified the conventions against the Obsidian/Folder-Notes docs. Replaces the abandoned `.gitmost/index.json` sidecar (path-keyed → fragile to git-undetected renames; the in-file id is self-sufficient): removes vault-index.ts. Adds lib/page-file.ts — parsePageFile/serializePageFile (frontmatter id + clean body) with a LEGACY `docmost:meta` fallback for migration. 6 unit tests; engine suite green. Not yet wired into pull/push — no behavior change. Design doc rewritten to the native-Obsidian format. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
43
packages/git-sync/test/page-file.test.ts
Normal file
43
packages/git-sync/test/page-file.test.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { parsePageFile, serializePageFile } from "../src/lib/page-file";
|
||||
import { serializeDocmostMarkdownBody } from "../src/lib/index";
|
||||
|
||||
describe("page-file thin format", () => {
|
||||
it("round-trips id frontmatter + clean body", () => {
|
||||
const text = serializePageFile("019ef6fc-2638", "# Hello\n\nbody text");
|
||||
expect(text.startsWith("---\ngitmost_id: 019ef6fc-2638\n---\n")).toBe(true);
|
||||
const { id, body } = parsePageFile(text);
|
||||
expect(id).toBe("019ef6fc-2638");
|
||||
expect(body).toBe("# Hello\n\nbody text");
|
||||
});
|
||||
|
||||
it("serialization is deterministic (byte-identical for the same input)", () => {
|
||||
expect(serializePageFile("p", "x")).toBe(serializePageFile("p", "x"));
|
||||
});
|
||||
|
||||
it("reads id from frontmatter with quotes / extra fields", () => {
|
||||
expect(parsePageFile('---\ngitmost_id: "abc"\ntitle: ignored\n---\nbody').id).toBe("abc");
|
||||
expect(parsePageFile("---\ngitmost_id: 'xyz'\n---\nbody").id).toBe("xyz");
|
||||
});
|
||||
|
||||
it("MIGRATION: falls back to a legacy docmost:meta block for the id", () => {
|
||||
const legacy = serializeDocmostMarkdownBody(
|
||||
{ version: 1, pageId: "legacy-1", title: "T", spaceId: "sp" },
|
||||
"old body",
|
||||
);
|
||||
const { id, body } = parsePageFile(legacy);
|
||||
expect(id).toBe("legacy-1");
|
||||
expect(body).toContain("old body");
|
||||
});
|
||||
|
||||
it("ADOPT: a plain hand-written file has no id and keeps its whole body", () => {
|
||||
const { id, body } = parsePageFile("# Just a note\n\nwritten in Obsidian");
|
||||
expect(id).toBeNull();
|
||||
expect(body).toBe("# Just a note\n\nwritten in Obsidian");
|
||||
});
|
||||
|
||||
it("tolerates empty / whitespace input", () => {
|
||||
expect(parsePageFile("").id).toBeNull();
|
||||
expect(parsePageFile(" \n ").body).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -1,78 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
emptyVaultIndex,
|
||||
parseVaultIndex,
|
||||
serializeVaultIndex,
|
||||
pageIdAt,
|
||||
slugIdAt,
|
||||
pathForPageId,
|
||||
trackedPageIds,
|
||||
setEntry,
|
||||
removeAt,
|
||||
moveEntry,
|
||||
} from "../src/engine/vault-index";
|
||||
|
||||
describe("vault-index parse/serialize", () => {
|
||||
it("round-trips a populated index", () => {
|
||||
const idx = emptyVaultIndex("sp1");
|
||||
setEntry(idx, "Проект/index.md", { pageId: "p1", slugId: "Ab12" });
|
||||
setEntry(idx, "Заметка.md", { pageId: "p2" });
|
||||
const text = serializeVaultIndex(idx);
|
||||
const back = parseVaultIndex(text);
|
||||
expect(back.spaceId).toBe("sp1");
|
||||
expect(pageIdAt(back, "Проект/index.md")).toBe("p1");
|
||||
expect(slugIdAt(back, "Проект/index.md")).toBe("Ab12");
|
||||
expect(pageIdAt(back, "Заметка.md")).toBe("p2");
|
||||
expect(slugIdAt(back, "Заметка.md")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("serializes deterministically (sorted keys -> stable diffs)", () => {
|
||||
const a = emptyVaultIndex("s");
|
||||
setEntry(a, "b.md", { pageId: "2" });
|
||||
setEntry(a, "a.md", { pageId: "1" });
|
||||
const b = emptyVaultIndex("s");
|
||||
setEntry(b, "a.md", { pageId: "1" });
|
||||
setEntry(b, "b.md", { pageId: "2" });
|
||||
// insertion order differs; serialized output must be identical.
|
||||
expect(serializeVaultIndex(a)).toBe(serializeVaultIndex(b));
|
||||
// keys are sorted in the output
|
||||
expect(serializeVaultIndex(a).indexOf('"a.md"')).toBeLessThan(
|
||||
serializeVaultIndex(a).indexOf('"b.md"'),
|
||||
);
|
||||
});
|
||||
|
||||
it("is tolerant: null / garbage / bad entries -> empty or skipped", () => {
|
||||
expect(parseVaultIndex(null).pages.size).toBe(0);
|
||||
expect(parseVaultIndex("").pages.size).toBe(0);
|
||||
expect(parseVaultIndex("not json{").pages.size).toBe(0);
|
||||
expect(parseVaultIndex("[1,2,3]").pages.size).toBe(0);
|
||||
// a page entry missing pageId is skipped, valid ones kept
|
||||
const idx = parseVaultIndex(
|
||||
JSON.stringify({ version: 1, pages: { "ok.md": { pageId: "p" }, "bad.md": { slugId: "x" } } }),
|
||||
);
|
||||
expect(idx.pages.size).toBe(1);
|
||||
expect(pageIdAt(idx, "ok.md")).toBe("p");
|
||||
});
|
||||
});
|
||||
|
||||
describe("vault-index lookups + mutations", () => {
|
||||
it("reverse lookup + tracked set", () => {
|
||||
const idx = emptyVaultIndex();
|
||||
setEntry(idx, "x.md", { pageId: "px" });
|
||||
setEntry(idx, "y/index.md", { pageId: "py" });
|
||||
expect(pathForPageId(idx, "py")).toBe("y/index.md");
|
||||
expect(pathForPageId(idx, "missing")).toBeUndefined();
|
||||
expect([...trackedPageIds(idx)].sort()).toEqual(["px", "py"]);
|
||||
});
|
||||
|
||||
it("moveEntry relocates identity; removeAt drops it", () => {
|
||||
const idx = emptyVaultIndex();
|
||||
setEntry(idx, "Old.md", { pageId: "p", slugId: "s" });
|
||||
moveEntry(idx, "Old.md", "New/index.md");
|
||||
expect(pageIdAt(idx, "Old.md")).toBeUndefined();
|
||||
expect(pageIdAt(idx, "New/index.md")).toBe("p");
|
||||
expect(slugIdAt(idx, "New/index.md")).toBe("s"); // identity preserved
|
||||
removeAt(idx, "New/index.md");
|
||||
expect(pageIdAt(idx, "New/index.md")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user