Initial commit

This commit is contained in:
Octoturge
2026-06-09 22:07:05 +02:00
commit 23af71e45a
16 changed files with 1865 additions and 0 deletions
+303
View File
@@ -0,0 +1,303 @@
/**
* Build LevelDB JournalEntry packs from book chapters.
*
* One pack per book / topic (mapped via PACK_CONFIGS + TOPICAL_CONFIGS).
*
* Split-book packs accept multiple source files: a list of `SourceFile`
* entries each pointing at a `rules/` or `lore/` markdown file (instead of a
* single monolithic `_books/` file). An optional `prefaceTitle` per source
* captures the introductory content before the first chapter heading.
*
* Run: `bun scripts/build-journal-packs.ts` from `.src/foundry-lore/`.
*/
import { mkdirSync, rmSync, writeFileSync, existsSync, readdirSync, statSync, readFileSync } from 'node:fs';
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 { parseTopicalFolder } from './parsers/topical-parser';
const __dirname = dirname(fileURLToPath(import.meta.url));
const moduleRoot = resolve(__dirname, '..');
const repoRoot = resolve(moduleRoot, '..', '..');
const booksDir = resolve(repoRoot, '_books');
const packsSrcDir = resolve(moduleRoot, 'packs-src');
const packsOutDir = resolve(moduleRoot, 'packs');
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface SourceFile {
/** Path relative to repoRoot. */
path: string;
/**
* If set, lines before the first chapter heading are collected and emitted
* as a synthetic journal entry with this title (e.g. "Wstęp").
*/
prefaceTitle?: string;
}
interface PackConfig {
pack: string;
/** For provenance / flags. */
sourceBook: string;
/** One or more source files that together make up this pack. */
sources: SourceFile[];
/** Skip chapters whose title matches this regex. */
skip?: RegExp;
}
// ---------------------------------------------------------------------------
// Pack configurations
// ---------------------------------------------------------------------------
const PACK_CONFIGS: PackConfig[] = [
{
pack: 'core-rules-lore',
sourceBook: 'core-rules',
sources: [{ path: 'ObsidianNotes/rules/00. Podręcznik Gry.md' }],
skip: /^(Talenty|Zaklęcia|Atrybuty|Umiejętności|Wyposażenie)/i,
},
{
pack: 'bestiary-lore',
sourceBook: 'bestiary',
sources: [{ path: 'ObsidianNotes/rules/06. Bestiariusz.md' }],
skip: /^Talenty/i,
},
// ── Split books: combine lore/ chapters + Wstęp intro from rules/ ──
{
pack: 'magic-book-lore',
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' },
],
skip: /^(Zaklęcia|Lista zaklęć|Rozdział V)/i,
},
{
pack: 'abyss',
sourceBook: 'abyss-curse',
sources: [
// 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' },
],
skip: /^(Rozdział II - Talenty|Rozdział III - Dary|Rozdział IV - Choroby|Rozdział V - Zaklęcia|Rozdział X - Artefakty)/i,
},
{
pack: 'blood-magic-history',
sourceBook: 'arcanum-sanguinis',
sources: [
{ path: 'ObsidianNotes/rules/04. Arcanum Sanguinis.md', prefaceTitle: 'Wstęp' },
],
skip: /^Rozdział IV - Talenty/i,
},
{
pack: 'crimson-cult',
sourceBook: 'crimson-cult',
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).
{ path: 'ObsidianNotes/rules/05. Vivat Patriarcha coccineus!.md', prefaceTitle: 'Wstęp' },
],
skip: /^(Rozdział I - Zaklęcia|Rozdział II - Artefakty)/i,
},
{
pack: 'economy',
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' },
],
skip: /^(Aneks A - Nowe Talenty|Aneks B - Przedmioty)/i,
},
// ── New: Humanity Guide ──
{
pack: 'humanity-guide',
sourceBook: 'humanity-guide',
sources: [{ path: 'ObsidianNotes/lore/10. Przewodnik Ludzkości po Magicznym Świecie.md' }],
},
];
// ---------------------------------------------------------------------------
// Topical vault-folder configurations
// ---------------------------------------------------------------------------
interface TopicalConfig {
/** Folder path relative to repoRoot. */
folder: string;
/** Foundry pack id. */
pack: string;
}
const TOPICAL_CONFIGS: TopicalConfig[] = [
{ folder: 'ObsidianNotes/disciplines', pack: 'disciplines-lore' },
{ folder: 'ObsidianNotes/organizations', pack: 'organizations' },
{ folder: 'ObsidianNotes/races', pack: 'races-lore' },
{ folder: 'ObsidianNotes/classes', pack: 'classes' },
{ folder: 'ObsidianNotes/concepts', pack: 'concepts' },
{ folder: 'ObsidianNotes/locations', pack: 'locations' },
{ folder: 'ObsidianNotes/npcs', pack: 'npcs' },
{ folder: 'ObsidianNotes/conflicts', pack: 'conflicts' },
{ folder: 'ObsidianNotes/player-characters', pack: 'player-characters' },
{ folder: 'ObsidianNotes/adventures', pack: 'adventures' },
];
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
console.log(`[lore] repo root: ${repoRoot}`);
const allDocs: JournalDoc[] = [];
// ── Book packs ──
for (const cfg of PACK_CONFIGS) {
let packDocs: JournalDoc[] = [];
for (const src of cfg.sources) {
const bookPath = resolve(repoRoot, src.path);
if (!existsSync(bookPath)) {
console.warn(` · skip source ${src.path}: file not found`);
continue;
}
const docs = parseJournalBook({
bookPath,
pack: cfg.pack,
sourceBook: cfg.sourceBook,
chapterSkip: cfg.skip,
prefaceTitle: src.prefaceTitle,
});
packDocs.push(...docs);
}
console.log(` · ${cfg.pack}: ${packDocs.length} entries`);
allDocs.push(...packDocs);
}
// ── Topical packs ──
for (const top of TOPICAL_CONFIGS) {
const folderPath = resolve(repoRoot, top.folder);
if (!existsSync(folderPath)) {
console.warn(` · skip topical ${top.pack}: folder not found (${top.folder})`);
continue;
}
const docs = parseTopicalFolder({ folderPath, pack: top.pack });
console.log(` · ${top.pack}: ${docs.length} entries`);
allDocs.push(...docs);
}
console.log(`[lore] parsed ${allDocs.length} journal entries total`);
if (existsSync(packsSrcDir)) rmSync(packsSrcDir, { recursive: true });
const byPack = new Map<string, JournalDoc[]>();
for (const doc of allDocs) {
const list = byPack.get(doc.pack) ?? [];
list.push(doc);
byPack.set(doc.pack, list);
}
for (const [pack, docs] of byPack) {
const dir = resolve(packsSrcDir, pack);
mkdirSync(dir, { recursive: true });
let sort = 0;
for (const doc of docs) {
sort += 100;
const foundryDoc = toFoundryJournal(doc, sort);
writeFileSync(resolve(dir, `${doc.id}.json`), `${JSON.stringify(foundryDoc, null, 2)}\n`, 'utf8');
}
console.log(` · wrote ${pack}: ${docs.length} entries`);
}
if (existsSync(packsOutDir)) rmSync(packsOutDir, { recursive: true });
for (const pack of byPack.keys()) {
const src = resolve(packsSrcDir, pack);
const dest = resolve(packsOutDir, pack);
await compilePack(src, dest, {
recursive: false,
log: false,
transformEntry: (doc: any, context: any) => {
if (!doc._key) {
console.error(`[build-journal-packs] ERROR: Document missing _key! Name: ${doc.name}, ID: ${doc._id}`);
}
if (doc.pages) {
for (const page of doc.pages) {
if (!page._key) {
console.error(`[build-journal-packs] ERROR: Page missing _key! Journal: ${doc.name}, Page: ${page.name}, Page ID: ${page._id}`);
}
}
}
return true;
}
});
console.log(` ✓ compiled ${pack}`);
}
console.log(`[lore] done — ${byPack.size} packs in packs/`);
function toFoundryJournal(doc: JournalDoc, sort: number): Record<string, unknown> {
const fId = makeFoundryId(doc.id);
return {
_key: `!journal!${fId}`,
_id: fId,
name: doc.name,
folder: null,
sort,
pages: doc.pages.map((page, i) => {
const pageFId = makeFoundryId(`${doc.id}-page-${i}`);
return {
_key: `!journal.pages!${fId}.${pageFId}`,
_id: pageFId,
name: page.name,
type: 'text',
title: { show: true, level: 1 },
text: { content: page.html, format: 1 /* HTML */ },
sort: page.sort,
};
}),
flags: {
'hbm-rpg-v3-lore': {
slug: doc.id,
sourceBook: doc.source.book,
sourceChapter: doc.source.chapter,
sourceLine: doc.source.line,
},
},
_stats: {},
};
}
/**
* Derive a stable 12-character Foundry document ID from a slug.
* Uses two independent FNV-1a 32-bit hashes to spread entropy across the
* *entire* slug (not just the first 12 bytes), avoiding collisions between
* slugs that share a long common prefix (e.g. page-0 vs page-1).
*/
function makeFoundryId(slug: string): string {
const alphabet = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
// FNV-1a 32-bit, two independent passes with different offsets
let h1 = 0x811c9dc5;
let h2 = 0x4b9ace3f;
for (let i = 0; i < slug.length; i++) {
const c = slug.charCodeAt(i);
h1 = Math.imul(h1 ^ c, 0x01000193) >>> 0;
h2 = Math.imul(h2 ^ (c + i + 1), 0x01000193) >>> 0;
}
// Encode 64 bits (two 32-bit hashes) into 12 base-62 chars
let out = '';
let lo = h1;
let hi = h2;
for (let i = 0; i < 12; i++) {
// Combine both halves cycling
const combined = (i % 2 === 0 ? lo : hi) >>> 0;
out += alphabet[combined % alphabet.length];
lo = Math.imul(lo, 1664525) + 1013904223 >>> 0;
hi = Math.imul(hi, 22695477) + 1013904223 >>> 0;
}
return out;
}
+44
View File
@@ -0,0 +1,44 @@
/**
* Bumps the patch version in package.json and module.json in lock-step.
* Run automatically as part of `bun run package`.
*
* Usage:
* bun scripts/bump-version.ts # patch bump (0.1.0 → 0.1.1)
* bun scripts/bump-version.ts minor # minor bump (0.1.0 → 0.2.0)
* bun scripts/bump-version.ts major # major bump (0.1.0 → 1.0.0)
*/
import { readFileSync, writeFileSync } from 'node:fs';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const root = resolve(__dirname, '..');
type BumpType = 'major' | 'minor' | 'patch';
const bump = (process.argv[2] ?? 'patch') as BumpType;
function bumpVersion(version: string, type: BumpType): string {
const [major, minor, patch] = version.split('.').map(Number);
if (type === 'major') return `${major + 1}.0.0`;
if (type === 'minor') return `${major}.${minor + 1}.0`;
return `${major}.${minor}.${patch + 1}`;
}
// --- package.json ---
const pkgPath = resolve(root, 'package.json');
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) as { version: string };
const oldVersion = pkg.version;
const newVersion = bumpVersion(oldVersion, bump);
pkg.version = newVersion;
writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
// --- module.json ---
const modPath = resolve(root, 'module.json');
const mod = JSON.parse(readFileSync(modPath, 'utf8')) as { version: string; download: string };
mod.version = newVersion;
// Update the versioned filename in the download URL if it follows the vX.Y.Z pattern
mod.download = mod.download.replace(/-v[\d.]+\.zip$/, `-v${newVersion}.zip`);
writeFileSync(modPath, JSON.stringify(mod, null, 2) + '\n');
console.log(`✓ Version bumped ${oldVersion}${newVersion}`);
+59
View File
@@ -0,0 +1,59 @@
/**
* Lint @UUID[…] references across packs-src to catch broken cross-links
* before packaging. Walks every JSON page in packs-src/, scans for
* `@UUID[Compendium.<pkg>.<pack>...]` patterns, and reports any reference
* to a system pack we know about but a slug that does not exist.
*
* Currently informational only — the journal parser does not yet emit
* UUID cross-links automatically. This script provides the framework
* for Phase 7.3/7.4 integration.
*/
import { readdirSync, readFileSync, statSync } from 'node:fs';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const moduleRoot = resolve(__dirname, '..');
const packsSrcDir = resolve(moduleRoot, 'packs-src');
const UUID_RE = /@UUID\[Compendium\.([^.\s]+)\.([^.\s]+)\.([^.\s]+)\.([^\]\s]+)\]/g;
let scanned = 0;
let total = 0;
const refs: Array<{ file: string; pkg: string; pack: string; type: string; id: string }> = [];
function walk(dir: string) {
for (const entry of readdirSync(dir)) {
const p = resolve(dir, entry);
const s = statSync(p);
if (s.isDirectory()) walk(p);
else if (entry.endsWith('.json')) scanFile(p);
}
}
function scanFile(file: string) {
scanned++;
const text = readFileSync(file, 'utf8');
let m: RegExpExecArray | null;
while ((m = UUID_RE.exec(text)) !== null) {
total++;
refs.push({ file, pkg: m[1], pack: m[2], type: m[3], id: m[4] });
}
}
try {
walk(packsSrcDir);
} catch {
console.log('[lint-uuid] no packs-src/ — run build:packs first.');
process.exit(0);
}
console.log(`[lint-uuid] scanned ${scanned} files; found ${total} @UUID refs.`);
if (refs.length > 0) {
for (const r of refs.slice(0, 25)) {
console.log(` · ${r.pkg}.${r.pack}.${r.type}.${r.id}`);
}
if (refs.length > 25) console.log(` … (+${refs.length - 25} more)`);
}
process.exit(0);
+39
View File
@@ -0,0 +1,39 @@
/**
* Package the lore module into a versioned zip ready to drop into a
* Foundry VTT instance's Data/modules/ directory.
*/
import { createWriteStream, readFileSync } from 'node:fs';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import archiver from 'archiver';
const __dirname = dirname(fileURLToPath(import.meta.url));
const root = resolve(__dirname, '..');
const moduleJson = JSON.parse(readFileSync(resolve(root, 'module.json'), 'utf8')) as { id: string; version: string };
const outFile = resolve(root, `${moduleJson.id}-v${moduleJson.version}.zip`);
await new Promise<void>((resolveAll, rejectAll) => {
const output = createWriteStream(outFile);
const archive = archiver('zip', { zlib: { level: 9 } });
output.on('close', () => {
console.log(`✓ Packaged ${outFile} (${archive.pointer()} bytes)`);
resolveAll();
});
archive.on('warning', (err) => {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') console.warn(err);
else rejectAll(err);
});
archive.on('error', rejectAll);
archive.pipe(output);
// Foundry expects everything under a folder named after the module id.
archive.file(resolve(root, 'module.json'), { name: `${moduleJson.id}/module.json` });
archive.directory(resolve(root, 'lang'), `${moduleJson.id}/lang`);
archive.directory(resolve(root, 'packs'), `${moduleJson.id}/packs`, (entry) => {
if (entry.name.endsWith('LOCK')) return false;
return entry;
});
archive.finalize();
});
+300
View File
@@ -0,0 +1,300 @@
/**
* Journal parser for HbM RPG v3 books.
*
* Strategy
* ────────
* Supports two file formats:
*
* A. Legacy Google-Docs export (`_books/`):
* - No frontmatter.
* - TOC is a plain-text block that reuses the same `Rozdział X — Title`
* headings. The parser deduplicates: the SECOND occurrence of each
* heading is the real body.
*
* B. Native Obsidian/repo files (`rules/`, `lore/`):
* - Has YAML frontmatter (--- ... ---), stripped before parsing.
* - TOC is Obsidian bullet-links (`- [[#Anchor|Label]]`), not bare
* `Rozdział X` headings, so no duplicates exist and dedup is skipped.
* - Chapters may be prefixed with markdown heading markers (`## `).
* - An optional `prefaceTitle` captures content before the first chapter
* as a synthetic "Wstęp" entry.
*
* Output: `JournalDoc[]` consumed by `build-journal-packs.ts`.
*/
import { readFileSync } from 'node:fs';
export interface JournalDoc {
/** Stable kebab-case slug. */
id: string;
/** Display title (Polish, as in book). */
name: string;
/** Pack id this entry belongs to (e.g. `abyss`). */
pack: string;
/** Source provenance for traceability. */
source: { book: string; chapter: string; line: number };
/** Pages — each becomes a `JournalEntryPage`. */
pages: JournalPage[];
}
export interface JournalPage {
name: string;
/** Content as HTML (paragraph-split markdown, basic conversion). */
html: string;
/** Hint for sort order. */
sort: number;
}
export interface JournalParseConfig {
/** Absolute path to the markdown book. */
bookPath: string;
/** Pack id to assign (e.g. `abyss`). */
pack: string;
/** Source-book id for `source.book`. */
sourceBook: string;
/** Optional: only emit chapters whose title matches this regex. */
chapterFilter?: RegExp;
/** Optional: skip chapters whose title matches this regex (e.g. spell tables). */
chapterSkip?: RegExp;
/**
* If set, content between the frontmatter/start and the first chapter is
* collected and emitted as a synthetic entry with this title (e.g. "Wstęp").
* Useful for the intro paragraphs of split `rules/` files.
*/
prefaceTitle?: string;
}
// Matches both plain-text (`Rozdział IV - Title`) and heading-prefixed
// (`## Rozdział IV - Title`) chapter lines. Optional trailing page number.
const CHAPTER_RE = /^(?:#{1,6}\s+)?Rozdzia[łl]\s+([IVXLC]+)\s*[:.—\-]\s*(.+?)(?:\s+\d+)?$/;
const SEPARATOR = /^_{3,}$/;
const FRONTMATTER_FENCE = /^---\s*$/;
// Obsidian TOC bullets: `- [[#Anchor|Label]]` — skip in body content too
const OBSIDIAN_TOC_BULLET = /^[-*]\s+\[\[#/;
/** Returns the first non-frontmatter line index (skips `--- ... ---` block). */
function skipFrontmatter(lines: string[]): number {
if (lines.length === 0 || !FRONTMATTER_FENCE.test(lines[0].trim())) return 0;
for (let i = 1; i < lines.length; i++) {
if (FRONTMATTER_FENCE.test(lines[i].trim())) return i + 1;
}
return 0;
}
export function parseJournalBook(cfg: JournalParseConfig): JournalDoc[] {
const text = readFileSync(cfg.bookPath, 'utf8');
const rawLines = text.split(/\r?\n/);
const startIdx = skipFrontmatter(rawLines);
const lines = rawLines.slice(startIdx);
// Pass 1: find every chapter occurrence.
const occurrences: Array<{ idx: number; roman: string; title: string; raw: string }> = [];
for (let i = 0; i < lines.length; i++) {
const m = lines[i].trim().match(CHAPTER_RE);
if (m) occurrences.push({ idx: i, roman: m[1], title: m[2].trim(), raw: lines[i].trim() });
}
if (occurrences.length === 0) {
// No chapters found — if there's a prefaceTitle, still try to emit preface.
if (cfg.prefaceTitle) {
const pages = bodyToPages(lines);
if (pages.length > 0) {
const slug = slugify(cfg.prefaceTitle);
return [{ id: `${cfg.pack}-${slug}`, name: cfg.prefaceTitle, pack: cfg.pack, source: { book: cfg.sourceBook, chapter: cfg.prefaceTitle, line: startIdx + 1 }, pages }];
}
}
return [];
}
// Identify TOC entries: a chapter occurrence is part of the TOC if the
// SAME (roman, title) pair occurs again later in the file.
const seen = new Map<string, number>();
const bodyChapters: typeof occurrences = [];
for (const occ of occurrences) {
const key = `${occ.roman}|${occ.title}`;
if (!seen.has(key)) {
seen.set(key, occ.idx);
continue;
}
bodyChapters.push(occ);
}
// If no duplicates were found (no plain-text TOC — typical of native files),
// treat the entire list as body chapters.
const chapters = bodyChapters.length > 0 ? bodyChapters : occurrences;
const docs: JournalDoc[] = [];
// Emit preface entry (content before first chapter heading).
if (cfg.prefaceTitle && chapters.length > 0) {
const prefaceBody = lines.slice(0, chapters[0].idx);
// Filter out Obsidian TOC bullets so they don't become page content.
const prefaceClean = prefaceBody.filter((l) => !OBSIDIAN_TOC_BULLET.test(l.trim()));
const pages = bodyToPages(prefaceClean);
if (pages.length > 0) {
const slug = slugify(cfg.prefaceTitle);
docs.push({
id: `${cfg.pack}-${slug}`,
name: cfg.prefaceTitle,
pack: cfg.pack,
source: { book: cfg.sourceBook, chapter: cfg.prefaceTitle, line: startIdx + 1 },
pages,
});
}
}
for (let c = 0; c < chapters.length; c++) {
const ch = chapters[c];
if (cfg.chapterSkip && cfg.chapterSkip.test(ch.title)) continue;
if (cfg.chapterFilter && !cfg.chapterFilter.test(ch.title)) continue;
const start = ch.idx + 1;
const end = c + 1 < chapters.length ? chapters[c + 1].idx : lines.length;
const bodyRaw = lines.slice(start, end);
// Filter out Obsidian TOC bullets.
const body = bodyRaw.filter((l) => !OBSIDIAN_TOC_BULLET.test(l.trim()));
const pages = bodyToPages(body);
if (pages.length === 0) continue;
const slug = slugify(`${ch.roman}-${ch.title}`);
docs.push({
id: `${cfg.pack}-${slug}`,
name: `Rozdział ${ch.roman}${ch.title}`,
pack: cfg.pack,
source: { book: cfg.sourceBook, chapter: ch.title, line: startIdx + ch.idx + 1 },
pages,
});
}
return docs;
}
/** Split a chapter body by `____` separators into pages, converting each to HTML. */
function bodyToPages(body: string[]): JournalPage[] {
const segments: string[][] = [[]];
for (const line of body) {
if (SEPARATOR.test(line.trim())) {
if (segments[segments.length - 1].length > 0) segments.push([]);
} else {
segments[segments.length - 1].push(line);
}
}
const pages: JournalPage[] = [];
let pageNo = 1;
for (const seg of segments) {
const cleaned = trimEdges(seg);
if (cleaned.length === 0) continue;
const name = derivePageTitle(cleaned, pageNo);
pages.push({ name, html: linesToHtml(cleaned), sort: pageNo * 100 });
pageNo++;
}
return pages;
}
function trimEdges(arr: string[]): string[] {
let lo = 0;
let hi = arr.length;
while (lo < hi && arr[lo].trim() === '') lo++;
while (hi > lo && arr[hi - 1].trim() === '') hi--;
return arr.slice(lo, hi);
}
/** 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() ?? '';
if (first.length > 0 && first.length <= 80 && !/[.!?]$/.test(first)) {
return first;
}
return `Część ${pageNo}`;
}
/** Conservative markdown → HTML conversion. Paragraphs, bold, italic, lists. */
function linesToHtml(lines: string[]): string {
const out: string[] = [];
let para: string[] = [];
let listType: 'ul' | 'ol' | null = null;
const flushPara = () => {
if (para.length === 0) return;
out.push(`<p>${inline(para.join(' ').trim())}</p>`);
para = [];
};
const flushList = () => {
if (listType) {
out.push(`</${listType}>`);
listType = null;
}
};
for (const raw of lines) {
const line = raw.trimEnd();
const trimmed = line.trim();
if (trimmed === '') {
flushPara();
flushList();
continue;
}
const ulMatch = trimmed.match(/^[*\-]\s+(.+)$/);
const olMatch = trimmed.match(/^\d+\.\s+(.+)$/);
if (ulMatch) {
flushPara();
if (listType !== 'ul') {
flushList();
listType = 'ul';
out.push('<ul>');
}
out.push(`<li>${inline(ulMatch[1])}</li>`);
continue;
}
if (olMatch) {
flushPara();
if (listType !== 'ol') {
flushList();
listType = 'ol';
out.push('<ol>');
}
out.push(`<li>${inline(olMatch[1])}</li>`);
continue;
}
// Sub-heading: stand-alone short bold-ish line
if (/^#{1,6}\s+/.test(trimmed)) {
flushPara();
flushList();
const lvl = Math.min(6, (trimmed.match(/^#+/)?.[0].length ?? 2) + 1);
out.push(`<h${lvl}>${inline(trimmed.replace(/^#+\s+/, ''))}</h${lvl}>`);
continue;
}
flushList();
para.push(line);
}
flushPara();
flushList();
return out.join('\n');
}
function inline(s: string): string {
// Order matters: escape first, then markup.
let r = s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
r = r.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
r = r.replace(/(^|[\s(])\*([^*\n]+)\*/g, '$1<em>$2</em>');
r = r.replace(/\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g, (_m, target, label) => {
return `<a class="lore-link" data-target="${target.trim()}">${(label ?? target).trim()}</a>`;
});
return r;
}
function slugify(s: string): string {
return s
.toLowerCase()
.normalize('NFKD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/ł/g, 'l')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '')
.slice(0, 60);
}
+251
View File
@@ -0,0 +1,251 @@
/**
* Topical vault-folder parser.
*
* Recursively walks a folder and converts every `.md` file into a
* `JournalDoc`. Strips YAML frontmatter and Obsidian tag lines; converts the
* remaining body to HTML pages (one per `____` separator, or a single page
* if no separators). Empty/stub files still produce a JournalEntry with one
* empty page so the folder layout mirrors the Obsidian vault in Foundry.
*
* Stable `_id` derivation: `slugify(packId + '/' + relativePathWithoutExt)`.
*/
import { readFileSync, readdirSync, statSync } from 'node:fs';
import { resolve, relative, basename, extname, dirname } from 'node:path';
import type { JournalDoc, JournalPage } from './journal-parser';
export interface TopicalParseConfig {
/** Absolute path to the folder to walk. */
folderPath: string;
/** Foundry pack id (e.g. `organizations`). */
pack: string;
}
const SEPARATOR = /^_{3,}$/;
const FRONTMATTER_FENCE = /^---\s*$/;
const OBSIDIAN_TOC_BULLET = /^[-*]\s+\[\[#/;
// Obsidian tag lines: lines that start with `#tag` or are `#tag #tag2 …`
const TAG_LINE = /^(#[a-zA-ZżźćąśęłóńŻŹĆĄŚĘŁÓŃ_-]+\s*)+$/;
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
export function parseTopicalFolder(cfg: TopicalParseConfig): JournalDoc[] {
const docs: JournalDoc[] = [];
walkDir(cfg.folderPath, cfg.folderPath, cfg.pack, docs);
return docs;
}
// ---------------------------------------------------------------------------
// Internal
// ---------------------------------------------------------------------------
function walkDir(
rootPath: string,
currentPath: string,
pack: string,
out: JournalDoc[],
): void {
let entries: string[];
try {
entries = readdirSync(currentPath);
} catch {
return;
}
for (const entry of entries) {
// Skip hidden files / system files.
if (entry.startsWith('.')) continue;
const fullPath = resolve(currentPath, entry);
let stat;
try {
stat = statSync(fullPath);
} catch {
continue;
}
if (stat.isDirectory()) {
walkDir(rootPath, fullPath, pack, out);
} else if (stat.isFile() && extname(entry).toLowerCase() === '.md') {
const doc = parseTopicalFile(rootPath, fullPath, pack);
if (doc) out.push(doc);
}
}
}
function parseTopicalFile(
rootPath: string,
filePath: string,
pack: string,
): JournalDoc | null {
let rawText = '';
try {
rawText = readFileSync(filePath, 'utf8');
} catch {
return null;
}
const rawLines = rawText.split(/\r?\n/);
// Strip YAML frontmatter.
const bodyStart = skipFrontmatter(rawLines);
const bodyLines = rawLines.slice(bodyStart);
// Derive stable id from relative path.
const relPath = relative(rootPath, filePath);
// e.g. "Federacja Sol-3/Diana.md" → "federacja-sol-3-diana"
const slug = slugifyPath(relPath);
const id = `${pack}-${slug}`;
// Display name = filename without extension.
const name = basename(filePath, extname(filePath));
// Build pages from body (split on ___ separators); even stubs get one page.
const pages = bodyToPages(bodyLines, name);
// Source: relative path inside the repo.
const source = {
book: pack,
chapter: name,
line: bodyStart + 1,
};
return { id, name, pack, source, pages };
}
/** Split body into pages separated by `____`. */
function bodyToPages(lines: string[], defaultName: string): JournalPage[] {
const segments: string[][] = [[]];
for (const line of lines) {
if (SEPARATOR.test(line.trim())) {
segments.push([]);
} else if (!OBSIDIAN_TOC_BULLET.test(line.trim())) {
// Skip Obsidian TOC bullets from pages too.
segments[segments.length - 1].push(line);
}
}
const pages: JournalPage[] = [];
let pageNo = 1;
for (const seg of segments) {
const cleaned = trimEdges(seg.filter((l) => !TAG_LINE.test(l.trim())));
// Always emit at least one page, even if empty (placeholders for stubs).
const html = cleaned.length > 0 ? linesToHtml(cleaned) : '';
const name = pages.length === 0 ? (derivePageTitle(cleaned) ?? defaultName) : `Część ${pageNo}`;
pages.push({ name, html, sort: pageNo * 100 });
pageNo++;
// Only the first page can be empty (stub placeholder). Subsequent empty
// segments (trailing separators) are skipped.
if (cleaned.length === 0 && pageNo > 2) continue;
}
return pages;
}
function trimEdges(arr: string[]): string[] {
let lo = 0;
let hi = arr.length;
while (lo < hi && arr[lo].trim() === '') lo++;
while (hi > lo && arr[hi - 1].trim() === '') hi--;
return arr.slice(lo, hi);
}
function derivePageTitle(lines: string[]): string | null {
const first = lines.find((l) => l.trim().length > 0)?.trim() ?? '';
if (!first) return null;
const bare = first.replace(/^#{1,6}\s+/, '');
if (bare.length > 0 && bare.length <= 80 && !/[.!?]$/.test(bare)) return bare;
return null;
}
function skipFrontmatter(lines: string[]): number {
if (lines.length === 0 || !FRONTMATTER_FENCE.test(lines[0].trim())) return 0;
for (let i = 1; i < lines.length; i++) {
if (FRONTMATTER_FENCE.test(lines[i].trim())) return i + 1;
}
return 0;
}
function slugifyPath(relPath: string): string {
// Remove .md extension, convert path separators to hyphens, slugify.
const withoutExt = relPath.replace(/\.md$/i, '');
return withoutExt
.toLowerCase()
.normalize('NFKD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/ł/g, 'l')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '')
.slice(0, 80);
}
/** Conservative markdown → HTML. Reuses same logic as journal-parser. */
function linesToHtml(lines: string[]): string {
const out: string[] = [];
let para: string[] = [];
let listType: 'ul' | 'ol' | null = null;
const flushPara = () => {
if (para.length === 0) return;
out.push(`<p>${inline(para.join(' ').trim())}</p>`);
para = [];
};
const flushList = () => {
if (listType) {
out.push(`</${listType}>`);
listType = null;
}
};
for (const raw of lines) {
const line = raw.trimEnd();
const trimmed = line.trim();
if (trimmed === '' || TAG_LINE.test(trimmed)) {
flushPara();
flushList();
continue;
}
const ulMatch = trimmed.match(/^[*\-]\s+(.+)$/);
const olMatch = trimmed.match(/^\d+\.\s+(.+)$/);
if (ulMatch) {
flushPara();
if (listType !== 'ul') { flushList(); listType = 'ul'; out.push('<ul>'); }
out.push(`<li>${inline(ulMatch[1])}</li>`);
continue;
}
if (olMatch) {
flushPara();
if (listType !== 'ol') { flushList(); listType = 'ol'; out.push('<ol>'); }
out.push(`<li>${inline(olMatch[1])}</li>`);
continue;
}
if (/^#{1,6}\s+/.test(trimmed)) {
flushPara();
flushList();
const lvl = Math.min(6, (trimmed.match(/^#+/)?.[0].length ?? 2) + 1);
out.push(`<h${lvl}>${inline(trimmed.replace(/^#+\s+/, ''))}</h${lvl}>`);
continue;
}
flushList();
para.push(line);
}
flushPara();
flushList();
return out.join('\n');
}
function inline(s: string): string {
let r = s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
r = r.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
r = r.replace(/(^|[\s(])\*([^*\n]+)\*/g, '$1<em>$2</em>');
r = r.replace(/\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g, (_m, target, label) =>
`<a class="lore-link" data-target="${target.trim()}">${(label ?? target).trim()}</a>`,
);
return r;
}
+83
View File
@@ -0,0 +1,83 @@
import { existsSync, readFileSync } from 'node:fs';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
// 1. Resolve paths and load package manifest
const __dirname = dirname(fileURLToPath(import.meta.url));
const root = resolve(__dirname, '..');
let id = '';
let version = '';
if (existsSync(resolve(root, 'module.json'))) {
const moduleJson = JSON.parse(readFileSync(resolve(root, 'module.json'), 'utf8'));
id = moduleJson.id;
version = moduleJson.version;
} else if (existsSync(resolve(root, 'system.json'))) {
const systemJson = JSON.parse(readFileSync(resolve(root, 'system.json'), 'utf8'));
id = systemJson.id;
version = systemJson.version;
} else if (existsSync(resolve(root, 'dist', 'system.json'))) {
const systemJson = JSON.parse(readFileSync(resolve(root, 'dist', 'system.json'), 'utf8'));
id = systemJson.id;
version = systemJson.version;
} else {
console.error("Error: Could not find system.json or module.json manifest!");
process.exit(1);
}
const zipPath = resolve(root, `${id}-v${version}.zip`);
if (!existsSync(zipPath)) {
console.error(`Error: Packaged zip file not found at ${zipPath}`);
console.error("Please run 'bun run package' first to generate the zip package.");
process.exit(1);
}
// 2. Determine ingest URL and API token from environment variables
const ingestUrl = process.env.FOUNDRY_INGEST_URL;
const apiToken = process.env.FOUNDRY_INGEST_TOKEN;
if (!ingestUrl) {
console.error("Error: FOUNDRY_INGEST_URL environment variable is not defined!");
console.error("Please define FOUNDRY_INGEST_URL in your .env file.");
process.exit(1);
}
if (!apiToken) {
console.error("Error: FOUNDRY_INGEST_TOKEN environment variable is not defined!");
console.error("Please define FOUNDRY_INGEST_TOKEN in your .env file.");
process.exit(1);
}
console.log(`Pushing package "${id}" version ${version}...`);
console.log(`Target URL: ${ingestUrl}`);
// 3. Build multipart form data and perform upload
const formData = new FormData();
const fileBlob = Bun.file(zipPath);
formData.append('file', fileBlob, `${id}-v${version}.zip`);
try {
const response = await fetch(ingestUrl, {
method: 'POST',
headers: {
'X-API-TOKEN': apiToken
},
body: formData
});
if (response.ok) {
const data = await response.json();
console.log("✓ Package successfully pushed and ingested!");
console.log(JSON.stringify(data, null, 2));
} else {
console.error(`Error: Ingest server returned status ${response.status} ${response.statusText}`);
const text = await response.text();
console.error(`Response details: ${text}`);
process.exit(1);
}
} catch (error) {
console.error("Error connecting to ingest server:", error);
process.exit(1);
}