From 3f8ef16a3a80bd030366781867539592a1306ace Mon Sep 17 00:00:00 2001 From: claude code agent 227 Date: Wed, 24 Jun 2026 04:38:07 +0300 Subject: [PATCH] =?UTF-8?q?feat(git-sync):=20phase=202a=20=E2=80=94=20fold?= =?UTF-8?q?er-note=20layout=20(parent=20->=20Folder/Folder.md)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Native-Obsidian structure: a page WITH children now lives at its folder-note /.md (LostPaul Folder Notes convention) with its children alongside; a leaf stays .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 --- packages/git-sync/src/engine/layout.ts | 29 ++++++- .../test/compute-pull-actions.test.ts | 6 +- packages/git-sync/test/layout.test.ts | 78 +++++++++++++++++++ 3 files changed, 109 insertions(+), 4 deletions(-) diff --git a/packages/git-sync/src/engine/layout.ts b/packages/git-sync/src/engine/layout.ts index 7b665821..3f3bd9cc 100644 --- a/packages/git-sync/src/engine/layout.ts +++ b/packages/git-sync/src/engine/layout.ts @@ -114,17 +114,42 @@ export function buildVaultLayout(pages: PageNode[]): Map { }); } + // FOLDER-NOTE transform (native-Obsidian layout): a page WITH CHILDREN lives at + // `<…>//.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 `<…>/.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 `/.md` before a same-named CHILD — the child (a leaf) + // is the one that disambiguates, never the folder-note. const usedPaths = new Set(); const seenIds = new Set(); 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; diff --git a/packages/git-sync/test/compute-pull-actions.test.ts b/packages/git-sync/test/compute-pull-actions.test.ts index df26d94b..f96b2494 100644 --- a/packages/git-sync/test/compute-pull-actions.test.ts +++ b/packages/git-sync/test/compute-pull-actions.test.ts @@ -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([]); diff --git a/packages/git-sync/test/layout.test.ts b/packages/git-sync/test/layout.test.ts index ae7c4aff..80442884 100644 --- a/packages/git-sync/test/layout.test.ts +++ b/packages/git-sync/test/layout.test.ts @@ -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 (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 / (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 //', () => { + 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: 'Пусто' }); + }); });