#!/usr/bin/env node /** * Migrate markdown articles from content/articles/ into Outline wiki. * * Usage: * OUTLINE_URL=http://localhost:3100 OUTLINE_API_TOKEN=your-token node migrate-content.js * * Requires: npm install (in this directory) to get gray-matter. */ import fs from "fs/promises"; import path from "path"; import { fileURLToPath } from "url"; import matter from "gray-matter"; 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/articles"); 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)); } async function getOrCreateCollection(name) { // Search existing collections const { data } = await outlineApi("collections.list", { limit: 100 }); const existing = data.find( (c) => c.name.toLowerCase() === name.toLowerCase() ); if (existing) return existing.id; // Create new collection await delay(RATE_LIMIT_MS); const { data: created } = await outlineApi("collections.create", { name }); console.log(` Created collection: ${name}`); return created.id; } async function main() { const files = (await fs.readdir(CONTENT_DIR)).filter((f) => f.endsWith(".md")); console.log(`Found ${files.length} markdown files to migrate.\n`); // Build a map of category → collection ID const collectionIds = new Map(); let success = 0; let failed = 0; for (const file of files) { const filePath = path.join(CONTENT_DIR, file); const raw = await fs.readFile(filePath, "utf-8"); const { data: frontmatter, content } = matter(raw); // Determine title: frontmatter title, or first H1, or filename let title = frontmatter.title || content.match(/^#\s+(.+)$/m)?.[1] || path.basename(file, ".md").replace(/-/g, " "); // Determine collection from category const category = frontmatter.category || "Uncategorized"; const categoryName = category .replace(/-/g, " ") .replace(/\b\w/g, (c) => c.toUpperCase()); if (!collectionIds.has(categoryName)) { try { const id = await getOrCreateCollection(categoryName); collectionIds.set(categoryName, id); await delay(RATE_LIMIT_MS); } catch (err) { console.error(` Failed to get/create collection "${categoryName}": ${err.message}`); failed++; continue; } } const collectionId = collectionIds.get(categoryName); try { const { data: doc } = await outlineApi("documents.create", { title, text: content.trim(), collectionId, publish: true, }); console.log(` ✓ ${title} → ${doc.url}`); success++; } catch (err) { console.error(` ✗ ${title}: ${err.message}`); failed++; } await delay(RATE_LIMIT_MS); } console.log(`\nDone. ${success} migrated, ${failed} failed.`); } main().catch((err) => { console.error("Migration failed:", err); process.exit(1); });