Files
gupi-osint-board/scripts/exportMystery.ts
T

230 lines
13 KiB
TypeScript
Raw Normal View History

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,
brief: state.brief.body || state.brief.concepts.length
? { body: state.brief.body, concepts: state.brief.concepts.map(concept => ({ label: concept.label, context: concept.context, expectedPartyKind: concept.expectedPartyKind || 'person' })) }
: 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))
}