From 718f92676d39952381f1ee3c4cb6c0ec0ed2e174 Mon Sep 17 00:00:00 2001 From: root Date: Thu, 4 Jun 2026 22:21:03 +1000 Subject: [PATCH] feat(migrate): BookStack importer Co-Authored-By: Claude Opus 4.8 --- migrate/sources/bookstack.js | 26 ++++++++++++++++++++++++++ tests/migrate/bookstack.test.js | 27 +++++++++++++++++++++++++++ 2 files changed, 53 insertions(+) create mode 100644 migrate/sources/bookstack.js create mode 100644 tests/migrate/bookstack.test.js diff --git a/migrate/sources/bookstack.js b/migrate/sources/bookstack.js new file mode 100644 index 0000000..50eb59a --- /dev/null +++ b/migrate/sources/bookstack.js @@ -0,0 +1,26 @@ +import * as pages from '../../lib/db/repos/pages.js'; +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 :". +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(); + let created = 0; + for (const item of 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 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); + 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 new file mode 100644 index 0000000..c0cca94 --- /dev/null +++ b/tests/migrate/bookstack.test.js @@ -0,0 +1,27 @@ +import { describe, it, expect, beforeAll, vi } from 'vitest'; +import { pool } from '../../lib/db/pool.js'; +import { resetDb } from '../helpers/db.js'; +import { migrateUp } from '../../lib/db/migrate.js'; +import { ensureSpace } from '../../migrate/spaces.js'; +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 }); + expect(r2.created).toBe(0); + }); +});