import { mkdir, writeFile } from 'node:fs/promises' import path from 'node:path' import { fileURLToPath } from 'node:url' import type { CaseDocument, CaseState, ClaimExhibit, FolderExhibit, NoteExhibit } from '../src/types.js' import type { TemplateBoardExport } from '../server/levelRepository.js' import type { NpcDto } from '../server/narrativeRepository.js' import type { StoryGraphDto, StoryNodeDto, UtteranceDto } from '../server/storyGraphRepository.js' const DEV_URL = 'http://localhost:8787' const PROD_URL = 'https://gupi.glitch.university' type MysterySummary = { id: string; slug: string; title: string; nodes: number } function slugify(input: string, fallback: string) { const value = (input || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') return value || fallback } // Assign a unique key per id, deriving a readable slug and de-duplicating collisions. function uniqueKeyer() { const used = new Set() return (base: string, fallback: string) => { const root = slugify(base, fallback) let key = root for (let n = 2; used.has(key); n++) key = `${root}-${n}` used.add(key) return key } } // Drop undefined and empty arrays so the emitted manifest reads like a hand-written one // (the importer tolerates sparse levels, so an absent key means "none"). function prune>(object: T): T { const out: Record = {} for (const [key, value] of Object.entries(object)) { if (value === undefined) continue if (Array.isArray(value) && value.length === 0) continue out[key] = value } return out as T } async function getJson(baseUrl: string, route: string, authorization?: string): Promise { const response = await fetch(`${baseUrl}${route}`, { headers: authorization ? { authorization } : undefined }) if (!response.ok) throw new Error(`GET ${route} failed (${response.status}): ${await response.text()}`) return await response.json() as T } // Local dev has no login page; mint an admin cookie via the dev-only endpoint and reuse // its token as a bearer. Production always requires an explicit OSINT_ADMIN_JWT. async function resolveAdminJwt(baseUrl: string, isProd: boolean): Promise { if (process.env.OSINT_ADMIN_JWT) return process.env.OSINT_ADMIN_JWT if (isProd) throw new Error('OSINT_ADMIN_JWT is required for --prod (mint one inside the gnommo-osint-board container)') const response = await fetch(`${baseUrl}/api/dev/admin-session`, { redirect: 'manual' }) const cookie = response.headers.get('set-cookie') const token = cookie?.match(/auth_token=([^;]+)/)?.[1] if (!token) throw new Error(`Could not obtain a dev admin token from ${baseUrl}/api/dev/admin-session`) return token } // One board (frozen template version) → the importer's level shape. function serializeLevel(board: TemplateBoardExport, assetFilenames: Map) { const state: CaseState = board.state const documents = state.exhibits.filter((exhibit): exhibit is CaseDocument => exhibit.type === 'document') const folders = state.exhibits.filter((exhibit): exhibit is FolderExhibit => exhibit.type === 'folder') const claims = state.exhibits.filter((exhibit): exhibit is ClaimExhibit => exhibit.type === 'claim') const notes = state.exhibits.filter((exhibit): exhibit is NoteExhibit => exhibit.type === 'note') const documentKey = uniqueKeyer() const documentKeyById = new Map(documents.map((document, index) => [document.id, documentKey(document.title, `doc-${index + 1}`)])) const folderKey = uniqueKeyer() const timeline = state.views.find(view => view.type === 'timeline' && view.rangeMode === 'fixed') return prune({ slug: board.slug, name: board.name, title: state.title, subtitle: state.subtitle || undefined, timelineRange: timeline?.range, brief: state.brief.body || state.brief.concepts.length || state.brief.checklist?.length ? prune({ body: state.brief.body, concepts: state.brief.concepts.map(concept => ({ label: concept.label, context: concept.context, expectedPartyKind: concept.expectedPartyKind || 'person' })), checklist: state.brief.checklist }) : undefined, documents: documents.map(document => prune({ key: documentKeyById.get(document.id)!, title: document.title, fileType: document.fileType, captureKind: document.captureKind, publishedAt: document.publishedAt || '', body: document.body, metadata: Object.keys(document.metadata || {}).length ? document.metadata : undefined, requiredFlags: document.requiredFlags, asset: document.assetId ? `assets/${assetFilenames.get(document.assetId)}` : undefined, })), folders: folders.map(folder => ({ key: folderKey(folder.title, 'folder'), title: folder.title, content: folder.content, x: folder.x, y: folder.y, width: folder.width, members: state.relations.filter(relation => relation.type === 'contains' && relation.fromExhibitId === folder.id) .sort((a, b) => a.sortOrder - b.sortOrder).map(relation => documentKeyById.get(relation.toExhibitId)!).filter(Boolean), })), claims: claims.map(claim => ({ key: slugify(claim.statement, `claim`), statement: claim.statement, x: claim.x, y: claim.y, width: claim.width, height: claim.height })), notes: notes.map(note => prune({ title: note.title || undefined, content: note.content, presentation: note.presentation, x: note.x, y: note.y, width: note.width, height: note.height })), report: state.report ? { title: state.report.title, requiredForCompletion: state.report.requiredForCompletion } : undefined, goals: state.goals.map(goal => prune({ key: goal.key, title: goal.title, instructions: goal.instructions, completionMessage: goal.completionMessage, enabled: goal.enabled, requiredFlags: goal.requiredFlags })), evidenceMatchRules: board.matchRules.map(rule => prune({ name: rule.name, sourceLabel: rule.sourceLabel, sourceUri: rule.sourceUri, flagKey: rule.flagKey, minimumAnchorMatches: rule.minimumAnchorMatches, enabled: rule.enabled, anchors: rule.anchors.map(anchor => ({ phrase: anchor.phrase, minimumSimilarity: anchor.minimumSimilarity })), })), evidenceSemanticRules: board.semanticRules.map(rule => prune({ goalKey: rule.goalKey, name: rule.name, targetSubject: rule.targetSubject, relatedSubject: rule.relatedSubject, assertion: rule.assertion, successFlagKey: rule.successFlagKey, relatedFlagKey: rule.relatedFlagKey, minimumConfidence: rule.minimumConfidence, evaluatorVersion: rule.evaluatorVersion, enabled: rule.enabled, })), }) } // A node's utterances → the importer's branching form (explicit key + parent + terminal), // so the dialogue tree and its flag gates round-trip losslessly. function serializeUtterances(utterances: UtteranceDto[], node: StoryNodeDto, npcKeyById: Map) { const ordered = [...utterances].sort((a, b) => a.sortOrder - b.sortOrder) const keyById = new Map(ordered.map((utterance, index) => [utterance.id, `u${index}`])) const terminalKeyById = new Map(node.terminals.map(terminal => [terminal.id, terminal.terminalKey])) return ordered.map(utterance => prune({ key: keyById.get(utterance.id)!, parent: utterance.parentUtteranceId ? keyById.get(utterance.parentUtteranceId) : undefined, terminal: utterance.terminalId ? terminalKeyById.get(utterance.terminalId) : undefined, npc: utterance.npcId ? npcKeyById.get(utterance.npcId) : undefined, pose: utterance.poseKey || undefined, text: utterance.text, utterer: utterance.utterer, awardsFlag: utterance.awardsFlag || undefined, requiresFlag: utterance.requiresFlag || undefined, })) } export async function exportMystery(slug: string, options: { baseUrl: string; adminJwt: string; outDir: string }) { const { baseUrl, adminJwt, outDir } = options const authorization = `Bearer ${adminJwt}` const mysteries = await getJson(baseUrl, '/api/admin/mysteries', authorization) const mystery = mysteries.find(candidate => candidate.slug === slug) if (!mystery) throw new Error(`Mystery '${slug}' not found on ${baseUrl}`) const [graph, npcs] = await Promise.all([ getJson(baseUrl, `/api/admin/mysteries/${mystery.id}/graph`, authorization), getJson(baseUrl, '/api/admin/npcs', authorization), ]) const npcKeyById = new Map(npcs.map(npc => [npc.id, npc.key])) // Stable, readable key per node (graph nodes arrive in creation order). const nodeKey = uniqueKeyer() const nodeKeyById = new Map(graph.nodes.map(node => [node.id, nodeKey(node.label || node.nodeType, node.nodeType)])) // Fetch each distinct level template version once, in first-appearance order. const levelByVersion = new Map() for (const node of graph.nodes) { if (node.nodeType !== 'level' || !node.levelTemplateVersionId || levelByVersion.has(node.levelTemplateVersionId)) continue levelByVersion.set(node.levelTemplateVersionId, await getJson(baseUrl, `/api/admin/level-templates/${node.levelTemplateVersionId}/export`, authorization)) } // Collect document assets across all levels; filenames come from the stored upload name. const assetFilenames = new Map() const assetFilenameKeyer = uniqueKeyer() for (const board of levelByVersion.values()) { for (const exhibit of board.state.exhibits) { if (exhibit.type !== 'document' || !exhibit.assetId || assetFilenames.has(exhibit.assetId)) continue const suggested = exhibit.fileName || `${exhibit.assetId}.bin` const extension = suggested.includes('.') ? suggested.slice(suggested.lastIndexOf('.')) : '' const base = suggested.slice(0, suggested.length - extension.length) || exhibit.assetId assetFilenames.set(exhibit.assetId, `${assetFilenameKeyer(base, exhibit.assetId)}${extension}`) } } const levels = [...levelByVersion.values()].map(board => serializeLevel(board, assetFilenames)) // Serialize the graph, gathering the NPCs the flow actually references as we go. const referencedNpcIds = new Set() const nodes: Record[] = [] for (const node of graph.nodes) { const terminals = node.terminals.map(terminal => { if (terminal.npcId) referencedNpcIds.add(terminal.npcId) return prune({ key: terminal.terminalKey, label: terminal.label, to: terminal.toNodeId ? nodeKeyById.get(terminal.toNodeId) : undefined, npc: terminal.npcId ? npcKeyById.get(terminal.npcId) : undefined, }) }) let utterances: Record[] | undefined if (node.hasUtterances) { const raw = await getJson(baseUrl, `/api/admin/story-nodes/${node.id}/utterances`, authorization) for (const utterance of raw) if (utterance.npcId) referencedNpcIds.add(utterance.npcId) utterances = serializeUtterances(raw, node, npcKeyById) } nodes.push(prune({ key: nodeKeyById.get(node.id)!, type: node.nodeType, label: node.label, x: node.xpos, y: node.ypos, componentKey: node.componentKey || undefined, awardsFlag: node.awardsFlag || undefined, templateSlug: node.levelTemplateVersionId ? levelByVersion.get(node.levelTemplateVersionId)?.slug : undefined, terminals, utterances, })) } const cast = npcs.filter(npc => referencedNpcIds.has(npc.id)).map(npc => prune({ key: npc.key, name: npc.name, role: npc.role || undefined, defaultPose: npc.defaultPose || undefined, phoneNumber: npc.phoneNumber || undefined, email: npc.email || undefined, poses: npc.poses.map(pose => ({ poseKey: pose.poseKey, assetId: pose.assetId })), })) const entry = graph.entryNodeId ? nodeKeyById.get(graph.entryNodeId) : undefined if (!entry) throw new Error(`Mystery '${slug}' has no entry node; author its graph entrypoint before exporting`) const outAbs = path.resolve(outDir) await mkdir(path.join(outAbs, 'assets'), { recursive: true }) for (const [assetId, filename] of assetFilenames) { const response = await fetch(`${baseUrl}/api/assets/${assetId}`, { headers: { authorization } }) if (!response.ok) throw new Error(`Download asset ${assetId} failed (${response.status})`) await writeFile(path.join(outAbs, 'assets', filename), Buffer.from(await response.arrayBuffer())) } const file = { slug: mystery.slug, title: mystery.title, levels, narrative: { cast, graph: { entry, nodes } } } await writeFile(path.join(outAbs, 'mystery.json'), JSON.stringify(file, null, 2) + '\n') return { slug: mystery.slug, outDir: outAbs, levels: levels.length, nodes: nodes.length, cast: cast.length, assets: assetFilenames.size } } const invokedPath = process.argv[1] ? path.resolve(process.argv[1]) : '' if (invokedPath === fileURLToPath(import.meta.url)) { const args = process.argv.slice(2) let isProd = false let outDir: string | undefined const positionals: string[] = [] for (let i = 0; i < args.length; i++) { if (args[i] === '--prod') isProd = true else if (args[i] === '--out') outDir = args[++i] else positionals.push(args[i]) } const slug = positionals[0] if (!slug) throw new Error('Usage: npm run mystery:pull -- [--prod] [--out ]') const baseUrl = isProd ? PROD_URL : (process.env.OSINT_BOARD_URL || DEV_URL) const adminJwt = await resolveAdminJwt(baseUrl, isProd) const result = await exportMystery(slug, { baseUrl, adminJwt, outDir: outDir || path.join('mysteries', slug) }) console.log(JSON.stringify({ ...result, source: baseUrl }, null, 2)) }