9 session pages and 10 PS Guide markdown files for the Baby Ghosts cooperative foundations curriculum. Import script creates documents in Outline wiki with cross-links between paired session/PS Guide pages.
297 lines
9.1 KiB
JavaScript
297 lines
9.1 KiB
JavaScript
#!/usr/bin/env node
|
||
|
||
/**
|
||
* Import Baby Ghosts Coop Foundations Curriculum into Outline wiki.
|
||
*
|
||
* - 9 session pages → top-level in "Cooperative Foundations" collection
|
||
* - 10 PS Guide pages → nested under "Peer Support Playbook" parent doc
|
||
* - Cross-links added between paired session/PS Guide pages
|
||
*
|
||
* Usage:
|
||
* OUTLINE_URL=https://wiki.ghostguild.org OUTLINE_API_TOKEN=<token> node scripts/import-curriculum.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 RATE_LIMIT_MS = 200;
|
||
|
||
const SESSIONS_DIR = path.resolve(__dirname, "../content/curriculum/Sessions");
|
||
const PS_GUIDES_DIR = path.resolve(__dirname, "../content/curriculum/PS Guides");
|
||
|
||
if (!OUTLINE_URL || !OUTLINE_API_TOKEN) {
|
||
console.error("Error: OUTLINE_URL and OUTLINE_API_TOKEN env vars are required.");
|
||
process.exit(1);
|
||
}
|
||
|
||
// --- API helper (same pattern as migrate-content.js) ---
|
||
|
||
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));
|
||
}
|
||
|
||
// --- Session files in order ---
|
||
|
||
const SESSION_FILES = [
|
||
"Session 0 – Kickoff + Onboarding.md",
|
||
"Session 1 – Coop Principles and Power.md",
|
||
"Session 2 – Shared Purpose and Alignment.md",
|
||
"Session 3 – Actionable Values and Impact.md",
|
||
"Session 4 – Decision-Making in Practice.md",
|
||
"Session 5 – Coop Structures and Governance.md",
|
||
"Session 6 – Equitable Economics.md",
|
||
"Session 7 – Conflict Resolution and Collective Care.md",
|
||
"Session 8 – Self-Evaluation and Pathways.md",
|
||
];
|
||
|
||
// --- PS Guide files with explicit titles ---
|
||
|
||
const PS_GUIDE_FILES = [
|
||
{
|
||
file: "pre-program-onboarding-and-prep.md",
|
||
title: "Pre-program: Onboarding and Prep",
|
||
sessionIndex: null, // no cross-link pair
|
||
},
|
||
{
|
||
file: "0-kickoff-onboarding.md",
|
||
title: "PS Guide: Session 0 — Kickoff + Onboarding",
|
||
sessionIndex: 0,
|
||
},
|
||
{
|
||
file: "1-coop-principles-power.md",
|
||
title: "PS Guide: Session 1 — Coop Principles and Power",
|
||
sessionIndex: 1,
|
||
},
|
||
{
|
||
file: "2-shared-purpose-alignment.md",
|
||
title: "PS Guide: Session 2 — Shared Purpose and Alignment",
|
||
sessionIndex: 2,
|
||
},
|
||
{
|
||
file: "3-actionable-values-impact.md",
|
||
title: "PS Guide: Session 3 — Actionable Values and Impact",
|
||
sessionIndex: 3,
|
||
},
|
||
{
|
||
file: "4-decision-making-in-practice.md",
|
||
title: "PS Guide: Session 4 — Decision-Making in Practice",
|
||
sessionIndex: 4,
|
||
},
|
||
{
|
||
file: "5-coop-structures-governance.md",
|
||
title: "PS Guide: Session 5 — Coop Structures and Governance",
|
||
sessionIndex: 5,
|
||
},
|
||
{
|
||
file: "6-equitable-economics.md",
|
||
title: "PS Guide: Session 6 — Equitable Economics",
|
||
sessionIndex: 6,
|
||
},
|
||
{
|
||
file: "7-conflict-resolution-collective-care.md",
|
||
title: "PS Guide: Session 7 — Conflict Resolution and Collective Care",
|
||
sessionIndex: 7,
|
||
},
|
||
{
|
||
file: "8-self-evaluation-pathways.md",
|
||
title: "PS Guide: Session 8 — Self-Evaluation and Pathways",
|
||
sessionIndex: 8,
|
||
},
|
||
];
|
||
|
||
// --- Content helpers ---
|
||
|
||
/** Strip the H1 line from markdown content. */
|
||
function stripH1(text) {
|
||
return text.replace(/^# .+\n+/, "");
|
||
}
|
||
|
||
/**
|
||
* Remove PS reference lines that appear near the top of session pages.
|
||
* Handles both italic and non-italic variants:
|
||
* *Peer Supports: See **PS Guide: Session N** for pre-session tasks.*
|
||
* Peer Supports: See **PS Guide: Session N** for pre-session tasks.
|
||
* Also removes an empty "## Pre-session" heading left behind.
|
||
*/
|
||
function stripPsReference(text) {
|
||
// Remove the PS reference line itself
|
||
let result = text.replace(
|
||
/^\*?Peer Supports: See \*\*PS Guide: Session \d\*\* for pre-session tasks\.\*?\n+/m,
|
||
""
|
||
);
|
||
// Remove empty "## Pre-session" section (heading followed only by --- or whitespace)
|
||
result = result.replace(/^## Pre-session\n+(?=---)/m, "");
|
||
return result;
|
||
}
|
||
|
||
// --- Main ---
|
||
|
||
async function main() {
|
||
// ============================================================
|
||
// Phase 1: Find existing collection and parent document
|
||
// ============================================================
|
||
|
||
console.log("Phase 1: Finding collection and parent document...\n");
|
||
|
||
const { data: collections } = await outlineApi("collections.list", { limit: 100 });
|
||
const collection = collections.find(
|
||
(c) => c.name.toLowerCase() === "cooperative foundations"
|
||
);
|
||
if (!collection) {
|
||
console.error('Error: Collection "Cooperative Foundations" not found.');
|
||
process.exit(1);
|
||
}
|
||
console.log(` Collection: ${collection.name} (${collection.id})`);
|
||
await delay(RATE_LIMIT_MS);
|
||
|
||
const { data: searchResults } = await outlineApi("documents.search", {
|
||
query: "Peer Support Playbook",
|
||
collectionId: collection.id,
|
||
limit: 5,
|
||
});
|
||
const parentDoc = searchResults.find(
|
||
(r) => r.document.title === "Peer Support Playbook"
|
||
);
|
||
if (!parentDoc) {
|
||
console.error('Error: Document "Peer Support Playbook" not found in collection.');
|
||
process.exit(1);
|
||
}
|
||
const parentDocId = parentDoc.document.id;
|
||
console.log(` Parent doc: Peer Support Playbook (${parentDocId})\n`);
|
||
await delay(RATE_LIMIT_MS);
|
||
|
||
// ============================================================
|
||
// Phase 2: Import 9 session pages
|
||
// ============================================================
|
||
|
||
console.log("Phase 2: Importing session pages...\n");
|
||
|
||
const sessions = []; // { id, slug, title }
|
||
|
||
for (const file of SESSION_FILES) {
|
||
const raw = await fs.readFile(path.join(SESSIONS_DIR, file), "utf-8");
|
||
|
||
// Extract H1 as title
|
||
const h1Match = raw.match(/^# (.+)$/m);
|
||
const title = h1Match ? h1Match[1] : path.basename(file, ".md");
|
||
|
||
// Strip H1 and PS reference from body
|
||
let body = stripH1(raw);
|
||
body = stripPsReference(body);
|
||
|
||
const { data: doc } = await outlineApi("documents.create", {
|
||
title,
|
||
text: body.trim(),
|
||
collectionId: collection.id,
|
||
publish: true,
|
||
});
|
||
|
||
sessions.push({ id: doc.id, slug: doc.url.split("/doc/")[1], title });
|
||
console.log(` ✓ ${title}`);
|
||
await delay(RATE_LIMIT_MS);
|
||
}
|
||
|
||
// ============================================================
|
||
// Phase 3: Import 10 PS Guide pages
|
||
// ============================================================
|
||
|
||
console.log("\nPhase 3: Importing PS Guide pages...\n");
|
||
|
||
const psGuides = []; // { id, slug, title, sessionIndex }
|
||
|
||
for (const entry of PS_GUIDE_FILES) {
|
||
const raw = await fs.readFile(path.join(PS_GUIDES_DIR, entry.file), "utf-8");
|
||
|
||
// Strip H1 from body
|
||
const body = stripH1(raw);
|
||
|
||
const { data: doc } = await outlineApi("documents.create", {
|
||
title: entry.title,
|
||
text: body.trim(),
|
||
collectionId: collection.id,
|
||
parentDocumentId: parentDocId,
|
||
publish: true,
|
||
});
|
||
|
||
psGuides.push({
|
||
id: doc.id,
|
||
slug: doc.url.split("/doc/")[1],
|
||
title: entry.title,
|
||
sessionIndex: entry.sessionIndex,
|
||
});
|
||
console.log(` ✓ ${entry.title}`);
|
||
await delay(RATE_LIMIT_MS);
|
||
}
|
||
|
||
// ============================================================
|
||
// Phase 4: Add cross-links
|
||
// ============================================================
|
||
|
||
console.log("\nPhase 4: Adding cross-links...\n");
|
||
|
||
for (const psGuide of psGuides) {
|
||
if (psGuide.sessionIndex === null) continue; // skip pre-program
|
||
|
||
const session = sessions[psGuide.sessionIndex];
|
||
if (!session) {
|
||
console.error(` ✗ No session found for index ${psGuide.sessionIndex}`);
|
||
continue;
|
||
}
|
||
|
||
// Update session page: prepend PS Guide link
|
||
const { data: sessionDoc } = await outlineApi("documents.info", { id: session.id });
|
||
await delay(RATE_LIMIT_MS);
|
||
|
||
const sessionCrossLink = `> **Peer Supports:** See [${psGuide.title}](/doc/${psGuide.slug}) for your role during session and this week's studio support meeting.\n\n`;
|
||
|
||
await outlineApi("documents.update", {
|
||
id: session.id,
|
||
text: sessionCrossLink + sessionDoc.text,
|
||
});
|
||
console.log(` ✓ ${session.title} → ${psGuide.title}`);
|
||
await delay(RATE_LIMIT_MS);
|
||
|
||
// Update PS Guide page: prepend session link
|
||
const { data: psDoc } = await outlineApi("documents.info", { id: psGuide.id });
|
||
await delay(RATE_LIMIT_MS);
|
||
|
||
const psCrossLink = `> **Session content:** See [${session.title}](/doc/${session.slug}) for the full curriculum.\n\n`;
|
||
|
||
await outlineApi("documents.update", {
|
||
id: psGuide.id,
|
||
text: psCrossLink + psDoc.text,
|
||
});
|
||
console.log(` ✓ ${psGuide.title} → ${session.title}`);
|
||
await delay(RATE_LIMIT_MS);
|
||
}
|
||
|
||
console.log(`\nDone. ${sessions.length} sessions + ${psGuides.length} PS Guides imported.`);
|
||
}
|
||
|
||
main().catch((err) => {
|
||
console.error("Import failed:", err);
|
||
process.exit(1);
|
||
});
|