From 7dd2af1ec549f0bd1c5c8074bcc36183b6074d35 Mon Sep 17 00:00:00 2001 From: Octoturge Date: Sun, 23 Aug 2026 01:15:51 +0200 Subject: [PATCH] Rebuild split-rulebook journal parsing (vault sync 2026-08-18 to 08-23) The bab5a62 vault commit split 9 rulebook monoliths into hub/ToC pages plus per-topic-file subfolders (rules/NN. //.md). The existing chapter-slicing parser (parseJournalBook) relied on finding a duplicate TOC-vs-body heading pair per chapter; after the split each hub heading only occurs once (in the ToC), so it was silently treating the ToC's link-list body as if it were real chapter content. Added parseSplitBookFolder (journal-parser.ts): walks a book's split subfolder directly, one JournalDoc per Rozdzial/Aneks subfolder-or-file, one JournalPage per topic file (or a single page for a standalone chapter file). The hub's own Wstep intro paragraph is unaffected by the split (it's still real prose, not a link) and continues to come from the existing prefaceTitle preface-extraction logic. Wired into build-journal-packs.ts for the 6 affected packs: core-rules-lore, bestiary-lore, magic-book-lore, abyss, blood-magic-history, economy. crimson-cult's rules source was never split into a subfolder, so it's left on the old code path unchanged. Also fixed a pre-existing derivePageTitle bug (page titles kept a leading markdown heading marker, e.g. "## Wstep" instead of "Wstep") to match the already-correct behavior in topical-parser.ts. Co-Authored-By: Claude Sonnet 5 --- scripts/build-journal-packs.ts | 67 +++++++++++--- scripts/parsers/journal-parser.ts | 149 +++++++++++++++++++++++++++++- 2 files changed, 201 insertions(+), 15 deletions(-) diff --git a/scripts/build-journal-packs.ts b/scripts/build-journal-packs.ts index 8db1933..bef4bda 100644 --- a/scripts/build-journal-packs.ts +++ b/scripts/build-journal-packs.ts @@ -15,7 +15,7 @@ import { mkdirSync, rmSync, writeFileSync, existsSync, readdirSync, statSync, re import { dirname, resolve, basename, extname } from 'node:path'; import { fileURLToPath } from 'node:url'; import { compilePack } from '@foundryvtt/foundryvtt-cli'; -import { parseJournalBook, type JournalDoc, type JournalPage } from './parsers/journal-parser'; +import { parseJournalBook, parseSplitBookFolder, type JournalDoc, type JournalPage } from './parsers/journal-parser'; import { parseTopicalFolder } from './parsers/topical-parser'; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -37,6 +37,14 @@ interface SourceFile { * as a synthetic journal entry with this title (e.g. "Wstęp"). */ prefaceTitle?: string; + /** + * Since `bab5a62`, `path`'s chapter bodies were replaced with links into + * this per-topic subfolder (relative to repoRoot). When set, chapters are + * read from that folder instead of sliced out of `path` - `path` is then + * used only to extract `prefaceTitle`'s intro paragraph, which is still + * real prose in the hub file. + */ + splitFolder?: string; } interface PackConfig { @@ -57,13 +65,17 @@ const PACK_CONFIGS: PackConfig[] = [ { pack: 'core-rules-lore', sourceBook: 'core-rules', - sources: [{ path: 'ObsidianNotes/rules/00. Podręcznik Gry.md' }], + sources: [ + { path: 'ObsidianNotes/rules/00. Podręcznik Gry.md', prefaceTitle: 'Wstęp', splitFolder: 'ObsidianNotes/rules/00. Podręcznik Gry' }, + ], skip: /^(Talenty|Zaklęcia|Atrybuty|Umiejętności|Wyposażenie)/i, }, { pack: 'bestiary-lore', sourceBook: 'bestiary', - sources: [{ path: 'ObsidianNotes/rules/06. Bestiariusz.md' }], + sources: [ + { path: 'ObsidianNotes/rules/06. Bestiariusz.md', prefaceTitle: 'Wstęp', splitFolder: 'ObsidianNotes/rules/06. Bestiariusz' }, + ], skip: /^Talenty/i, }, @@ -73,9 +85,9 @@ const PACK_CONFIGS: PackConfig[] = [ sourceBook: 'magic-book', sources: [ // Lore intro lives inside the rules file (no dedicated lore/ file). - { path: 'ObsidianNotes/rules/01. Księga Magii.md', prefaceTitle: 'Wstęp' }, + { path: 'ObsidianNotes/rules/01. Księga Magii.md', prefaceTitle: 'Wstęp', splitFolder: 'ObsidianNotes/rules/01. Księga Magii' }, ], - skip: /^(Zaklęcia|Lista zaklęć|Rozdział V)/i, + skip: /^(Zaklęcia|Lista zaklęć)/i, }, { pack: 'abyss', @@ -84,17 +96,17 @@ const PACK_CONFIGS: PackConfig[] = [ // Pure lore narrative. { path: 'ObsidianNotes/lore/02. Otchłań i Magia.md' }, // Rules intro (Wstęp only - mechanics chapters are skipped). - { path: 'ObsidianNotes/rules/02. Klątwa Otchłani.md', prefaceTitle: 'Wstęp' }, + { path: 'ObsidianNotes/rules/02. Klątwa Otchłani.md', prefaceTitle: 'Wstęp', splitFolder: 'ObsidianNotes/rules/02. Klątwa Otchłani' }, ], - skip: /^(Rozdział II - Talenty|Rozdział III - Dary|Rozdział IV - Choroby|Rozdział V - Zaklęcia|Rozdział X - Artefakty)/i, + skip: /^(Talenty|Dary|Choroby|Zaklęcia|Artefakty)/i, }, { pack: 'blood-magic-history', sourceBook: 'arcanum-sanguinis', sources: [ - { path: 'ObsidianNotes/rules/04. Arcanum Sanguinis.md', prefaceTitle: 'Wstęp' }, + { path: 'ObsidianNotes/rules/04. Arcanum Sanguinis.md', prefaceTitle: 'Wstęp', splitFolder: 'ObsidianNotes/rules/04. Arcanum Sanguinis' }, ], - skip: /^Rozdział IV - Talenty/i, + skip: /^Talenty/i, }, { pack: 'crimson-cult', @@ -102,7 +114,9 @@ const PACK_CONFIGS: PackConfig[] = [ sources: [ // Lore narrative (former Chwała Szkarłatnemu Kultowi lore section). { path: 'ObsidianNotes/lore/05. Szkarłatny Kult.md' }, - // Rules intro only (spells/artifacts skipped). + // Rules intro only (spells/artifacts skipped). This book was NOT split + // into a per-topic subfolder (bab5a62) - it stayed a monolith, so the + // old heading-slicing approach still applies unmodified. { path: 'ObsidianNotes/rules/05. Vivat Patriarcha coccineus!.md', prefaceTitle: 'Wstęp' }, ], skip: /^(Rozdział I - Zaklęcia|Rozdział II - Artefakty)/i, @@ -112,9 +126,9 @@ const PACK_CONFIGS: PackConfig[] = [ sourceBook: 'gold-steel-magic', sources: [ { path: 'ObsidianNotes/lore/03. Ekonomia Magicznego Świata.md' }, - { path: 'ObsidianNotes/rules/03. Złoto, Stal i Magia.md', prefaceTitle: 'Wstęp' }, + { path: 'ObsidianNotes/rules/03. Złoto, Stal i Magia.md', prefaceTitle: 'Wstęp', splitFolder: 'ObsidianNotes/rules/03. Złoto, Stal i Magia' }, ], - skip: /^(Aneks A - Nowe Talenty|Aneks B - Przedmioty)/i, + skip: /^(Nowe Talenty|Przedmioty)/i, }, // ── New: Humanity Guide ── @@ -166,6 +180,35 @@ for (const cfg of PACK_CONFIGS) { console.warn(` · skip source ${src.path}: file not found`); continue; } + if (src.splitFolder) { + const folderPath = resolve(repoRoot, src.splitFolder); + if (!existsSync(folderPath)) { + console.warn(` · skip splitFolder ${src.splitFolder}: folder not found`); + } else { + if (src.prefaceTitle) { + // Preface only - skip every real chapter (they come from the folder walk below). + packDocs.push( + ...parseJournalBook({ + bookPath, + pack: cfg.pack, + sourceBook: cfg.sourceBook, + prefaceTitle: src.prefaceTitle, + chapterSkip: /./, + }), + ); + } + packDocs.push( + ...parseSplitBookFolder({ + folderPath, + pack: cfg.pack, + sourceBook: cfg.sourceBook, + chapterSkip: cfg.skip, + }), + ); + } + continue; + } + const docs = parseJournalBook({ bookPath, pack: cfg.pack, diff --git a/scripts/parsers/journal-parser.ts b/scripts/parsers/journal-parser.ts index 847c0bc..a9af806 100644 --- a/scripts/parsers/journal-parser.ts +++ b/scripts/parsers/journal-parser.ts @@ -22,7 +22,8 @@ * Output: `JournalDoc[]` consumed by `build-journal-packs.ts`. */ -import { readFileSync } from 'node:fs'; +import { readFileSync, readdirSync, statSync } from 'node:fs'; +import { resolve, basename, extname } from 'node:path'; export interface JournalDoc { /** Stable kebab-case slug. */ @@ -199,7 +200,7 @@ function trimEdges(arr: string[]): string[] { /** First non-empty line if it looks short and title-like, else "Część N". */ function derivePageTitle(lines: string[], pageNo: number): string { - const first = lines.find((l) => l.trim().length > 0)?.trim() ?? ''; + const first = (lines.find((l) => l.trim().length > 0)?.trim() ?? '').replace(/^#{1,6}\s+/, ''); if (first.length > 0 && first.length <= 80 && !/[.!?]$/.test(first)) { return first; } @@ -292,9 +293,151 @@ function slugify(s: string): string { return s .toLowerCase() .normalize('NFKD') - .replace(/[\u0300-\u036f]/g, '') + .replace(/[̀-ͯ]/g, '') .replace(/ł/g, 'l') .replace(/[^a-z0-9]+/g, '-') .replace(/^-|-$/g, '') .slice(0, 60); } + +// --------------------------------------------------------------------------- +// Split-book folder parser +// --------------------------------------------------------------------------- +// +// The `bab5a62` vault split turned each `rules/NN. .md` monolith into a +// hub/ToC page plus a `rules/NN. //.md` folder tree. +// `parseJournalBook`'s heading-slicing approach can no longer find real +// chapter content in the hub (its former body text is now just links), so +// split books read their chapters directly from that folder tree instead: +// one `JournalDoc` per immediate child (a `Rozdział`/`Aneks` subfolder or a +// standalone chapter file), one `JournalPage` per topic file inside it (or a +// single page for a standalone chapter file). The hub's own `Wstęp` intro +// (still real prose, not a link) is unaffected and continues to come from +// `parseJournalBook`'s existing `prefaceTitle` handling. + +export interface SplitBookFolderConfig { + /** Absolute path to the book's split subfolder (e.g. `.../00. Podręcznik Gry`). */ + folderPath: string; + pack: string; + sourceBook: string; + /** Skip chapters whose folder/file name OR extracted title matches this regex. */ + chapterSkip?: RegExp; + /** Only include chapters whose folder/file name OR extracted title matches this regex. */ + chapterFilter?: RegExp; +} + +export function parseSplitBookFolder(cfg: SplitBookFolderConfig): JournalDoc[] { + let entries: string[]; + try { + entries = readdirSync(cfg.folderPath); + } catch { + return []; + } + + const chapters = entries + .filter((e) => !e.startsWith('.')) + .map((e) => ({ entry: e, full: resolve(cfg.folderPath, e) })) + .filter(({ full }) => { + let s; + try { + s = statSync(full); + } catch { + return false; + } + return s.isDirectory() || extname(full).toLowerCase() === '.md'; + }); + + chapters.sort((a, b) => { + const ka = chapterSortKey(chapterBaseName(a.entry)); + const kb = chapterSortKey(chapterBaseName(b.entry)); + return ka[0] - kb[0] || ka[1] - kb[1] || ka[2].localeCompare(kb[2]); + }); + + const docs: JournalDoc[] = []; + for (const { entry, full } of chapters) { + const name = chapterBaseName(entry); + const title = chapterTitle(name); + if (cfg.chapterSkip && (cfg.chapterSkip.test(name) || cfg.chapterSkip.test(title))) continue; + if (cfg.chapterFilter && !(cfg.chapterFilter.test(name) || cfg.chapterFilter.test(title))) continue; + + const isDir = statSync(full).isDirectory(); + const pages = isDir ? topicFilesToPages(full) : singleFileToPages(full); + if (pages.length === 0) continue; + + docs.push({ + id: `${cfg.pack}-${slugify(name)}`, + name, + pack: cfg.pack, + source: { book: cfg.sourceBook, chapter: title, line: 0 }, + pages, + }); + } + return docs; +} + +function chapterBaseName(entry: string): string { + return extname(entry).toLowerCase() === '.md' ? basename(entry, '.md') : entry; +} + +/** + * Strips a `Rozdział - ` or `Aneks - ` prefix, e.g. + * "Rozdział II - Talenty" -> "Talenty", "Aneks B - Przedmioty" -> "Przedmioty". + */ +function chapterTitle(name: string): string { + const m = name.match(/^(?:Rozdzia[łl]\s+[IVXLC]+|Aneks\s+[A-Za-z])\s*[-–:.]\s*(.+)$/i); + return m ? m[1].trim() : name; +} + +function romanToInt(r: string): number { + const vals: Record = { I: 1, V: 5, X: 10, L: 50, C: 100 }; + const up = r.toUpperCase(); + let total = 0; + for (let i = 0; i < up.length; i++) { + const cur = vals[up[i]] ?? 0; + const next = vals[up[i + 1]] ?? 0; + total += cur < next ? -cur : cur; + } + return total; +} + +/** Rozdział chapters first (by numeral), then Aneks appendices (by letter), then anything else. */ +function chapterSortKey(name: string): [number, number, string] { + const rz = name.match(/^Rozdzia[łl]\s+([IVXLC]+)/i); + if (rz) return [0, romanToInt(rz[1]), name]; + const an = name.match(/^Aneks\s+([A-Za-z])/i); + if (an) return [1, an[1].toUpperCase().charCodeAt(0), name]; + return [2, 0, name]; +} + +function fileBody(filePath: string): string[] { + const raw = readFileSync(filePath, 'utf8').split(/\r?\n/); + return raw.slice(skipFrontmatter(raw)); +} + +function cleanBody(lines: string[]): string[] { + return trimEdges(lines.filter((l) => !SEPARATOR.test(l.trim()) && !OBSIDIAN_TOC_BULLET.test(l.trim()))); +} + +/** One topic file per page, sorted by filename. */ +function topicFilesToPages(dirPath: string): JournalPage[] { + const files = readdirSync(dirPath) + .filter((f) => extname(f).toLowerCase() === '.md') + .sort((a, b) => a.localeCompare(b)); + + const pages: JournalPage[] = []; + let sort = 100; + for (const file of files) { + const cleaned = cleanBody(fileBody(resolve(dirPath, file))); + if (cleaned.length === 0) continue; + pages.push({ name: basename(file, '.md'), html: linesToHtml(cleaned), sort }); + sort += 100; + } + return pages; +} + +/** A standalone chapter file (no subfolder) becomes a single page. */ +function singleFileToPages(filePath: string): JournalPage[] { + const cleaned = cleanBody(fileBody(filePath)); + if (cleaned.length === 0) return []; + return [{ name: basename(filePath, '.md'), html: linesToHtml(cleaned), sort: 100 }]; +} -- 2.52.0