174 lines
4.9 KiB
JavaScript
174 lines
4.9 KiB
JavaScript
#!/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);
|
|
});
|