/* ═══════════════════════════════════════════════════════ PROPAGANDA — Declassified Document Archive SPA Engine with hash routing & PDF.js viewer ═══════════════════════════════════════════════════════ */ (function () { 'use strict'; const ROOT = document.getElementById('propagandaRoot'); const PDF_BASE = '/propaganda/docs'; let DATA = null; // ─── Data Loading ──────────────────────────────────── async function loadData() { try { const r = await fetch('/api/propaganda'); if (!r.ok) throw new Error(`HTTP ${r.status}`); DATA = await r.json(); } catch (e) { console.error('PROPAGANDA: data load failed', e); ROOT.innerHTML = `
⚠️
DATA FEED OFFLINE
Failed to load archive index. ${e.message}
`; } } // ─── Helpers ────────────────────────────────────────── function countDocuments(obj) { if (!obj) return 0; if (Array.isArray(obj.documents)) return obj.documents.length; if (Array.isArray(obj.collections)) { return obj.collections.reduce((s, c) => s + (c.documents ? c.documents.length : 0), 0); } if (Array.isArray(obj.countries)) { return obj.countries.reduce((s, cn) => s + countDocuments(cn), 0); } return 0; } function countCollections(obj) { if (!obj) return 0; if (Array.isArray(obj.collections)) return obj.collections.length; if (Array.isArray(obj.countries)) { return obj.countries.reduce((s, cn) => s + (cn.collections ? cn.collections.length : 0), 0); } return 0; } function countCountries(cat) { return cat.countries ? cat.countries.length : 0; } function totalStats() { if (!DATA || !DATA.categories) return { cats: 0, countries: 0, collections: 0, docs: 0 }; let countries = new Set(); let collections = 0; let docs = 0; DATA.categories.forEach(cat => { (cat.countries || []).forEach(cn => { countries.add(cn.code); (cn.collections || []).forEach(col => { collections++; docs += (col.documents || []).length; }); }); }); return { cats: DATA.categories.length, countries: countries.size, collections, docs }; } function findCategory(id) { return DATA.categories.find(c => c.id === id); } function findCountry(cat, code) { return (cat.countries || []).find(c => c.code === code); } function findCollection(country, colId) { return (country.collections || []).find(c => c.id === colId); } function findDocument(collection, docId) { return (collection.documents || []).find(d => d.id === docId); } function esc(s) { const d = document.createElement('div'); d.textContent = s || ''; return d.innerHTML; } // ─── Breadcrumb Builder ─────────────────────────────── function breadcrumb(parts) { // parts: [{label, hash?}, ...] let html = '
'; parts.forEach((p, i) => { if (i > 0) html += '/'; if (p.hash) { html += `${esc(p.label)}`; } else { html += `${esc(p.label)}`; } }); html += '
'; return html; } // ─── View: Categories (Home) ────────────────────────── function renderCategories() { const stats = totalStats(); let html = ''; html += `
DECLASSIFIED // DOCUMENT ARCHIVE // ACCESS GRANTED
`; html += `
CATEGORIES${stats.cats}
COUNTRIES${stats.countries}
COLLECTIONS${stats.collections}
DOCUMENTS${stats.docs}
`; html += breadcrumb([{ label: 'PROPAGANDA' }]); html += '
SELECT CATEGORY
'; html += '
'; DATA.categories.forEach(cat => { const nCountries = countCountries(cat); const nDocs = countDocuments(cat); const nCol = countCollections(cat); html += `
${esc(cat.name)}
${cat.icon || '📁'}
${esc(cat.description)}
${nCountries} COUNTR${nCountries === 1 ? 'Y' : 'IES'} · ${nCol} COLLECTION${nCol === 1 ? '' : 'S'} ${nDocs} DOC${nDocs === 1 ? '' : 'S'}
`; }); html += '
'; ROOT.innerHTML = html; } // ─── View: Category (Country Selector + Collections) ── function renderCategory(catId, activeCountry) { const cat = findCategory(catId); if (!cat) return renderNotFound('Category not found'); let html = ''; html += `
DECLASSIFIED // ${esc(cat.name)} // ACCESS GRANTED
`; html += breadcrumb([ { label: 'PROPAGANDA', hash: '' }, { label: cat.name } ]); // Country selector if (cat.countries && cat.countries.length > 0) { html += '
SELECT COUNTRY
'; html += '
'; cat.countries.forEach(cn => { const nCol = cn.collections ? cn.collections.length : 0; const isActive = activeCountry === cn.code; html += `
${cn.flag || ''} ${esc(cn.name)} [${nCol}]
`; }); html += '
'; // If a country is selected, show its collections if (activeCountry) { const country = findCountry(cat, activeCountry); if (country) { html += renderCountryCollections(cat, country); } } else { // Show all collections from all countries html += '
ALL COLLECTIONS
'; let hasCols = false; cat.countries.forEach(cn => { if (cn.collections && cn.collections.length > 0) { hasCols = true; } }); if (hasCols) { html += '
'; cat.countries.forEach(cn => { (cn.collections || []).forEach(col => { html += renderCollectionCard(catId, cn.code, col, cn.flag); }); }); html += '
'; } else { html += renderEmpty('NO COLLECTIONS YET', 'Collections will appear here once documents are added to this category.'); } } } else { html += renderEmpty('NO COUNTRIES REGISTERED', 'No country data available for this category yet.'); } ROOT.innerHTML = html; } function renderCountryCollections(cat, country) { let html = ''; html += `
${country.flag || ''} ${esc(country.name)} — COLLECTIONS
`; if (country.collections && country.collections.length > 0) { html += '
'; country.collections.forEach(col => { html += renderCollectionCard(cat.id, country.code, col); }); html += '
'; } else { html += renderEmpty('NO COLLECTIONS', 'No document collections available for this country yet.'); } return html; } function renderCollectionCard(catId, countryCode, col, flag) { const nDocs = col.documents ? col.documents.length : 0; return `
${flag ? flag + ' ' : ''}${esc(col.name)}
${esc(col.year)}
${esc(col.description)}
`; } // ─── View: Collection (Documents) ───────────────────── function renderCollection(catId, countryCode, colId) { const cat = findCategory(catId); if (!cat) return renderNotFound('Category not found'); const country = findCountry(cat, countryCode); if (!country) return renderNotFound('Country not found'); const col = findCollection(country, colId); if (!col) return renderNotFound('Collection not found'); let html = ''; html += `
DECLASSIFIED // ${esc(col.name).toUpperCase()} // ACCESS GRANTED
`; html += breadcrumb([ { label: 'PROPAGANDA', hash: '' }, { label: cat.name, hash: `category/${catId}` }, { label: `${country.flag || ''} ${country.name}`, hash: `country/${catId}/${countryCode}` }, { label: col.name } ]); // Detail header html += `
${esc(col.name)}
${esc(col.year)}
${esc(col.description)}
SOURCE: ${esc(col.source)}
`; // Documents if (col.documents && col.documents.length > 0) { html += `
DOCUMENTS [${col.documents.length}]
`; html += '
'; col.documents.forEach(doc => { html += `
📄
${esc(doc.title)}
${esc(doc.description)}
PAGES: ${doc.pages || '?'} RELEASED: ${esc(doc.date_released || 'UNKNOWN')} ${esc(doc.filename)}
VIEW
`; }); html += '
'; } else { html += renderEmpty('NO DOCUMENTS UPLOADED YET', 'This collection has been catalogued but documents have not yet been uploaded to the archive.'); } ROOT.innerHTML = html; } // ─── View: Document (PDF Viewer) ────────────────────── function renderDocument(catId, countryCode, colId, docId) { const cat = findCategory(catId); if (!cat) return renderNotFound('Category not found'); const country = findCountry(cat, countryCode); if (!country) return renderNotFound('Country not found'); const col = findCollection(country, colId); if (!col) return renderNotFound('Collection not found'); const doc = findDocument(col, docId); if (!doc) return renderNotFound('Document not found'); const pdfUrl = `${PDF_BASE}/${catId}/${colId}/${doc.filename}`; let html = ''; html += `
DECLASSIFIED // ${esc(doc.title).toUpperCase()}
`; html += breadcrumb([ { label: 'PROPAGANDA', hash: '' }, { label: cat.name, hash: `category/${catId}` }, { label: `${country.flag || ''} ${country.name}`, hash: `country/${catId}/${countryCode}` }, { label: col.name, hash: `collection/${catId}/${countryCode}/${colId}` }, { label: doc.title } ]); // Viewer html += `
${esc(doc.title)}
— / — ⬇ DOWNLOAD
DECRYPTING DOCUMENT...
`; // Doc info html += `
${esc(doc.title)}
${esc(doc.description)}
PAGES: ${doc.pages || '?'} RELEASED: ${esc(doc.date_released || 'UNKNOWN')} FILE: ${esc(doc.filename)}
`; ROOT.innerHTML = html; // Init PDF viewer initPdfViewer(pdfUrl); } // ─── PDF.js Viewer ─────────────────────────────────── let pdfDoc = null; let pdfPage = 1; let pdfScale = 1.5; let pdfRendering = false; let pdfPending = null; let pdfAllText = []; let pdfSearchActive = false; let pdfSearchQuery = ''; let pdfSearchCaseSensitive = false; let pdfSearchMatches = []; let pdfSearchCurrentIdx = -1; async function initPdfViewer(url) { const canvasWrap = document.getElementById('pdfCanvasWrap'); const loadingEl = document.getElementById('pdfLoading'); const pageInfo = document.getElementById('pdfPageInfo'); // Check if pdf.js is loaded if (typeof pdfjsLib === 'undefined') { canvasWrap.innerHTML = `
⚠️
PDF.js library not loaded
⬇ DOWNLOAD PDF DIRECTLY
`; return; } try { pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js'; const loadingTask = pdfjsLib.getDocument(url); pdfDoc = await loadingTask.promise; pdfPage = 1; loadingEl.remove(); // Create page container with canvas const pageContainer = document.createElement('div'); pageContainer.id = 'pdfPageContainer'; pageContainer.className = 'prop-page-container'; const canvas = document.createElement('canvas'); canvas.id = 'pdfCanvas'; pageContainer.appendChild(canvas); canvasWrap.appendChild(pageContainer); updatePageInfo(); renderPdfPage(); // Controls document.getElementById('pdfPrev').addEventListener('click', () => { if (pdfPage <= 1) return; pdfPage--; queueRender(); }); document.getElementById('pdfNext').addEventListener('click', () => { if (pdfPage >= pdfDoc.numPages) return; pdfPage++; queueRender(); }); document.getElementById('pdfZoomIn').addEventListener('click', () => { pdfScale = Math.min(pdfScale + 0.25, 4); queueRender(); }); document.getElementById('pdfZoomOut').addEventListener('click', () => { pdfScale = Math.max(pdfScale - 0.25, 0.5); queueRender(); }); document.getElementById('pdfFit').addEventListener('click', () => { pdfScale = 1.5; queueRender(); }); // Extract all page text for search extractAllText(); // Initialize search controls initSearchControls(); } catch (e) { console.error('PDF load failed:', e); canvasWrap.innerHTML = `
⚠️
DOCUMENT LOAD FAILED — ${esc(e.message || 'File may not exist yet')}
⬇ ATTEMPT DIRECT DOWNLOAD
`; } } function updatePageInfo() { const el = document.getElementById('pdfPageInfo'); if (el && pdfDoc) { el.textContent = `${pdfPage} / ${pdfDoc.numPages}`; } } async function renderPdfPage() { if (!pdfDoc) return; pdfRendering = true; const page = await pdfDoc.getPage(pdfPage); const viewport = page.getViewport({ scale: pdfScale }); const canvas = document.getElementById('pdfCanvas'); if (!canvas) { pdfRendering = false; return; } const ctx = canvas.getContext('2d'); canvas.height = viewport.height; canvas.width = viewport.width; // Update page container size to match viewport const pageContainer = document.getElementById('pdfPageContainer'); if (pageContainer) { pageContainer.style.width = viewport.width + 'px'; pageContainer.style.height = viewport.height + 'px'; } await page.render({ canvasContext: ctx, viewport: viewport }).promise; // Render text layer for search and text selection await renderPageTextLayer(page, viewport); // Highlight search matches on this page highlightCurrentPageMatches(); pdfRendering = false; updatePageInfo(); if (pdfPending !== null) { renderPdfPage(); pdfPending = null; } } function queueRender() { if (pdfRendering) { pdfPending = pdfPage; } else { renderPdfPage(); } } // ─── Text Layer Rendering ──────────────────────────── async function renderPageTextLayer(page, viewport) { const existing = document.getElementById('pdfTextLayer'); if (existing) existing.remove(); const pageContainer = document.getElementById('pdfPageContainer'); if (!pageContainer) return; const textContent = await page.getTextContent(); const textLayerDiv = document.createElement('div'); textLayerDiv.id = 'pdfTextLayer'; textLayerDiv.className = 'prop-text-layer'; textLayerDiv.style.width = viewport.width + 'px'; textLayerDiv.style.height = viewport.height + 'px'; // Try pdf.js built-in renderTextLayer, fallback to manual if (typeof pdfjsLib.renderTextLayer === 'function') { try { const task = pdfjsLib.renderTextLayer({ textContentSource: textContent, container: textLayerDiv, viewport: viewport, }); await task.promise; } catch (err) { console.warn('renderTextLayer failed, using fallback:', err); buildTextLayerFallback(textContent, viewport, textLayerDiv); } } else { buildTextLayerFallback(textContent, viewport, textLayerDiv); } // Tag each span with its item index for search highlighting let itemIdx = 0; for (const child of textLayerDiv.childNodes) { if (child.nodeName === 'SPAN') { child.dataset.itemIdx = itemIdx; itemIdx++; } } pageContainer.appendChild(textLayerDiv); } function buildTextLayerFallback(textContent, viewport, container) { textContent.items.forEach((item, idx) => { if (!item.str) return; const span = document.createElement('span'); span.textContent = item.str; const tx = item.transform; const fontHeight = Math.sqrt(tx[2] * tx[2] + tx[3] * tx[3]); const s = viewport.scale; span.style.fontSize = (fontHeight * s) + 'px'; span.style.fontFamily = 'sans-serif'; span.style.left = (tx[4] * s) + 'px'; span.style.top = (viewport.height - tx[5] * s - fontHeight * s) + 'px'; span.style.position = 'absolute'; span.style.color = 'transparent'; span.style.whiteSpace = 'pre'; container.appendChild(span); }); } // ─── Text Extraction for Search ────────────────────── async function extractAllText() { pdfAllText = []; if (!pdfDoc) return; for (let i = 1; i <= pdfDoc.numPages; i++) { const page = await pdfDoc.getPage(i); const tc = await page.getTextContent(); let fullText = ''; const itemOffsets = []; tc.items.forEach((item, idx) => { const start = fullText.length; fullText += item.str; itemOffsets.push({ start: start, end: fullText.length, itemIdx: idx }); if (item.hasEOL) fullText += '\n'; }); pdfAllText.push({ pageNum: i, text: fullText, items: tc.items, itemOffsets: itemOffsets }); } console.log('PROPAGANDA: Extracted text from ' + pdfAllText.length + ' pages'); } // ─── Search Controls Init ──────────────────────────── function initSearchControls() { const searchInput = document.getElementById('pdfSearchInput'); const searchPrev = document.getElementById('pdfSearchPrev'); const searchNext = document.getElementById('pdfSearchNext'); const searchClose = document.getElementById('pdfSearchClose'); const searchToggle = document.getElementById('pdfSearchToggle'); const searchCaseBtn = document.getElementById('pdfSearchCaseBtn'); if (!searchInput) return; searchToggle.addEventListener('click', () => toggleSearchBar()); searchClose.addEventListener('click', () => closeSearchBar()); let searchTimeout = null; searchInput.addEventListener('input', () => { clearTimeout(searchTimeout); searchTimeout = setTimeout(() => { pdfSearchQuery = searchInput.value; performSearch(); }, 200); }); searchInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); if (e.shiftKey) prevMatch(); else nextMatch(); } if (e.key === 'Escape') { e.preventDefault(); closeSearchBar(); } }); searchPrev.addEventListener('click', () => prevMatch()); searchNext.addEventListener('click', () => nextMatch()); searchCaseBtn.addEventListener('click', () => { pdfSearchCaseSensitive = !pdfSearchCaseSensitive; searchCaseBtn.classList.toggle('active', pdfSearchCaseSensitive); performSearch(); }); // Ctrl+F shortcut — only when PDF viewer is present document.addEventListener('keydown', (e) => { if ((e.ctrlKey || e.metaKey) && e.key === 'f') { if (document.getElementById('pdfViewerContainer')) { e.preventDefault(); toggleSearchBar(true); } } }); } function toggleSearchBar(forceOpen) { const searchBar = document.getElementById('pdfSearchBar'); const searchInput = document.getElementById('pdfSearchInput'); if (!searchBar) return; if (forceOpen || !pdfSearchActive) { searchBar.classList.add('active'); pdfSearchActive = true; setTimeout(() => searchInput && searchInput.focus(), 50); } else { closeSearchBar(); } } function closeSearchBar() { const searchBar = document.getElementById('pdfSearchBar'); if (!searchBar) return; searchBar.classList.remove('active'); pdfSearchActive = false; pdfSearchQuery = ''; pdfSearchMatches = []; pdfSearchCurrentIdx = -1; const searchInput = document.getElementById('pdfSearchInput'); if (searchInput) searchInput.value = ''; updateSearchInfo(); clearHighlights(); } function performSearch() { pdfSearchMatches = []; pdfSearchCurrentIdx = -1; const query = pdfSearchQuery.trim(); if (!query || query.length === 0) { updateSearchInfo(); clearHighlights(); return; } const cs = pdfSearchCaseSensitive; const q = cs ? query : query.toLowerCase(); pdfAllText.forEach(pageData => { const text = cs ? pageData.text : pageData.text.toLowerCase(); let pos = 0; while ((pos = text.indexOf(q, pos)) !== -1) { const matchStart = pos; const matchEnd = pos + q.length; const affectedItems = pageData.itemOffsets.filter( io => io.end > matchStart && io.start < matchEnd ); pdfSearchMatches.push({ page: pageData.pageNum, charStart: matchStart, charEnd: matchEnd, itemIndices: affectedItems.map(io => io.itemIdx) }); pos += 1; } }); pdfSearchCurrentIdx = pdfSearchMatches.length > 0 ? 0 : -1; updateSearchInfo(); if (pdfSearchMatches.length > 0) { goToMatch(0); } else { clearHighlights(); } } function goToMatch(idx) { if (idx < 0 || idx >= pdfSearchMatches.length) return; pdfSearchCurrentIdx = idx; const match = pdfSearchMatches[idx]; if (match.page !== pdfPage) { pdfPage = match.page; queueRender(); } else { highlightCurrentPageMatches(); } updateSearchInfo(); } function nextMatch() { if (pdfSearchMatches.length === 0) return; goToMatch((pdfSearchCurrentIdx + 1) % pdfSearchMatches.length); } function prevMatch() { if (pdfSearchMatches.length === 0) return; goToMatch((pdfSearchCurrentIdx - 1 + pdfSearchMatches.length) % pdfSearchMatches.length); } function highlightCurrentPageMatches() { clearHighlights(); const textLayer = document.getElementById('pdfTextLayer'); if (!textLayer) return; const pageMatches = pdfSearchMatches.filter(m => m.page === pdfPage); if (pageMatches.length === 0) return; pageMatches.forEach(match => { const globalIdx = pdfSearchMatches.indexOf(match); const isActive = globalIdx === pdfSearchCurrentIdx; match.itemIndices.forEach(itemIdx => { const span = textLayer.querySelector('span[data-item-idx="' + itemIdx + '"]'); if (span) { span.classList.add('prop-search-hl'); if (isActive) { span.classList.add('prop-search-hl-active'); const canvasWrap = document.getElementById('pdfCanvasWrap'); if (canvasWrap) { const sr = span.getBoundingClientRect(); const wr = canvasWrap.getBoundingClientRect(); const offset = sr.top - wr.top - wr.height / 3; canvasWrap.scrollBy({ top: offset, behavior: 'smooth' }); } } } }); }); } function clearHighlights() { const tl = document.getElementById('pdfTextLayer'); if (!tl) return; tl.querySelectorAll('.prop-search-hl').forEach(el => { el.classList.remove('prop-search-hl', 'prop-search-hl-active'); }); } function updateSearchInfo() { const info = document.getElementById('pdfSearchInfo'); if (!info) return; if (pdfSearchMatches.length === 0) { if (pdfSearchQuery && pdfSearchQuery.trim().length > 0) { info.textContent = 'NO MATCHES'; info.className = 'prop-search-info no-match'; } else { info.textContent = ''; info.className = 'prop-search-info'; } } else { info.textContent = (pdfSearchCurrentIdx + 1) + ' OF ' + pdfSearchMatches.length; info.className = 'prop-search-info'; } } // ─── Not Found / Empty ──────────────────────────────── function renderNotFound(msg) { ROOT.innerHTML = `
⚠️
${esc(msg)}
The requested resource could not be located in the archive.
◂ RETURN TO ARCHIVE INDEX
`; } function renderEmpty(title, text) { return `
${esc(title)}
${esc(text)}
`; } // ─── Router ─────────────────────────────────────────── function route() { if (!DATA) return; // Reset PDF state pdfDoc = null; pdfPage = 1; pdfRendering = false; pdfPending = null; pdfAllText = []; pdfSearchActive = false; pdfSearchQuery = ''; pdfSearchMatches = []; pdfSearchCurrentIdx = -1; const hash = (location.hash || '').replace(/^#\/?/, ''); const parts = hash.split('/').filter(Boolean); if (parts.length === 0 || hash === '') { renderCategories(); } else if (parts[0] === 'category' && parts[1]) { renderCategory(parts[1], null); } else if (parts[0] === 'country' && parts[1] && parts[2]) { renderCategory(parts[1], parts[2]); } else if (parts[0] === 'collection' && parts.length >= 4) { renderCollection(parts[1], parts[2], parts[3]); } else if (parts[0] === 'doc' && parts.length >= 5) { renderDocument(parts[1], parts[2], parts[3], parts[4]); } else { renderNotFound('INVALID ROUTE'); } // Scroll to top window.scrollTo({ top: 0, behavior: 'smooth' }); } // ─── Init ───────────────────────────────────────────── async function init() { ROOT.innerHTML = '
INITIALISING PROPAGANDA ARCHIVE...
'; await loadData(); if (DATA) { window.addEventListener('hashchange', route); route(); } } // Wait for DOM if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); } })();