feat(migrate): BookStack importer
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
26
migrate/sources/bookstack.js
Normal file
26
migrate/sources/bookstack.js
Normal file
@@ -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 <id>:<secret>".
|
||||||
|
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 };
|
||||||
|
}
|
||||||
27
tests/migrate/bookstack.test.js
Normal file
27
tests/migrate/bookstack.test.js
Normal file
@@ -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: `<p>body</p>` });
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user