['message' => 'Unsupported format.']]); exit; } if (!$events) { http_response_code(422); echo json_encode(['error' => ['message' => 'No events provided.']]); exit; } if (!class_exists('ZipArchive')) { http_response_code(500); echo json_encode(['error' => ['message' => 'ZipArchive extension not available.']]); exit; } $text = buildTimelineText($events, $whatFound); $docx = buildTimelineDocx($text); header('Content-Type: application/vnd.openxmlformats-officedocument.wordprocessingml.document'); header('Content-Disposition: attachment; filename="timeline.docx"'); header('Content-Length: ' . strlen($docx)); header('Cache-Control: no-store'); echo $docx; exit; function buildTimelineText(array $events, string $whatFound): string { $lines = []; if ($whatFound !== '') { $lines[] = $whatFound; $lines[] = ''; $lines[] = str_repeat("\u{2500}", 40); $lines[] = ''; } foreach ($events as $ev) { $date = (string)($ev['date'] ?? 'unknown date'); $time = (string)($ev['time'] ?? ''); $actor = (string)($ev['actor'] ?? ''); $event = (string)($ev['event'] ?? ''); $excerpt = (string)($ev['source_excerpt'] ?? ''); $conf = (string)($ev['confidence'] ?? ''); $dateStr = $time !== '' ? "{$date} {$time}" : $date; $header = $actor !== '' ? "[{$dateStr}] {$actor}" : "[{$dateStr}]"; if ($conf !== '' && $conf !== 'high') { $header .= " ({$conf} confidence)"; } $lines[] = $header; if ($event !== '') $lines[] = $event; if ($excerpt !== '') $lines[] = "Source: \u{201C}{$excerpt}\u{201D}"; $lines[] = ''; $lines[] = str_repeat("\u{2500}", 40); $lines[] = ''; } return implode("\n", $lines); } function buildTimelineDocx(string $text): string { $tmp = tempnam(sys_get_temp_dir(), 'dbn_tl_'); @unlink($tmp); $tmp .= '.docx'; $zip = new ZipArchive(); $zip->open($tmp, ZipArchive::CREATE | ZipArchive::OVERWRITE); $zip->addFromString('[Content_Types].xml', tlContentTypesXml()); $zip->addFromString('_rels/.rels', tlRelsXml()); $zip->addFromString('word/document.xml', tlDocumentXml($text)); $zip->addFromString('word/_rels/document.xml.rels', tlWordRelsXml()); $zip->addFromString('docProps/app.xml', tlAppXml()); $zip->addFromString('docProps/core.xml', tlCoreXml()); $zip->close(); $bytes = file_get_contents($tmp); @unlink($tmp); return $bytes; } function tlDocumentXml(string $text): string { $lines = explode("\n", str_replace("\r\n", "\n", str_replace("\r", "\n", $text))); $paras = []; foreach ($lines as $line) { $safe = htmlspecialchars($line, ENT_XML1 | ENT_COMPAT, 'UTF-8'); if ($safe === '') { $paras[] = ''; } elseif (preg_match('/^\[.+?\]/', $line)) { // Event header line — bold $paras[] = '' . '' . $safe . ''; } elseif (str_starts_with($line, str_repeat("\u{2500}", 5))) { // Divider line — light grey $paras[] = '' . '' . $safe . ''; } else { $paras[] = '' . '' . $safe . ''; } } return '' . '' . '' . implode('', $paras) . '' . '' . ''; } function tlContentTypesXml(): string { return '' . '' . '' . '' . '' . '' . '' . ''; } function tlRelsXml(): string { return '' . '' . '' . '' . '' . ''; } function tlWordRelsXml(): string { return '' . ''; } function tlAppXml(): string { return '' . '' . 'DoBetterNorge Timeline' . ''; } function tlCoreXml(): string { $date = date('Y-m-d\TH:i:s\Z'); return '' . '' . 'DoBetterNorge' . '' . $date . '' . ''; }