From 0a490e4e6810b0a87bda4aac4d54f6c866c36cb9 Mon Sep 17 00:00:00 2001 From: root Date: Thu, 4 Jun 2026 23:25:00 +1000 Subject: [PATCH] =?UTF-8?q?feat(migrate):=20BookStack=20importer=20preserv?= =?UTF-8?q?es=20Book=20=E2=80=BA=20Chapter=20=E2=80=BA=20Page=20hierarchy?= =?UTF-8?q?=20(parent=5Fid)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- migrate/sources/bookstack.js | 53 ++++++++++++++++++++++++++------- tests/migrate/bookstack.test.js | 38 ++++++++++++++--------- 2 files changed, 67 insertions(+), 24 deletions(-) diff --git a/migrate/sources/bookstack.js b/migrate/sources/bookstack.js index 50eb59a..cd814a8 100644 --- a/migrate/sources/bookstack.js +++ b/migrate/sources/bookstack.js @@ -4,23 +4,56 @@ import * as map from '../../lib/db/repos/migration_map.js'; const SYS = { kind: 'system', id: null }; const slugify = (s) => (s || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '').slice(0, 60) || 'page'; -// BookStack REST: list GET /api/pages → {data:[{id,name}]}; detail GET /api/pages/:id -// → {id,name,markdown,html}. Auth header: "Token :". +// BookStack REST. Auth header: "Token :". Builds the hierarchy as +// parent pages: books → chapters (parent=book) → pages (parent=chapter or book), +// so Void breadcrumbs read Wiki › Book › Chapter › Page. export async function importBookstack({ apiUrl = process.env.BOOKSTACK_URL, tokenId = process.env.BOOKSTACK_TOKEN_ID, tokenSecret = process.env.BOOKSTACK_TOKEN_SECRET, spaceId, dryRun = false, fetchImpl = fetch }) { const headers = { Authorization: `Token ${tokenId}:${tokenSecret}` }; - const listRes = await fetchImpl(`${apiUrl}/api/pages`, { headers }); - if (!listRes.ok) throw new Error(`bookstack list ${listRes.status}`); - const { data = [] } = await listRes.json(); + const getJson = async (path) => { + const r = await fetchImpl(`${apiUrl}${path}`, { headers }); + if (!r.ok) throw new Error(`bookstack ${path} ${r.status}`); + return r.json(); + }; let created = 0; - for (const item of data) { + const bookPage = new Map(); // book id → void page id + const chapterPage = new Map(); // chapter id → void page id + + // 1. Books → top-level parent pages. + for (const b of ((await getJson('/api/books')).data || [])) { + let pid = await map.seen('bookstack', `book:${b.id}`, 'page'); + if (!pid) { + created++; + if (!dryRun) { + const p = await pages.create({ space_id: spaceId, slug: slugify(b.name), title: b.name || 'Book', body_md: b.description || '' }, SYS); + pid = p.id; await map.record('bookstack', `book:${b.id}`, 'page', pid); + } + } + if (pid) bookPage.set(b.id, pid); + } + + // 2. Chapters → pages parented to their book. + for (const c of ((await getJson('/api/chapters')).data || [])) { + let pid = await map.seen('bookstack', `chapter:${c.id}`, 'page'); + if (!pid) { + created++; + if (!dryRun) { + const p = await pages.create({ space_id: spaceId, slug: slugify(c.name), title: c.name || 'Chapter', body_md: c.description || '', parent_id: bookPage.get(c.book_id) || null }, SYS); + pid = p.id; await map.record('bookstack', `chapter:${c.id}`, 'page', pid); + } + } + if (pid) chapterPage.set(c.id, pid); + } + + // 3. Pages → leaf pages parented to their chapter (or book if chapter-less). + for (const item of ((await getJson('/api/pages')).data || [])) { if (await map.seen('bookstack', `page:${item.id}`, 'page')) continue; created++; if (dryRun) continue; - const dRes = await fetchImpl(`${apiUrl}/api/pages/${item.id}`, { headers }); - if (!dRes.ok) throw new Error(`bookstack page ${item.id} ${dRes.status}`); - const d = await dRes.json(); + const d = await getJson(`/api/pages/${item.id}`); const body = d.markdown && d.markdown.trim() ? d.markdown : (d.html || ''); - const p = await pages.create({ space_id: spaceId, slug: slugify(d.name), title: d.name || 'Untitled', body_md: body }, SYS); + const parent = chapterPage.get(d.chapter_id) || bookPage.get(d.book_id) || null; + const p = await pages.create({ space_id: spaceId, slug: slugify(d.name), title: d.name || 'Untitled', body_md: body, parent_id: parent }, SYS); await map.record('bookstack', `page:${item.id}`, 'page', p.id); } + return { created }; } diff --git a/tests/migrate/bookstack.test.js b/tests/migrate/bookstack.test.js index c0cca94..e540287 100644 --- a/tests/migrate/bookstack.test.js +++ b/tests/migrate/bookstack.test.js @@ -8,20 +8,30 @@ import { importBookstack } from '../../migrate/sources/bookstack.js'; let spaceId; beforeAll(async () => { await resetDb(); await migrateUp(); spaceId = await ensureSpace('wiki', 'Wiki'); }); -describe('bookstack importer', () => { - it('imports pages with markdown body, idempotently', async () => { - const list = { data: [{ id: 1, name: 'Page One' }, { id: 2, name: 'Page Two' }] }; - const detail = (id) => ({ id, name: id === 1 ? 'Page One' : 'Page Two', markdown: `# P${id}\nbody`, html: `

body

` }); - const fetchImpl = vi.fn(async (u) => { - if (u.endsWith('/api/pages')) return { ok: true, json: async () => list }; - const id = Number(u.split('/').pop()); - return { ok: true, json: async () => detail(id) }; - }); - const r1 = await importBookstack({ apiUrl: 'http://bs', tokenId: 'i', tokenSecret: 's', spaceId, fetchImpl }); - expect(r1.created).toBe(2); - const { rows } = await pool.query(`SELECT title FROM pages WHERE space_id=$1 ORDER BY title`, [spaceId]); - expect(rows.map(r => r.title)).toEqual(['Page One', 'Page Two']); - const r2 = await importBookstack({ apiUrl: 'http://bs', tokenId: 'i', tokenSecret: 's', spaceId, fetchImpl }); +function fetchMock() { + const books = { data: [{ id: 10, name: 'Infra Book', description: '' }] }; + const chapters = { data: [{ id: 20, name: 'Storage', book_id: 10 }] }; + const list = { data: [{ id: 1, name: 'Overview' }, { id: 2, name: 'Disks' }] }; + const detail = (id) => ({ id, name: id === 1 ? 'Overview' : 'Disks', markdown: `# P${id}\nbody`, book_id: 10, chapter_id: id === 2 ? 20 : 0 }); + return vi.fn(async (u) => { + if (u.endsWith('/api/books')) return { ok: true, json: async () => books }; + if (u.endsWith('/api/chapters')) return { ok: true, json: async () => chapters }; + if (u.endsWith('/api/pages')) return { ok: true, json: async () => list }; + return { ok: true, json: async () => detail(Number(u.split('/').pop())) }; + }); +} + +describe('bookstack importer (hierarchy)', () => { + it('creates Book › Chapter › Page with parent links, idempotently', async () => { + const r1 = await importBookstack({ apiUrl: 'http://bs', tokenId: 'i', tokenSecret: 's', spaceId, fetchImpl: fetchMock() }); + expect(r1.created).toBe(4); // 1 book + 1 chapter + 2 pages + const byTitle = {}; + for (const r of (await pool.query(`SELECT id, title, parent_id FROM pages WHERE space_id=$1`, [spaceId])).rows) byTitle[r.title] = r; + expect(byTitle['Infra Book'].parent_id).toBeNull(); + expect(byTitle['Storage'].parent_id).toBe(byTitle['Infra Book'].id); // chapter under book + expect(byTitle['Overview'].parent_id).toBe(byTitle['Infra Book'].id); // page directly under book (chapter_id 0) + expect(byTitle['Disks'].parent_id).toBe(byTitle['Storage'].id); // page under chapter + const r2 = await importBookstack({ apiUrl: 'http://bs', tokenId: 'i', tokenSecret: 's', spaceId, fetchImpl: fetchMock() }); expect(r2.created).toBe(0); }); });