wiki_ghostguild/scripts/export-content.js
Jennie Robinson Faber 888fa2f6b5 Fix two cron jobs that surfaced once the container could actually run them
With yesterday's cron infrastructure fix in place, both daily jobs got
to actually execute today and revealed bugs that had been hiding behind
the silent failures.

1. Wiki export crashed on docs whose body starts with `---` (markdown
   horizontal rule). matter.stringify(str, data) re-parses str as if
   it might already contain frontmatter, so a leading `---` makes
   gray-matter try to YAML-parse the body and choke on the first
   `Title: subtitle` colon. Pass {content: str} instead — the parser
   only runs on bare strings, so an object skips the re-parse path.

2. outline-backup.sh referenced docker container names `outline-postgres`
   and `outline`, but DokPloy names containers `${project}-${service}-1`,
   so the backup got `Error response from daemon: No such container`.
   Derive names from $APP_NAME (set to the compose project name) with
   POSTGRES_CONTAINER / OUTLINE_CONTAINER overrides for portability.
2026-04-08 11:57:02 +01:00

197 lines
5.1 KiB
JavaScript
Executable file

#!/usr/bin/env node
/**
* Export all Outline wiki content as markdown files to content/wiki/.
*
* Usage:
* OUTLINE_URL=https://wiki.ghostguild.org OUTLINE_API_TOKEN=your-token node scripts/export-content.js
*
* Cron (daily at 4 AM UTC):
* 0 4 * * * /path/to/wiki-ghostguild/scripts/export-content-cron.sh >> /var/log/wiki-export.log 2>&1
*/
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 || process.env.URL;
const OUTLINE_API_TOKEN = process.env.OUTLINE_API_TOKEN;
const OUTPUT_DIR = path.resolve(__dirname, "../content/wiki");
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 slugify(text) {
return text
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
}
async function fetchAllCollections() {
const { data } = await outlineApi("collections.list", { limit: 100 });
return data;
}
async function fetchAllDocuments(collectionId) {
const docs = [];
let offset = 0;
const limit = 100;
while (true) {
await delay(RATE_LIMIT_MS);
const result = await outlineApi("documents.list", {
collectionId,
limit,
offset,
});
docs.push(...result.data);
if (!result.pagination || result.data.length < limit) break;
offset += limit;
}
return docs;
}
function buildPath(doc, docMap, collectionName) {
const parts = [doc.title];
let current = doc;
while (current.parentDocumentId) {
const parent = docMap.get(current.parentDocumentId);
if (!parent) break;
parts.unshift(parent.title);
current = parent;
}
return `${collectionName}/${parts.join("/")}`;
}
function getParentTitle(doc, docMap) {
if (!doc.parentDocumentId) return null;
const parent = docMap.get(doc.parentDocumentId);
return parent ? parent.title : null;
}
async function main() {
// Ensure output directory exists
await fs.mkdir(OUTPUT_DIR, { recursive: true });
console.log("Fetching collections...");
const collections = await fetchAllCollections();
console.log(`Found ${collections.length} collections.\n`);
// Build a map of collection ID → name
const collectionMap = new Map();
for (const col of collections) {
collectionMap.set(col.id, col.name);
}
// Fetch all documents across all collections
const allDocs = [];
for (const col of collections) {
console.log(`Fetching docs from "${col.name}"...`);
const docs = await fetchAllDocuments(col.id);
console.log(` ${docs.length} documents`);
allDocs.push(...docs);
}
// Build lookup map for parent chain resolution
const docMap = new Map();
for (const doc of allDocs) {
docMap.set(doc.id, doc);
}
// Write all documents
const writtenFiles = new Set();
let count = 0;
for (const doc of allDocs) {
const collectionName = collectionMap.get(doc.collectionId) || "unknown";
const collectionSlug = slugify(collectionName);
const docSlug = slugify(doc.title);
const filename = `${collectionSlug}--${docSlug}.md`;
const docPath = buildPath(doc, docMap, collectionName);
const parentTitle = getParentTitle(doc, docMap);
const frontmatter = {
title: doc.title,
collection: collectionName,
path: docPath,
parentDocument: parentTitle,
outlineId: doc.id,
createdBy: doc.createdBy?.email || doc.createdBy?.name || null,
};
// Pass an object (not a bare string) so gray-matter doesn't run the body
// through its frontmatter parser. Otherwise an Outline doc whose body
// starts with `---` (markdown horizontal rule) gets misread as having
// YAML frontmatter and crashes the export.
const content = matter.stringify({ content: doc.text || "" }, frontmatter);
const filePath = path.join(OUTPUT_DIR, filename);
await fs.writeFile(filePath, content, "utf-8");
writtenFiles.add(filename);
count++;
}
console.log(`\nWrote ${count} documents to content/wiki/`);
// Clean up orphaned files
const existing = (await fs.readdir(OUTPUT_DIR)).filter((f) =>
f.endsWith(".md")
);
let removed = 0;
for (const file of existing) {
if (!writtenFiles.has(file)) {
await fs.unlink(path.join(OUTPUT_DIR, file));
console.log(` Removed orphan: ${file}`);
removed++;
}
}
if (removed > 0) {
console.log(`Removed ${removed} orphaned files.`);
}
console.log("Export complete.");
}
main().catch((err) => {
console.error("Export failed:", err);
process.exit(1);
});