2026-08-23 21:37:06 +02:00
|
|
|
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<string>()
|
|
|
|
|
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<T extends Record<string, unknown>>(object: T): T {
|
|
|
|
|
const out: Record<string, unknown> = {}
|
|
|
|
|
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<T>(baseUrl: string, route: string, authorization?: string): Promise<T> {
|
|
|
|
|
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<string> {
|
|
|
|
|
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<string, string>) {
|
|
|
|
|
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,
|
2026-08-23 22:50:50 +02:00
|
|
|
brief: state.brief.body || state.brief.concepts.length || state.brief.checklist?.length
|
2026-08-23 23:06:20 +02:00
|
|
|
? 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?.map(item => item.text) })
|
2026-08-23 21:37:06 +02:00
|
|
|
: 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<string, string>) {
|
|
|
|
|
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<MysterySummary[]>(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<StoryGraphDto>(baseUrl, `/api/admin/mysteries/${mystery.id}/graph`, authorization),
|
|
|
|
|
getJson<NpcDto[]>(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<string, TemplateBoardExport>()
|
|
|
|
|
for (const node of graph.nodes) {
|
|
|
|
|
if (node.nodeType !== 'level' || !node.levelTemplateVersionId || levelByVersion.has(node.levelTemplateVersionId)) continue
|
|
|
|
|
levelByVersion.set(node.levelTemplateVersionId, await getJson<TemplateBoardExport>(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<string, string>()
|
|
|
|
|
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<string>()
|
|
|
|
|
const nodes: Record<string, unknown>[] = []
|
|
|
|
|
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<string, unknown>[] | undefined
|
|
|
|
|
if (node.hasUtterances) {
|
|
|
|
|
const raw = await getJson<UtteranceDto[]>(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 -- <mystery-slug> [--prod] [--out <dir>]')
|
|
|
|
|
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))
|
|
|
|
|
}
|