feat(git-sync): phase 2a — folder-note layout (parent -> Folder/Folder.md)

Native-Obsidian structure: a page WITH children now lives at its folder-note
<name>/<name>.md (LostPaul Folder Notes convention) with its children alongside;
a leaf stays <name>.md. Folder-notes claim their canonical path before a
same-named child, so the child (a leaf) is the one disambiguated, never the
folder-note — a folder X/ always contains its own note X.

Format-agnostic and safe in isolation: only the destination PATH changes, the
file content/serialization is untouched, so an existing parent relocates via the
move-by-id path (no delete). The frontmatter format flip (pull+push) is next.

6 new layout unit tests (leaf / parent / nested / child-named-as-parent /
twin-parents / childless). 611 engine tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
claude code agent 227
2026-06-24 04:38:07 +03:00
parent fd372e7d2d
commit af0cc73f4a
3 changed files with 109 additions and 4 deletions

View File

@@ -114,17 +114,42 @@ export function buildVaultLayout(pages: PageNode[]): Map<string, VaultEntry> {
});
}
// FOLDER-NOTE transform (native-Obsidian layout): a page WITH CHILDREN lives at
// `<…>/<stem>/<stem>.md` — its body is the folder-note INSIDE its own folder
// (LostPaul Folder Notes convention), and its children sit alongside it in that
// folder. A leaf stays `<…>/<stem>.md`. Children's segments already point into
// the parent's folder (folderSegmentsFor walks ancestor NAMES), so only the
// parent's own file relocates here; the sibling name pass above already made
// the parent name unique, so folder == file name stays consistent.
for (const p of pages) {
if (!p || !p.id) continue;
const entry = layout.get(p.id);
if (entry && p.hasChildren) {
entry.segments = [...entry.segments, entry.stem];
}
}
// Final full-path uniqueness pass — a belt-and-suspenders safety net. Note
// that cross-bucket (orphan/root) collisions are now resolved in the name pass
// above (orphans share the "__root__" bucket), so ancestor names are final
// before `segments` are built and this pass should rarely/never re-stem an
// ancestor. It only re-stems the colliding LATER leaf via the sanitized
// slugId/id, then (if still colliding) appends the id.
//
// Process FOLDER-NOTES (pages with children) FIRST so a parent claims its
// canonical `<name>/<name>.md` before a same-named CHILD — the child (a leaf)
// is the one that disambiguates, never the folder-note.
const usedPaths = new Set<string>();
const seenIds = new Set<string>();
const pathKey = (e: VaultEntry): string => [...e.segments, e.stem].join("/");
for (const p of pages) {
if (!p || !p.id || seenIds.has(p.id)) continue;
const ordered = pages
.filter((p): p is PageNode => Boolean(p && p.id))
.sort(
(a, b) =>
Number(Boolean(b.hasChildren)) - Number(Boolean(a.hasChildren)),
);
for (const p of ordered) {
if (seenIds.has(p.id)) continue;
seenIds.add(p.id);
const entry = layout.get(p.id);
if (!entry) continue;

View File

@@ -28,9 +28,11 @@ describe('computePullActions — normal complete fetch', () => {
treeComplete: true,
existing: [],
});
// Each live page is (re)written at its deterministic layout path.
// Each live page is (re)written at its deterministic layout path. `root`
// has a child, so it lives at the folder-note `Root/Root.md` (native-Obsidian
// layout), with the child alongside it in that folder.
expect(actions.toWrite).toEqual([
{ pageId: 'root', relPath: 'Root.md' },
{ pageId: 'root', relPath: 'Root/Root.md' },
{ pageId: 'child', relPath: 'Root/Child.md' },
]);
expect(actions.moved).toEqual([]);

View File

@@ -141,4 +141,82 @@ describe('buildVaultLayout', () => {
const paths = [p1, p2, c1, c2].map(pathOf);
expect(new Set(paths).size).toBe(paths.length);
});
// --- native-Obsidian folder-note layout -------------------------------------
const pathOf = (e: { segments: string[]; stem: string }) =>
[...e.segments, e.stem].join('/');
it('puts a LEAF (no children) at <name> (segments=[])', () => {
const pages: PageNode[] = [{ id: 'p1', title: 'Заметка' }];
const layout = buildVaultLayout(pages);
expect(layout.get('p1')).toEqual({ segments: [], stem: 'Заметка' });
});
it('puts a PARENT (with children) at <name>/<name> (folder-note)', () => {
const pages: PageNode[] = [
{ id: 'par', title: 'Проект', hasChildren: true },
{ id: 'ch', title: 'Задача', parentPageId: 'par' },
];
const layout = buildVaultLayout(pages);
// Folder-note: the parent's OWN file lives inside its own folder.
expect(layout.get('par')).toEqual({ segments: ['Проект'], stem: 'Проект' });
// The child sits ALONGSIDE the folder-note in the same folder.
expect(layout.get('ch')).toEqual({ segments: ['Проект'], stem: 'Задача' });
expect(pathOf(layout.get('par')!)).toBe('Проект/Проект');
expect(pathOf(layout.get('ch')!)).toBe('Проект/Задача');
});
it('nests a PARENT-of-parents as <a>/<b>/<b>', () => {
const pages: PageNode[] = [
{ id: 'a', title: 'Проект', hasChildren: true },
{ id: 'b', title: 'Подпроект', parentPageId: 'a', hasChildren: true },
{ id: 'c', title: 'Лист', parentPageId: 'b' },
];
const layout = buildVaultLayout(pages);
expect(pathOf(layout.get('a')!)).toBe('Проект/Проект');
expect(pathOf(layout.get('b')!)).toBe('Проект/Подпроект/Подпроект');
expect(pathOf(layout.get('c')!)).toBe('Проект/Подпроект/Лист');
});
it('disambiguates a CHILD named like its parent folder (folder-note wins)', () => {
// A child whose title equals the parent's title would collide with the
// parent's folder-note `Проект/Проект`. The folder-note must keep the
// canonical path; the CHILD (a leaf) is the one that gets a suffix.
const pages: PageNode[] = [
{ id: 'par', title: 'Проект', slugId: 'parSlug', hasChildren: true },
{ id: 'ch', title: 'Проект', slugId: 'chSlug', parentPageId: 'par' },
];
const layout = buildVaultLayout(pages);
const par = layout.get('par')!;
const ch = layout.get('ch')!;
// Folder-note keeps `Проект/Проект`.
expect(pathOf(par)).toBe('Проект/Проект');
// Child is forced off that path (suffix applied), still in the folder.
expect(ch.segments).toEqual(['Проект']);
expect(pathOf(ch)).not.toBe('Проект/Проект');
expect(ch.stem.startsWith('Проект ')).toBe(true);
expect(pathOf(par)).not.toBe(pathOf(ch));
});
it('keeps two same-named PARENTS distinct (folder == file name each)', () => {
const pages: PageNode[] = [
{ id: 'a', title: 'Dup', slugId: 'sa', hasChildren: true },
{ id: 'b', title: 'Dup', slugId: 'sb', hasChildren: true },
];
const layout = buildVaultLayout(pages);
const a = layout.get('a')!;
const b = layout.get('b')!;
// Each parent's folder segment EQUALS its file stem (folder-note invariant):
// a folder `X/` must always contain its own note `X`.
expect(a.segments).toEqual([a.stem]);
expect(b.segments).toEqual([b.stem]);
expect(pathOf(a)).not.toBe(pathOf(b));
});
it('does not move a childless page into a folder', () => {
const pages: PageNode[] = [{ id: 'p', title: 'Пусто', hasChildren: false }];
const layout = buildVaultLayout(pages);
expect(layout.get('p')).toEqual({ segments: [], stem: 'Пусто' });
});
});