#!/usr/bin/env node /** * Migrate Hub User Guide content from content/wiki/hub/ into Outline. * * Reads plain markdown files (no frontmatter), extracts titles from H1 headers, * and creates nested documents matching the directory structure. * * Usage: * OUTLINE_URL=https://wiki.ghostguild.org OUTLINE_API_TOKEN=your-token node migrate-hub.js */ import fs from "fs/promises"; import path from "path"; import { fileURLToPath } from "url"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const OUTLINE_URL = process.env.OUTLINE_URL; const OUTLINE_API_TOKEN = process.env.OUTLINE_API_TOKEN; const CONTENT_DIR = path.resolve(__dirname, "../content/wiki/hub"); const COLLECTION_NAME = "Hub User Guide"; const RATE_LIMIT_MS = 200; if (!OUTLINE_URL || !OUTLINE_API_TOKEN) { console.error( "Error: OUTLINE_URL and OUTLINE_API_TOKEN env vars are required." ); process.exit(1); } async function outlineApi(endpoint, body) { const res = await fetch(`${OUTLINE_URL}/api/${endpoint}`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${OUTLINE_API_TOKEN}`, }, body: JSON.stringify(body), }); if (!res.ok) { const text = await res.text(); throw new Error(`API ${endpoint} failed (${res.status}): ${text}`); } return res.json(); } function delay(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } function extractTitle(content, filename) { const match = content.match(/^#\s+(.+)$/m); if (match) return match[1]; // Fallback: clean up filename return filename .replace(/^\d+-/, "") .replace(/\.md$/, "") .replace(/-/g, " ") .replace(/\b\w/g, (c) => c.toUpperCase()); } function stripH1(content) { // Remove the first H1 line since Outline uses the title field return content.replace(/^#\s+.+\n*/, "").trim(); } // Section display names for subdirectories const SECTION_NAMES = { admin: "Admin Guide", reviewer: "Reviewer Guide", reference: "Reference", }; async function findCollection(name) { const { data } = await outlineApi("collections.list", { limit: 100 }); const existing = data.find( (c) => c.name.toLowerCase() === name.toLowerCase() ); if (!existing) { throw new Error( `Collection "${name}" not found. Available: ${data.map((c) => c.name).join(", ")}` ); } return existing.id; } async function createDoc({ title, text, collectionId, parentDocumentId }) { const body = { title, text, collectionId, publish: true }; if (parentDocumentId) body.parentDocumentId = parentDocumentId; const { data: doc } = await outlineApi("documents.create", body); return doc; } async function importFiles(dir, collectionId, parentDocumentId) { const entries = await fs.readdir(dir, { withFileTypes: true }); // Sort: files first (by name), then directories (by name) const files = entries.filter((e) => e.isFile() && e.name.endsWith(".md")).sort((a, b) => a.name.localeCompare(b.name)); const dirs = entries.filter((e) => e.isDirectory()).sort((a, b) => a.name.localeCompare(b.name)); let success = 0; let failed = 0; // Import markdown files for (const file of files) { const filePath = path.join(dir, file.name); const raw = await fs.readFile(filePath, "utf-8"); const title = extractTitle(raw, file.name); const text = stripH1(raw); try { const doc = await createDoc({ title, text, collectionId, parentDocumentId, }); console.log(` āœ“ ${title} → ${doc.url}`); success++; } catch (err) { console.error(` āœ— ${title}: ${err.message}`); failed++; } await delay(RATE_LIMIT_MS); } // Recurse into subdirectories, creating a parent doc for each for (const sub of dirs) { const sectionName = SECTION_NAMES[sub.name] || sub.name.replace(/\b\w/g, (c) => c.toUpperCase()); console.log(`\nšŸ“‚ ${sectionName}`); try { // Create a parent document for this section const parentDoc = await createDoc({ title: sectionName, text: "", collectionId, parentDocumentId, }); await delay(RATE_LIMIT_MS); const result = await importFiles( path.join(dir, sub.name), collectionId, parentDoc.id ); success += result.success; failed += result.failed; } catch (err) { console.error(` āœ— Failed to create section "${sectionName}": ${err.message}`); failed++; } } return { success, failed }; } async function main() { console.log(`Looking up collection: ${COLLECTION_NAME}\n`); const collectionId = await findCollection(COLLECTION_NAME); console.log(`Found collection: ${collectionId}\n`); const { success, failed } = await importFiles(CONTENT_DIR, collectionId, null); console.log(`\nDone. ${success} imported, ${failed} failed.`); } main().catch((err) => { console.error("Migration failed:", err); process.exit(1); });