Add Coop Foundations Curriculum content and import script

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.
This commit is contained in:
Jennie Robinson Faber 2026-03-09 14:33:06 +00:00
parent dd143b20fc
commit 136ee2442b
21 changed files with 4048 additions and 0 deletions

View file

@ -0,0 +1,297 @@
#!/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);
});