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:
@@ -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;
|
||||
|
||||
@@ -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([]);
|
||||
|
||||
@@ -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: 'Пусто' });
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user