import { randomUUID } from 'node:crypto' import { readFile } from 'node:fs/promises' import path from 'node:path' import { fileURLToPath } from 'node:url' import type { CaseDocument, CaseState, PartyKind, SourceFileType } from '../src/types.js' type MysteryDocument = { key: string title: string fileType: SourceFileType publishedAt: string body?: string[] metadata?: Record asset?: string } type MysteryManifest = { slug: string name: string title: string subtitle: string timelineRange?: { start: string; end: string } brief: { body: string; concepts: { label: string; context: string; expectedPartyKind: PartyKind }[] } documents: MysteryDocument[] folders: { key: string; title: string; content: string; x: number; y: number; width: number; members: string[] }[] } function requireOk(response: Response, action: string) { if (response.ok) return response return response.text().then(body => { throw new Error(`${action} failed (${response.status}): ${body}`) }) } function documentKind(type: SourceFileType) { return type === 'web_capture' ? 'WEB CAPTURE' : type.toUpperCase() } async function uploadAsset(baseUrl: string, levelId: string, manifestDir: string, document: MysteryDocument, authorization?: string) { if (!document.asset) return undefined const assetPath = path.resolve(manifestDir, document.asset) const form = new FormData() form.append('file', new Blob([await readFile(assetPath)]), path.basename(assetPath)) const response = await requireOk(await fetch(`${baseUrl}/api/levels/${levelId}/documents?edit=1`, { method: 'POST', headers: authorization ? { authorization } : undefined, body: form }), `Upload ${document.asset}`) return await response.json() as CaseDocument } export async function importMysteryTemplate(manifestPath: string, baseUrl = 'http://localhost:8787', adminJwt = process.env.OSINT_ADMIN_JWT) { const absoluteManifest = path.resolve(manifestPath) const manifest = JSON.parse(await readFile(absoluteManifest, 'utf8')) as MysteryManifest const manifestDir = path.dirname(absoluteManifest) const authoringId = `${manifest.slug}-authoring-${Date.now()}` const authorization = adminJwt ? `Bearer ${adminJwt}` : undefined const headers = { 'content-type': 'application/json', ...(authorization ? { authorization } : {}) } const createdResponse = await requireOk(await fetch(`${baseUrl}/api/levels`, { method: 'POST', headers, body: JSON.stringify({ id: authoringId, title: manifest.title, subtitle: manifest.subtitle }), }), 'Create authoring level') const state = await createdResponse.json() as CaseState const documents = new Map() for (const source of manifest.documents) { const uploaded = await uploadAsset(baseUrl, state.id, manifestDir, source, authorization) documents.set(source.key, { id: uploaded?.id || randomUUID(), title: source.title, kind: documentKind(source.fileType), date: source.publishedAt.slice(0, 10), publishedAt: source.publishedAt, body: source.body || [], regions: [], assetId: uploaded?.assetId, fileName: uploaded?.fileName, mimeType: uploaded?.mimeType, fileSize: uploaded?.fileSize, fileType: source.fileType, metadata: source.metadata || {}, }) } const folderIds = new Map(manifest.folders.map(folder => [folder.key, randomUUID()])) state.brief = { body: manifest.brief.body, concepts: manifest.brief.concepts.map(concept => ({ id: randomUUID(), ...concept })) } state.timelineRange = manifest.timelineRange state.documents = [...documents.values()] state.evidence = manifest.folders.map(folder => ({ id: folderIds.get(folder.key)!, type: 'folder', title: folder.title, content: folder.content, x: folder.x, y: folder.y, width: folder.width, config: { open: false }, containedDocumentIds: folder.members.map(key => documents.get(key)!.id), })) state.relations = manifest.folders.flatMap((folder, folderIndex) => folder.members.map((key, memberIndex) => { const document = documents.get(key) if (!document) throw new Error(`Folder ${folder.key} refers to unknown document ${key}`) return { id: `contains:${folderIds.get(folder.key)}:${document.id}`, fromWidgetId: folderIds.get(folder.key)!, toWidgetId: document.id, type: 'contains', sortOrder: memberIndex, config: { x: folder.x + 70 + memberIndex * 205, y: folder.y + 230 + folderIndex * 35 }, } })) state.connections = [] state.viewport = { x: 0, y: 28, zoom: 0.7 } await requireOk(await fetch(`${baseUrl}/api/levels/${state.id}?edit=1`, { method: 'PUT', headers, body: JSON.stringify(state), }), 'Save authored mystery') const templateResponse = await requireOk(await fetch(`${baseUrl}/api/levels/${state.id}/templates?edit=1`, { method: 'POST', headers, body: JSON.stringify({ slug: manifest.slug, name: manifest.name }), }), 'Freeze mystery template') const template = await templateResponse.json() as { slug: string; currentVersion: number } const playableId = `${manifest.slug}-case-${Date.now()}` const playableResponse = await requireOk(await fetch(`${baseUrl}/api/templates/${manifest.slug}/levels?edit=1`, { method: 'POST', headers, body: JSON.stringify({ id: playableId, title: manifest.title }), }), 'Instantiate playable mystery') const playable = await playableResponse.json() as CaseState return { manifest, template, authoringLevelId: state.id, playableLevel: playable } } const invokedPath = process.argv[1] ? path.resolve(process.argv[1]) : '' if (invokedPath === fileURLToPath(import.meta.url)) { const manifestPath = process.argv[2] if (!manifestPath) throw new Error('Usage: npm run mystery:import -- ') const result = await importMysteryTemplate(manifestPath, process.env.OSINT_BOARD_URL) console.log(JSON.stringify({ template: `${result.template.slug}@v${result.template.currentVersion}`, authoringLevelId: result.authoringLevelId, playableLevelId: result.playableLevel.id, playUrl: `${process.env.OSINT_BOARD_URL || 'http://localhost:8787'}/?level=${encodeURIComponent(result.playableLevel.id)}`, }, null, 2)) }