From 95ca7efa44476a9590d81ec6e046c5dd455e88dc Mon Sep 17 00:00:00 2001 From: nathan Date: Wed, 13 May 2026 10:43:13 -0400 Subject: [PATCH] cleaned up the code --- launch_viewer.py | 18 +- p44-landing.html | 337 ------------ p44-ocr-viewer.css | 513 ------------------ p44-ocr-viewer.html | 132 ----- p44-ocr-viewer.js | 1261 ------------------------------------------- 5 files changed, 11 insertions(+), 2250 deletions(-) delete mode 100644 p44-landing.html delete mode 100644 p44-ocr-viewer.css delete mode 100644 p44-ocr-viewer.html delete mode 100644 p44-ocr-viewer.js diff --git a/launch_viewer.py b/launch_viewer.py index cae99a5..489327c 100644 --- a/launch_viewer.py +++ b/launch_viewer.py @@ -12,10 +12,11 @@ import socket import os import sys import json -revfrom urllib.parse import urlparse, parse_qs +from urllib.parse import urlparse, parse_qs PORT = 5044 -ROOT = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.dirname(os.path.abspath(__file__)) +DATA_DIR = os.path.join(ROOT, 'viewer', 'data') class QuietHandler(http.server.SimpleHTTPRequestHandler): @@ -53,7 +54,8 @@ class QuietHandler(http.server.SimpleHTTPRequestHandler): return if parsed.path == '/save-flags': - dest = os.path.join(ROOT, 'flags-' + diary + '.json') + os.makedirs(DATA_DIR, exist_ok=True) + dest = os.path.join(DATA_DIR, 'flags-' + diary + '.json') with open(dest, 'w', encoding='utf-8') as f: json.dump(data, f, indent=2) self.send_response(200) @@ -62,7 +64,8 @@ class QuietHandler(http.server.SimpleHTTPRequestHandler): self.wfile.write(b'{"ok":true}') elif parsed.path == '/save-corrections': - dest = os.path.join(ROOT, 'corrections-' + diary + '.json') + os.makedirs(DATA_DIR, exist_ok=True) + dest = os.path.join(DATA_DIR, 'corrections-' + diary + '.json') existing = {} if os.path.exists(dest): try: @@ -84,7 +87,8 @@ class QuietHandler(http.server.SimpleHTTPRequestHandler): self.wfile.write(b'{"ok":true}') elif parsed.path == '/save-pass2-flags': - dest = os.path.join(ROOT, 'pass2_flags-' + diary + '.json') + os.makedirs(DATA_DIR, exist_ok=True) + dest = os.path.join(DATA_DIR, 'pass2_flags-' + diary + '.json') try: with open(dest, 'w', encoding='utf-8') as f: json.dump(data, f, indent=2) @@ -98,7 +102,7 @@ class QuietHandler(http.server.SimpleHTTPRequestHandler): elif parsed.path == '/clear-all-data': for prefix in ['flags-', 'corrections-', 'pass2_flags-']: - fpath = os.path.join(ROOT, prefix + diary + '.json') + fpath = os.path.join(DATA_DIR, prefix + diary + '.json') if os.path.exists(fpath): try: os.remove(fpath) @@ -124,7 +128,7 @@ def start_server(): if __name__ == '__main__': - url = 'http://localhost:{}/p44-landing.html'.format(PORT) + url = 'http://localhost:{}/viewer/p44-landing.html'.format(PORT) if port_in_use(PORT): print('\n P44 OCR Viewer (server already running)') diff --git a/p44-landing.html b/p44-landing.html deleted file mode 100644 index d98aa0b..0000000 --- a/p44-landing.html +++ /dev/null @@ -1,337 +0,0 @@ - - - - - -P44 OCR — War Diary Transcription - - - - -
-

⚜ P44 War Diary Transcription

- Pass 1 — Volunteer -
- -
- -
-
- - - - - - - - - diff --git a/p44-ocr-viewer.css b/p44-ocr-viewer.css deleted file mode 100644 index 302fc4d..0000000 --- a/p44-ocr-viewer.css +++ /dev/null @@ -1,513 +0,0 @@ -/* ── Reset & base ─────────────────────────────────────────────── */ -*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } - -:root { - --bg: #1a1a2e; - --surface: #16213e; - --surface2: #0f3460; - --gold: #c8a84b; - --gold-dim: #8a6f2e; - --text: #ddd4bb; - --text-dim: #7a7260; - --highlight: #ffe066; - --border: #2e2e4e; - --toolbar-h: 52px; - --footer-h: 34px; -} - -html, body { - height: 100%; - background: var(--bg); - color: var(--text); - font-family: 'Georgia', 'Times New Roman', serif; - font-size: 14px; - overflow: hidden; -} - -/* ── App shell ────────────────────────────────────────────────── */ -#app { - display: grid; - grid-template-rows: var(--toolbar-h) auto 1fr var(--footer-h); - height: 100vh; -} - -/* ── Override dropdown ────────────────────────────────────────── */ -#override-wrap { - position: relative; - flex-shrink: 0; -} - -#override-menu { - display: none; - position: absolute; - top: calc(100% + 4px); - left: 0; - z-index: 500; - background: var(--surface); - border: 1px solid var(--gold-dim); - border-radius: 4px; - min-width: 160px; - padding: 4px 0; - box-shadow: 0 4px 14px rgba(0,0,0,0.45); -} - -#override-menu.open { display: block; } - -.override-item { - display: flex; - align-items: center; - gap: 8px; - padding: 7px 14px; - font-family: 'Georgia', serif; - font-size: 11px; - color: var(--text-dim); - cursor: pointer; - white-space: nowrap; - letter-spacing: 0.03em; - transition: background 0.1s, color 0.1s; -} -.override-item:hover { background: var(--surface2); color: var(--gold); } -.override-item input[type="file"] { display: none; } - -/* ── Toolbar ──────────────────────────────────────────────────── */ -#toolbar { - display: flex; - align-items: center; - gap: 8px; - padding: 0 12px; - background: var(--surface); - border-bottom: 2px solid var(--gold-dim); - overflow: visible; /* must be visible so the Override dropdown can escape the toolbar */ - flex-wrap: nowrap; - position: relative; - z-index: 100; /* sit above pass2-banner and panel headers */ -} - -#toolbar h1 { - font-size: 13px; - font-weight: normal; - letter-spacing: 0.1em; - color: var(--gold); - white-space: nowrap; - flex-shrink: 0; -} - -.divider { width: 1px; height: 26px; background: var(--border); flex-shrink: 0; margin: 0 2px; } - -.toolbar-group { - display: flex; - align-items: center; - gap: 4px; - flex-shrink: 0; -} -.toolbar-group .lbl { - font-size: 9px; - color: var(--text-dim); - letter-spacing: 0.06em; - white-space: nowrap; -} - -.file-btn { - display: inline-block; - padding: 3px 8px; - background: var(--surface2); - border: 1px solid var(--gold-dim); - border-radius: 3px; - color: var(--text); - font-size: 10px; - cursor: pointer; - letter-spacing: 0.03em; - transition: border-color 0.15s, background 0.15s; - white-space: nowrap; -} -.file-btn:hover { border-color: var(--gold); background: #1a2a50; } -.file-btn input[type="file"] { display: none; } - -.tb-btn { - padding: 3px 9px; - background: transparent; - border: 1px solid var(--gold-dim); - border-radius: 3px; - color: var(--text-dim); - font-size: 10px; - font-family: inherit; - letter-spacing: 0.05em; - cursor: pointer; - transition: border-color 0.15s, color 0.15s, background 0.15s; - white-space: nowrap; - flex-shrink: 0; -} -.tb-btn:hover { border-color: var(--gold); color: var(--gold); background: rgba(200,168,75,0.08); } -a.tb-btn { text-decoration: none; display: inline-flex; align-items: center; } - -#scale-select { - background: var(--surface2); - border: 1px solid var(--gold-dim); - border-radius: 3px; - color: var(--text); - font-size: 10px; - font-family: inherit; - padding: 2px 4px; - cursor: pointer; -} - -/* ── Main panels ──────────────────────────────────────────────── */ -#panels { - display: grid; - grid-template-columns: 1fr 1fr; - overflow: hidden; -} - -.panel { - display: flex; - flex-direction: column; - overflow: hidden; - border-right: 1px solid var(--border); -} -.panel:last-child { border-right: none; } - -.panel-header { - padding: 5px 10px; - font-size: 9px; - letter-spacing: 0.12em; - color: var(--gold-dim); - text-transform: uppercase; - background: var(--surface); - border-bottom: 1px solid var(--border); - flex-shrink: 0; - display: flex; - align-items: center; - gap: 8px; -} -.panel-header .ph-title { flex: 1; } - -/* page navigation */ -.page-nav { display: flex; align-items: center; gap: 4px; } -.page-nav button { - background: var(--surface2); border: 1px solid var(--gold-dim); - border-radius: 2px; color: var(--text); font-size: 10px; - padding: 1px 6px; cursor: pointer; line-height: 1.5; -} -.page-nav button:hover { border-color: var(--gold); color: var(--gold); } -.page-nav button:disabled { opacity: 0.3; cursor: default; } -.page-nav input[type="number"] { - width: 42px; text-align: center; - background: var(--surface2); border: 1px solid var(--gold-dim); - border-radius: 2px; color: var(--text); font-size: 9px; padding: 2px 3px; -} -#page-label { font-size: 9px; color: var(--text-dim); white-space: nowrap; } - -.panel-body { - flex: 1; - overflow: auto; - position: relative; -} - -/* ── Left panel: PDF canvas + overlay ────────────────────────── */ -#image-panel-body { - position: relative; - scrollbar-gutter: stable; /* reserve scrollbar space always — prevents layout shift on zoom */ -} - -#pdf-container { - position: relative; - display: block; /* block instead of inline-block — fills full panel width cleanly */ - width: 100%; - min-height: 100%; /* always occupies the full panel height so nothing else renders below */ -} - -#pdf-canvas { - display: block; - user-select: none; - background: #e8e0cc; -} - -#overlay { - position: absolute; - top: 0; left: 0; - pointer-events: none; -} - -/* noinspection CssUnusedSymbol */ -.word-box { - position: absolute; - border: 1px solid transparent; - border-radius: 1px; - cursor: crosshair; - pointer-events: all; - transition: background 0.07s, border-color 0.07s; -} -/* noinspection CssUnusedSymbol */ -.word-box:hover, -/* noinspection CssUnusedSymbol */ -.word-box.highlight { - background: rgba(255, 224, 102, 0.38); - border-color: var(--highlight); -} - -.panel-placeholder { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - height: 100%; - gap: 12px; - color: var(--text-dim); - font-size: 13px; - font-style: italic; - padding: 40px; - text-align: center; -} -.panel-placeholder em { - color: var(--gold-dim); - font-size: 11px; - font-style: normal; - letter-spacing: 0.04em; -} - -.spinner { - width: 22px; height: 22px; - border: 2px solid var(--surface2); - border-top-color: var(--gold); - border-radius: 50%; - animation: spin 0.7s linear infinite; -} -@keyframes spin { to { transform: rotate(360deg); } } - -/* ── Footer ───────────────────────────────────────────────────── */ -#footer { - display: flex; - align-items: center; - padding: 0 12px; - gap: 10px; - background: var(--surface); - border-top: 1px solid var(--border); - font-size: 10px; - color: var(--text-dim); -} -#status-msg { flex: 1; font-style: italic; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; } - -/* ── Scrollbars ───────────────────────────────────────────────── */ -::-webkit-scrollbar { width: 7px; height: 7px; } -::-webkit-scrollbar-track { background: var(--bg); } -::-webkit-scrollbar-thumb { background: var(--surface2); border-radius: 4px; } -::-webkit-scrollbar-thumb:hover { background: var(--gold-dim); } - -/* ── Active toggle button state (sync-scroll, edit mode) ─────── */ -.tb-btn.active { border-color: var(--gold); color: var(--gold); } - -/* ── Flag buttons ─────────────────────────────────────────────── */ -.flag-btn { font-size: 9px; padding: 2px 7px; } -.flag-btn.active { border-color: var(--gold); color: var(--gold); } -/* ── Footer extras ────────────────────────────────────────────── */ -#flags-summary { color: var(--gold-dim); letter-spacing: 0.04em; white-space: nowrap; font-size: 10px; } -#kbd-hint { color: var(--text-dim); font-size: 9px; white-space: nowrap; opacity: 0.7; } - - -/* ── Zoom indicator in left panel header ─────────────────────────── */ -#zoom-label { - font-size: 9px; color: var(--gold-dim); letter-spacing: 0.06em; - white-space: nowrap; min-width: 34px; text-align: right; -} -#btn-zoom-reset { - padding: 1px 6px; font-size: 9px; font-family: inherit; - background: transparent; border: 1px solid var(--gold-dim); - border-radius: 2px; color: var(--text-dim); cursor: pointer; - letter-spacing: 0.04em; white-space: nowrap; - transition: border-color 0.12s, color 0.12s; -} -#btn-zoom-reset:hover { border-color: var(--gold); color: var(--gold); } - -/* ── Role switcher ────────────────────────────────────────────────── */ -#role-select { - background: var(--surface2); - border: 1px solid var(--gold-dim); - border-radius: 3px; - color: var(--text); - font-size: 10px; - font-family: inherit; - padding: 2px 4px; - cursor: pointer; -} - -/* ── Pass 2 review banner ─────────────────────────────────────────── */ -#pass2-banner { - display: none; - align-items: center; - padding: 0 14px; - background: #2e2010; - border-bottom: 1px solid #c08020; - font-size: 11px; - color: #e8b848; - letter-spacing: 0.04em; - height: 26px; - overflow: hidden; - white-space: nowrap; - flex-shrink: 0; -} - -/* ── Pass 2 revert bar ────────────────────────────────────────── */ -#pass2-revert-bar { - display: none; - align-items: center; - gap: 8px; - padding: 6px 16px; - background: var(--surface); - border-bottom: 1px solid var(--border); - flex-shrink: 0; -} -#pass2-revert-bar .rv-label { - font-size: 10px; - color: var(--text-dim); - letter-spacing: 0.04em; - flex: 1; -} -#pass2-revert-bar .rv-btn { - font-size: 10px; - font-family: 'Georgia', serif; - padding: 3px 10px; - border-radius: 3px; - border: 1px solid var(--border); - background: transparent; - color: var(--text-dim); - cursor: pointer; - letter-spacing: 0.04em; - transition: border-color 0.15s, color 0.15s; -} -#pass2-revert-bar .rv-btn:hover { border-color: var(--gold-dim); color: var(--gold); } -#pass2-revert-bar .rv-btn.danger { border-color: var(--red, #d05050); color: var(--red, #d05050); } -#pass2-revert-bar .rv-btn.danger:hover { border-color: var(--red, #d05050); color: var(--text); } - -/* ── Pass 2 change summary panel ─────────────────────────────── */ -#pass2-change-summary { - display: none; - padding: 10px 20px; - background: var(--surface2); - border-bottom: 1px solid var(--gold-dim); - flex-shrink: 0; - font-size: 11px; -} -#pass2-change-summary .cs-title { - font-size: 10px; - color: var(--gold-dim); - letter-spacing: 0.08em; - text-transform: uppercase; - margin-bottom: 6px; -} -#pass2-change-summary .cs-list { - color: var(--text-dim); - line-height: 1.8; - max-height: 100px; - overflow-y: auto; - margin-bottom: 8px; - font-family: Georgia, serif; -} -#pass2-change-summary .cs-changed { color: #e8a838; } -#pass2-change-summary .cs-added { color: #4caf70; } -#pass2-change-summary .cs-deleted { color: #d05050; } -#pass2-change-summary .cs-actions { display: flex; gap: 8px; } -.cs-revert-btn { - font-size: 10px; - font-family: Georgia, serif; - padding: 3px 10px; - border-radius: 3px; - border: 1px solid var(--border); - background: transparent; - color: var(--text-dim); - cursor: pointer; - letter-spacing: 0.04em; -} -.cs-revert-btn:hover { border-color: var(--gold-dim); color: var(--gold); } -.cs-revert-btn.danger { border-color: #d05050; color: #d05050; } -.cs-revert-btn.danger:hover { border-color: #e07070; color: #e07070; } - -/* ── Plain-text edit surface ──────────────────────────────────── */ -#text-panel-body { - display: flex; - flex-direction: column; - flex: 1; - min-height: 0; - overflow: hidden; -} - -#editor-wrap { - display: flex; - flex-direction: row; - flex: 1; - min-height: 0; - overflow: hidden; -} - -#line-numbers { - width: 48px; - flex-shrink: 0; - background: transparent; - border-right: 1px solid var(--border); - color: var(--gold-dim); - font-family: 'Courier New', monospace; - font-size: 11px; - line-height: 1.75; - padding: 0 8px 0 12px; - text-align: right; - overflow: hidden; - user-select: none; - white-space: pre; - box-sizing: border-box; - margin-right: 12px; -} - -#text-editor { - display: block; /* always block — visibility via #editor-wrap */ - flex: 1; - min-width: 0; - min-height: 0; - width: 100%; - height: 100%; - background: var(--surface2); - color: var(--text); - font-family: 'Georgia', 'Times New Roman', serif; - font-size: 12.5px; - line-height: 1.75; - padding: 0 16px 0 0; - border: none; - border-radius: 0; - resize: none; - outline: none; - box-sizing: border-box; - overflow-y: auto; - overflow-x: hidden; -} - -/* ── Done-page read-only banner (Pass 1) ─────────────────────── */ -#done-banner { - display: none; - align-items: center; - gap: 8px; - padding: 7px 16px; - background: #0f2a0f; - border-bottom: 1px solid #2e6e2e; - font-size: 10px; - color: #5cb85c; - letter-spacing: 0.05em; - flex-shrink: 0; -} - -/* ── Problem flag indicator on page nav input ─────────────────── */ -#page-input.problem { border-color: #c08020; } - -/* ── OCR overlay panel (replaces raster in left panel) ───────── */ -#ocr-overlay-panel { - display: none; - position: absolute; - inset: 0; - overflow-y: auto; - background: var(--surface); - padding: 20px 24px; - font-size: 13px; - line-height: 1.8; - color: var(--text); - font-family: 'Georgia', 'Times New Roman', serif; - white-space: pre-wrap; - word-break: break-word; - z-index: 10; -} diff --git a/p44-ocr-viewer.html b/p44-ocr-viewer.html deleted file mode 100644 index 2c3ad2e..0000000 --- a/p44-ocr-viewer.html +++ /dev/null @@ -1,132 +0,0 @@ - - - - - -P44 OCR Viewer - - - - - -
- - -
- ← Diaries -
-

⚜ P44 OCR VIEWER

- -
- - -
- -
- - - -
-
-
- -
-
-
- ROLE - -
-
- -
- - -
- Pass 2 mode · 0 pages ready for review - - -
- - -
- - -
-
- Source Document — Page Scan - 100% - - - -
-
-
- -
- War diary scan loads automatically. -
-
- - -
-
- - -
-
- OCR Text Output - - - -
-
-
- -
- OCR transcript loads automatically. -
-
- - - -
-
- -
-
-
-
- - - -
- - - - - - - - - - diff --git a/p44-ocr-viewer.js b/p44-ocr-viewer.js deleted file mode 100644 index 2d2a8b9..0000000 --- a/p44-ocr-viewer.js +++ /dev/null @@ -1,1261 +0,0 @@ -'use strict'; - -// ── PDF.js worker ──────────────────────────────────────────────── -if (typeof pdfjsLib !== 'undefined') { - pdfjsLib.GlobalWorkerOptions.workerSrc = - 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js'; -} - -// ── Diary config ───────────────────────────────────────────────── -const DIARY_CONFIG = { - 'calgary-highlanders': { - pdfPath: './Inputs/Calgary-Highlanders_War-Diary_Sep44.pdf', - textPath: './Inputs/ocr-output/Calgary-Highlanders_War-Diary_Sep44_olmocr.md', - }, - 'black-watch': { - pdfPath: './Inputs/RHC-Blackwatch_War-Diary_Sep44.pdf', - textPath: './Inputs/ocr-output/RHC-Blackwatch_War-Diary_Sep44_olmocr.md', - }, - '5cib': { - pdfPath: './Inputs/5CIB_War-Diary_Sep44.pdf', - textPath: './Inputs/ocr-output/5CIB_War-Diary_Sep44_olmocr.md', - }, -}; - -const diaryId = new URLSearchParams(window.location.search).get('diary') || 'calgary-highlanders'; -const _diary = DIARY_CONFIG[diaryId] || DIARY_CONFIG['calgary-highlanders']; - -// ── Data paths (served by launch_viewer.py) ─────────────────────── -const REAL_PDF_PATH = _diary.pdfPath; -const REAL_TEXT_PATH = _diary.textPath; - -// ── Per-diary storage keys / server endpoints ───────────────────── -const LS_FLAGS_KEY = 'p44-flags-' + diaryId; -const LS_PASS2_FLAGS_KEY = 'p44-pass2-flags-' + diaryId; -const LS_CORRECTIONS_KEY = 'p44-corrections-' + diaryId; - -const SERVER_FLAGS_FILE = 'flags-' + diaryId + '.json'; -const SERVER_PASS2_FLAGS_FILE = 'pass2_flags-' + diaryId + '.json'; -const SERVER_CORRECTIONS_FILE = 'corrections-' + diaryId + '.json'; -const SERVER_FLAGS_ENDPOINT = '/save-flags?diary=' + diaryId; -const SERVER_PASS2_ENDPOINT = '/save-pass2-flags?diary=' + diaryId; -const SERVER_CORRECTIONS_END = '/save-corrections?diary=' + diaryId; -const SERVER_CLEAR_ENDPOINT = '/clear-all-data?diary=' + diaryId; - -// ── Diary display names ─────────────────────────────────────────── -const DIARY_TITLES = { - 'calgary-highlanders': 'Calgary Highlanders — Sep 1944', - 'black-watch': 'Black Watch (RHR) — Sep 1944', - '5cib': '5th Canadian Infantry Brigade — Sep 1944', -}; - -// ───────────────────────────────────────────────────────────────── -// STATE -// ───────────────────────────────────────────────────────────────── -const state = { - mode: 'none', // 'pdf' | 'none' - pdfDoc: null, - currentPage: 1, - totalPages: 0, - zoomScale: 1.0, - wordData: [], - rendering: false, - ocrPages: {}, - lastRenderTime: 0, - flags: {}, - corrections: {}, - ocrOverlay: false, -}; - -// ───────────────────────────────────────────────────────────────── -// DOM REFS -// ───────────────────────────────────────────────────────────────── -const inputPdf = document.getElementById('input-pdf'); -const inputText = document.getElementById('input-text'); -const inputJson = document.getElementById('input-json'); - -const imagePlaceholder = document.getElementById('image-placeholder'); -const imgSpinner = document.getElementById('img-spinner'); -const imgPhText = document.getElementById('img-ph-text'); -const pdfContainer = document.getElementById('pdf-container'); -const pdfCanvas = document.getElementById('pdf-canvas'); -const overlay = document.getElementById('overlay'); - -const textPlaceholder = document.getElementById('text-placeholder'); -const txtSpinner = document.getElementById('txt-spinner'); -const txtPhText = document.getElementById('txt-ph-text'); - -const btnPrev = document.getElementById('btn-prev'); -const btnNext = document.getElementById('btn-next'); -const pageInput = document.getElementById('page-input'); -const pageLabel = document.getElementById('page-label'); -const ocrSyncLabel = document.getElementById('ocr-sync-label'); -const statusMsg = document.getElementById('status-msg'); - -// Phase 2 — panel body refs (used for edit mode layout) -const imagePanelBody = document.getElementById('image-panel-body'); -const textPanelBody = document.getElementById('text-panel-body'); - -// Zoom indicator -const zoomLabel = document.getElementById('zoom-label'); -const btnZoomReset = document.getElementById('btn-zoom-reset'); - -// Phase 4 — flags -const btnFlagDone = document.getElementById('btn-flag-done'); -const btnFlagProblem = document.getElementById('btn-flag-problem'); -const flagsSummary = document.getElementById('flags-summary'); - -// Phase 5 — edit surface -const editorWrap = document.getElementById('editor-wrap'); -const lineNumbers = document.getElementById('line-numbers'); -const textEditor = document.getElementById('text-editor'); - -// Phase 6 — OCR overlay toggle (Pass 2) -const btnOcrToggle = document.getElementById('btn-ocr-toggle'); -const ocrOverlayPanel = document.getElementById('ocr-overlay-panel'); - -// ───────────────────────────────────────────────────────────────── -// OVERRIDE DROPDOWN -// ───────────────────────────────────────────────────────────────── -const btnOverride = document.getElementById('btn-override'); -const overrideMenu = document.getElementById('override-menu'); - -btnOverride.addEventListener('click', e => { - e.stopPropagation(); - overrideMenu.classList.toggle('open'); -}); - -// Close when a file input is activated (user picked a file or cancelled) -document.getElementById('input-pdf') .addEventListener('change', () => overrideMenu.classList.remove('open')); -document.getElementById('input-text').addEventListener('change', () => overrideMenu.classList.remove('open')); -document.getElementById('input-json').addEventListener('change', () => overrideMenu.classList.remove('open')); - -// Close when clicking anywhere outside the dropdown -document.addEventListener('click', () => overrideMenu.classList.remove('open')); - -// ───────────────────────────────────────────────────────────────── -// TOOLBAR BUTTON EVENTS -// ───────────────────────────────────────────────────────────────── -inputPdf.addEventListener('change', async e => { - const file = e.target.files[0]; if (!file) return; - setStatus('Loading PDF: ' + file.name + '…'); showImgSpinner(true); - const buf = await file.arrayBuffer(); - showImgSpinner(false); - await loadPDFBuffer(buf); - setStatus('PDF: ' + file.name + ' — ' + state.totalPages + ' pages'); -}); - -inputText.addEventListener('change', e => { - const file = e.target.files[0]; if (!file) return; - const reader = new FileReader(); - reader.onload = ev => { loadOCRText(ev.target.result, true); setStatus('Text: ' + file.name); }; - reader.readAsText(file); -}); - -inputJson.addEventListener('change', e => { - const file = e.target.files[0]; if (!file) return; - const reader = new FileReader(); - reader.onload = ev => { - try { - loadWordDataJSON(JSON.parse(ev.target.result)); - setStatus('JSON: ' + file.name + ' — ' + state.wordData.length + ' words'); - } catch (err) { alert('JSON parse error: ' + err.message); } - }; - reader.readAsText(file); -}); - -btnPrev.addEventListener('click', () => { - if (currentRole === 'pass2') { - const verified = getVerifiedPages(); - if (verified.length) { - const prev = [...verified].reverse().find(p => p < state.currentPage); - if (prev !== undefined) goToPage(prev); - } else { - goToPage(state.currentPage - 1); // no queue yet — sequential fallback - } - } else { - goToPage(state.currentPage - 1); - } -}); -btnNext.addEventListener('click', () => { - if (currentRole === 'pass2') { - const verified = getVerifiedPages(); - if (verified.length) { - const next = verified.find(p => p > state.currentPage); - if (next !== undefined) goToPage(next); - } else { - goToPage(state.currentPage + 1); // no queue yet — sequential fallback - } - } else { - goToPage(state.currentPage + 1); - } -}); -pageInput.addEventListener('change', () => { - const n = parseInt(pageInput.value, 10); - if (!isNaN(n)) goToPage(n); -}); - -window.addEventListener('resize', () => { - if (state.mode === 'pdf' && state.pdfDoc && !state.rendering) renderCurrentPage(); - updateLineNumbers(); -}); - -// ── Auto-load on startup when served from localhost ─────────────── -window.addEventListener('DOMContentLoaded', () => { - // Set diary title in toolbar and browser tab - const diaryTitle = DIARY_TITLES[diaryId] || diaryId; - document.title = 'P44 OCR Viewer — ' + diaryTitle; - const diaryLabel = document.getElementById('diary-label'); - if (diaryLabel) diaryLabel.textContent = diaryTitle; - - if (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1') { - setTimeout(loadWarDiary, 200); - } -}); - -// ───────────────────────────────────────────────────────────────── -// AUTO-LOAD: fetch PDF + OCR text from the Inputs folder -// ───────────────────────────────────────────────────────────────── -async function loadWarDiary() { - if (typeof pdfjsLib === 'undefined') { - setStatus('PDF.js not loaded — check internet connection, then refresh.', true); - return; - } - setStatus('Loading war diary…'); - showImgSpinner(true); showTxtSpinner(true); - - const [textRes, pdfRes] = await Promise.allSettled([ - fetch(REAL_TEXT_PATH).then(r => { if (!r.ok) throw new Error('HTTP ' + r.status); return r.text(); }), - fetch(REAL_PDF_PATH ).then(r => { if (!r.ok) throw new Error('HTTP ' + r.status); return r.arrayBuffer(); }), - ]); - - // OCR text - showTxtSpinner(false); - if (textRes.status === 'fulfilled') { - loadOCRText(textRes.value, true); - } else { - showTxtPlaceholder('Could not load OCR text: ' + textRes.reason + '.
Use the OCR Override picker above.'); - } - - // PDF - showImgSpinner(false); - if (pdfRes.status === 'fulfilled') { - await loadPDFBuffer(pdfRes.value); - setStatus( - state.totalPages + '-page PDF loaded' + - (textRes.status === 'fulfilled' ? ' + OCR text' : '') + - '. Use ◀▶ to navigate.' - ); - } else { - showImgPlaceholder('Could not load PDF: ' + pdfRes.reason + '.
Use the PDF Override picker above.'); - if (textRes.status === 'fulfilled') { - setStatus('OCR text loaded. PDF unavailable — use the PDF Override picker.', true); - } - } -} - -// ───────────────────────────────────────────────────────────────── -// PDF.js — load & render -// ───────────────────────────────────────────────────────────────── -async function loadPDFBuffer(arrayBuffer) { - try { - state.pdfDoc = await pdfjsLib.getDocument({ data: arrayBuffer }).promise; - state.totalPages = state.pdfDoc.numPages; - state.mode = 'pdf'; - state.currentPage = 1; - state.wordData = []; // clear JSON sidecar — use PDF text layer - pageLabel.textContent = '/ ' + state.totalPages; - // Note: pageInput.value is set by renderCurrentPage() below. - // Setting it here would fire a change event → goToPage → duplicate render. - btnPrev.disabled = true; - btnNext.disabled = state.totalPages <= 1; - pdfContainer.style.display = 'inline-block'; - imagePlaceholder.style.display = 'none'; - await renderCurrentPage(); - } catch (err) { - showImgPlaceholder('Failed to open PDF: ' + err.message); - } -} - -async function renderCurrentPage() { - if (!state.pdfDoc || state.rendering) return; - // Debounce: ignore spurious re-render calls (e.g. from resize events) that - // fire within 100 ms of a render that just completed. - if (Date.now() - state.lastRenderTime < 100) return; - state.rendering = true; - try { - const page = await state.pdfDoc.getPage(state.currentPage); - - // Fit page to panel width, then apply zoomScale. Multiply by DPR for sharp HiDPI rendering. - const dpr = window.devicePixelRatio || 1; - const panelW = document.getElementById('image-panel-body').clientWidth || 700; - const baseVP = page.getViewport({ scale: 1 }); - const fitScale = panelW / baseVP.width; - const vp = page.getViewport({ scale: fitScale * state.zoomScale * dpr }); - - // Physical canvas size = DPR × CSS size → crisp on Retina/HiDPI displays - pdfCanvas.width = vp.width; - pdfCanvas.height = vp.height; - pdfCanvas.style.width = (vp.width / dpr) + 'px'; - pdfCanvas.style.height = (vp.height / dpr) + 'px'; - - await page.render({ canvasContext: pdfCanvas.getContext('2d'), viewport: vp }).promise; - await buildPDFWordOverlay(page, vp, dpr); - - pageInput.value = state.currentPage; - btnPrev.disabled = state.currentPage <= 1; - btnNext.disabled = state.currentPage >= state.totalPages; - - // Sync right panel - try { - showOCRPage(state.currentPage); - } catch (e) { - setStatus('OCR panel error on page ' + state.currentPage + ': ' + e.message, true); - console.error(e); - } - - state.lastRenderTime = Date.now(); // debounce: record when this render finished - _lastRenderedZoom = state.zoomScale; // keep transform delta in sync - } finally { - state.rendering = false; - // If the user navigated while we were rendering, honour the queued page - if (_pendingPage !== null) { - const next = _pendingPage; - _pendingPage = null; - if (next !== state.currentPage) { - state.currentPage = next; - renderCurrentPage(); - } - } - } -} - -// Build word-box overlay from PDF.js text content -async function buildPDFWordOverlay(page, vp, dpr) { - dpr = dpr || 1; - overlay.innerHTML = ''; - // Overlay must match CSS canvas size, not physical pixel size - overlay.style.width = (vp.width / dpr) + 'px'; - overlay.style.height = (vp.height / dpr) + 'px'; - - // JSON sidecar in PDF mode — coords are PDF points (bottom-left origin) - if (state.wordData.length) { - state.wordData.forEach(e => { - const [cx, cy] = vp.convertToViewportPoint(e.bbox.x, e.bbox.y + e.bbox.h); - const cw = e.bbox.w * vp.scale; - const ch = e.bbox.h * vp.scale; - overlay.appendChild(makeWordBox(e.id, cx/dpr, cy/dpr, cw/dpr, ch/dpr, e.word)); - }); - } - // No JSON sidecar — overlay intentionally left blank -} - -function makeWordBox(id, x, y, w, h, title) { - const div = document.createElement('div'); - div.className = 'word-box'; - div.dataset.wordId = id; - div.style.left = Math.round(x) + 'px'; - div.style.top = Math.round(y) + 'px'; - div.style.width = Math.max(4, Math.round(w)) + 'px'; - div.style.height = Math.max(4, Math.round(h)) + 'px'; - if (title) div.title = title; - div.addEventListener('mouseenter', () => { div.classList.add('highlight'); }); - div.addEventListener('mouseleave', () => { div.classList.remove('highlight'); }); - return div; -} - -let _pendingPage = null; // holds the last page requested while a render was in progress - -// ───────────────────────────────────────────────────────────────── -// OCR TEXT RENDERING -// ───────────────────────────────────────────────────────────────── -function loadOCRText(rawText, isHtmlMarkdown) { - textPlaceholder.style.display = 'none'; - - if (isHtmlMarkdown) { - // Parse the olmOCR markdown into a per-page dictionary so we can render - // exactly one page at a time — fast, accurate, and avoids the "highlight all" - // problem that comes from searching a giant single-document DOM. - state.ocrPages = parseOCRByPage(rawText); - // Do NOT call showOCRPage() here. If the PDF is already loaded, - // renderCurrentPage() will call showOCRPage() as part of its own flow. - // If the PDF isn't loaded yet (the common startup path where OCR arrives - // first), renderCurrentPage() will call showOCRPage() once it runs. - // Calling it here would build the OCR panel twice on every startup. - if (state.pdfDoc) { - showOCRPage(state.currentPage || 1); - } - // else: leave the panel blank — renderCurrentPage() will populate it. - } else { - // Plain-text / sample mode — load directly into editor - state.ocrPages = {}; - textEditor.value = rawText; - updateLineNumbers(); - } -} - -// ── Parse olmOCR markdown into per-page dict ────────────────────────────── -// Each "## Page N" heading starts a new page entry. Content between two -// headings (tables, prose, weather notes) is stored under its page number. -function parseOCRByPage(rawText) { - const pages = {}; - const lines = rawText.split('\n'); - let pageNum = null; - let buf = []; - - for (const line of lines) { - const m = line.match(/^## Page (\d+)\s*$/); - if (m) { - if (pageNum !== null) pages[pageNum] = buf.join('\n'); - pageNum = parseInt(m[1], 10); - buf = []; // do NOT include the ## Page N heading in the content - } else if (pageNum !== null) { - buf.push(line); - } - } - if (pageNum !== null) pages[pageNum] = buf.join('\n'); - return pages; -} - -function showOCROverlay(pageNum) { - const corr = state.corrections && state.corrections[pageNum]; - const originalText = (corr && corr.original) || state.ocrPages[pageNum] || ''; - - ocrOverlayPanel.innerHTML = - '
' +
-    escH(originalText) + '
'; - - ocrOverlayPanel.style.display = 'block'; - pdfContainer.style.display = 'none'; - imagePlaceholder.style.display = 'none'; -} - -function hideOCROverlay() { - ocrOverlayPanel.style.display = 'none'; - if (state.mode === 'pdf') { - pdfContainer.style.display = 'block'; - } else { - imagePlaceholder.style.display = 'flex'; - } -} - -function toggleOCROverlay() { - state.ocrOverlay = !state.ocrOverlay; - btnOcrToggle.classList.toggle('active', state.ocrOverlay); - - if (state.ocrOverlay) { - showOCROverlay(state.currentPage); - } else { - hideOCROverlay(); - } -} - -btnOcrToggle.addEventListener('click', toggleOCROverlay); - -// ── Render the OCR panel for a single page ──────────────────────────────── -function showOCRPage(pageNum) { - textEditor.dataset.page = pageNum; - - if (!Object.keys(state.ocrPages).length) { - textEditor.value = ''; - ocrSyncLabel.textContent = ''; - return; - } - - const content = state.ocrPages[pageNum]; - if (!content) { - textEditor.value = 'No OCR text for page ' + pageNum + '.'; - ocrSyncLabel.textContent = 'page ' + pageNum + ' — no data'; - updateLineNumbers(); - return; - } - - const corr = state.corrections && state.corrections[pageNum]; - const editText = corr - ? (currentRole === 'pass2' - ? (corr.pass2 || corr.pass1 || corr.original || content) - : (corr.pass1 || corr.original || content)) - : content; - - textEditor.value = editText; - textEditor.scrollTop = 0; - ocrSyncLabel.textContent = 'page ' + pageNum; - - updateLineNumbers(); - updateFlagButtons(pageNum); - updateCorrectionIndicator(pageNum); - - // Snapshot original on first visit - if (!state.corrections[pageNum] || !state.corrections[pageNum].original) { - if (!state.corrections[pageNum]) state.corrections[pageNum] = {}; - state.corrections[pageNum].original = content.trim(); - } - - // Pass 1 done-page lock - const doneBanner = document.getElementById('done-banner'); - const isDonePage = currentRole === 'pass1' && state.flags[pageNum] === 'done'; - if (isDonePage) { - textEditor.readOnly = true; - textEditor.style.opacity = '0.55'; - doneBanner.innerHTML = '✓ Done — submitted to Pass 2 queue. This page is locked.'; - doneBanner.style.display = 'flex'; - } else { - textEditor.readOnly = false; - textEditor.style.opacity = ''; - doneBanner.style.display = 'none'; - } - - if (currentRole === 'pass2') { - updatePass2NavButtons(); - if (state.ocrOverlay) showOCROverlay(pageNum); - showPass2RevertBar(pageNum); - showPass2ChangeSummary(pageNum); - } -} - -// ───────────────────────────────────────────────────────────────── -// WORD DATA (JSON SIDECAR) -// ───────────────────────────────────────────────────────────────── -function loadWordDataJSON(data) { - state.wordData = data; - - // PDF mode: re-render so buildPDFWordOverlay picks up the new boxes - if (state.mode === 'pdf' && state.pdfDoc) { - renderCurrentPage(); - return; - } - - // Image mode: re-render overlay & re-link text spans (unchanged behaviour) - if (state.mode === 'image' && pdfCanvas.width) { - const sx = pdfCanvas.width / state.imageNaturalW; - const sy = pdfCanvas.height / state.imageNaturalH; - overlay.innerHTML = ''; - overlay.style.width = pdfCanvas.width + 'px'; - overlay.style.height = pdfCanvas.height + 'px'; - data.forEach(e => overlay.appendChild( - makeWordBox(e.id, e.bbox.x*sx, e.bbox.y*sy, e.bbox.w*sx, e.bbox.h*sy, e.word) - )); - } -} - -// ───────────────────────────────────────────────────────────────── -// HELPERS -// ───────────────────────────────────────────────────────────────── -function escH(s) { return s.replace(/&/g,'&').replace(//g,'>'); } -function showImgSpinner(on) { - imgSpinner.style.display = on ? 'inline-block' : 'none'; - imgPhText.style.display = on ? 'none' : 'block'; - imagePlaceholder.style.display = 'flex'; - if (on) pdfContainer.style.display = 'none'; -} -function showTxtSpinner(on) { - txtSpinner.style.display = on ? 'inline-block' : 'none'; - txtPhText.style.display = on ? 'none' : 'block'; - textPlaceholder.style.display = 'flex'; -} -function showImgPlaceholder(html) { - imgPhText.innerHTML = html; - imagePlaceholder.style.display = 'flex'; - pdfContainer.style.display = 'none'; -} -function showTxtPlaceholder(html) { - txtPhText.innerHTML = html; - textPlaceholder.style.display = 'flex'; -} -function setStatus(msg, warn) { - statusMsg.textContent = msg; - statusMsg.style.color = warn ? '#e07070' : 'var(--text-dim)'; -} - -// ───────────────────────────────────────────────────────────────── -// PHASE 3 — SCROLL ZOOM ON LEFT PANEL -// ───────────────────────────────────────────────────────────────── -const ZOOM_SCALE_MIN = 0.5; -const ZOOM_SCALE_MAX = 4.0; -const ZOOM_SCALE_STEP = 0.1; - -// Zoom state — separate "last rendered" zoom from "desired" zoom so we can -// use CSS transform for instant feedback without triggering a PDF re-render -// on every wheel tick. -let _lastRenderedZoom = 1.0; -let _zoomSettleTimer = null; -let _zoomAnchorFracX = 0.5; -let _zoomAnchorFracY = 0.5; -let _zoomAnchorClientX = 0; -let _zoomAnchorClientY = 0; - -function updateZoomLabel() { - const pct = Math.round(state.zoomScale * 100); - zoomLabel.textContent = pct + '%'; - btnZoomReset.style.opacity = state.zoomScale === 1.0 ? '0.4' : '1'; -} - -imagePanelBody.addEventListener('wheel', e => { - if (state.mode === 'none') return; - if (state.ocrOverlay) return; // overlay is showing — let scroll pass through normally - e.preventDefault(); - - const delta = e.deltaY < 0 ? ZOOM_SCALE_STEP : -ZOOM_SCALE_STEP; - const newZoom = Math.round( - Math.max(ZOOM_SCALE_MIN, Math.min(ZOOM_SCALE_MAX, state.zoomScale + delta)) * 10 - ) / 10; - - if (newZoom === state.zoomScale) return; - - // Capture cursor anchor once at the start of each new gesture (when timer isn't running) - if (_zoomSettleTimer === null) { - const rect = imagePanelBody.getBoundingClientRect(); - const cssW = parseFloat(pdfCanvas.style.width) || pdfCanvas.clientWidth || 700; - const cssH = parseFloat(pdfCanvas.style.height) || pdfCanvas.clientHeight || 900; - _zoomAnchorClientX = e.clientX - rect.left; - _zoomAnchorClientY = e.clientY - rect.top; - _zoomAnchorFracX = (_zoomAnchorClientX + imagePanelBody.scrollLeft) / cssW; - _zoomAnchorFracY = (_zoomAnchorClientY + imagePanelBody.scrollTop) / cssH; - } - - state.zoomScale = newZoom; - updateZoomLabel(); - - // Instant visual feedback via CSS transform — GPU-composited, zero layout impact, - // zero flicker. Scale relative to the last full render so the factor is exact. - const sf = state.zoomScale / _lastRenderedZoom; - const ox = _zoomAnchorFracX * 100; - const oy = _zoomAnchorFracY * 100; - pdfCanvas.style.transformOrigin = `${ox}% ${oy}%`; - pdfCanvas.style.transform = `scale(${sf})`; - overlay.style.transformOrigin = `${ox}% ${oy}%`; - overlay.style.transform = `scale(${sf})`; - - // After the scroll gesture settles, do ONE proper HiDPI re-render - clearTimeout(_zoomSettleTimer); - _zoomSettleTimer = setTimeout(async () => { - _zoomSettleTimer = null; - - // Clear the transform before re-render to avoid double-scaling - pdfCanvas.style.transform = ''; - pdfCanvas.style.transformOrigin = ''; - overlay.style.transform = ''; - overlay.style.transformOrigin = ''; - - state.lastRenderTime = 0; - - if (state.pdfDoc) { - await renderCurrentPage(); - } - - _lastRenderedZoom = state.zoomScale; - - // Scroll so the anchor point stays under the cursor - const newCSSW = parseFloat(pdfCanvas.style.width) || pdfCanvas.clientWidth; - const newCSSH = parseFloat(pdfCanvas.style.height) || pdfCanvas.clientHeight; - imagePanelBody.scrollLeft = _zoomAnchorFracX * newCSSW - _zoomAnchorClientX; - imagePanelBody.scrollTop = _zoomAnchorFracY * newCSSH - _zoomAnchorClientY; - }, 150); -}, { passive: false }); - -btnZoomReset.addEventListener('click', async () => { - if (state.zoomScale === 1.0) return; - // Cancel any pending settle timer - clearTimeout(_zoomSettleTimer); - _zoomSettleTimer = null; - // Clear any in-progress CSS transform - pdfCanvas.style.transform = ''; - pdfCanvas.style.transformOrigin = ''; - overlay.style.transform = ''; - overlay.style.transformOrigin = ''; - state.zoomScale = 1.0; - updateZoomLabel(); - state.lastRenderTime = 0; - await renderCurrentPage(); - _lastRenderedZoom = 1.0; - imagePanelBody.scrollLeft = 0; - imagePanelBody.scrollTop = 0; -}); - -// ───────────────────────────────────────────────────────────────── -// ROLE SWITCHER — PASS 1 / PASS 2 -// ───────────────────────────────────────────────────────────────── -const roleSelect = document.getElementById('role-select'); -const pass2Banner = document.getElementById('pass2-banner'); -const pass2BannerText = document.getElementById('pass2-banner-text'); - -let currentRole = 'pass1'; -let pass2Flags = {}; - -const PASS2_BTN_LABELS = { done: '✓ Pass 2 approved', problem: '⚠ Needs work' }; -const PASS1_BTN_LABELS = { done: '✓ Done', problem: '⚠ Problem' }; - -function getVerifiedPages() { - return Object.keys(state.flags) - .filter(k => state.flags[k] === 'done') - .map(Number) - .sort((a, b) => a - b); -} - -function updatePass2NavButtons() { - const verified = getVerifiedPages(); - // If Pass 1 queue has no done pages yet, fall back to sequential navigation - if (!verified.length) { - btnPrev.disabled = !state.pdfDoc || state.currentPage <= 1; - btnNext.disabled = !state.pdfDoc || state.currentPage >= state.totalPages; - return; - } - if (!state.pdfDoc) { btnPrev.disabled = true; btnNext.disabled = true; return; } - btnPrev.disabled = !verified.some(p => p < state.currentPage); - btnNext.disabled = !verified.some(p => p > state.currentPage); -} - -function updatePass2Banner() { - const count = getVerifiedPages().length; - pass2BannerText.textContent = count === 0 - ? 'Pass 2 mode · No pages in queue' - : 'Pass 2 mode · ' + count + ' page' + (count === 1 ? '' : 's') + ' ready for review'; - updatePass2NavButtons(); -} - -function applyRole(role) { - currentRole = role; - localStorage.setItem('p44-role', role); - roleSelect.value = role; - if (role === 'pass2') { - pass2Banner.style.display = 'flex'; - btnOcrToggle.style.display = 'inline-block'; - btnFlagDone.textContent = PASS2_BTN_LABELS.done; - btnFlagProblem.textContent = PASS2_BTN_LABELS.problem; - updatePass2Banner(); - } else { - pass2Banner.style.display = 'none'; - btnOcrToggle.style.display = 'none'; - state.ocrOverlay = false; - btnOcrToggle.classList.remove('active'); - hideOCROverlay(); - btnFlagDone.textContent = PASS1_BTN_LABELS.done; - btnFlagProblem.textContent = PASS1_BTN_LABELS.problem; - if (state.pdfDoc) { - btnPrev.disabled = state.currentPage <= 1; - btnNext.disabled = state.currentPage >= state.totalPages; - } - } - updateFlagButtons(state.currentPage); -} - -function savePass2Flags() { - try { localStorage.setItem(LS_PASS2_FLAGS_KEY, JSON.stringify(pass2Flags)); } catch (_) {} - fetch(SERVER_PASS2_ENDPOINT, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(pass2Flags), - }).catch(() => {}); -} - -function loadPass2FlagsOnStartup() { - fetch('/' + SERVER_PASS2_FLAGS_FILE) - .then(r => r.ok ? r.json() : Promise.reject()) - .then(data => { pass2Flags = data; }) - .catch(() => { - try { - const stored = localStorage.getItem(LS_PASS2_FLAGS_KEY); - if (stored) pass2Flags = JSON.parse(stored); - } catch (_) {} - }); -} - -function resetPass2() { - if (!confirm( - 'Reset Pass 2?\n\n' + - 'This will clear:\n' + - ' • All Pass 2 approved / needs-work / rejected flags\n' + - ' • All saved text corrections\n\n' + - 'Pass 1 done flags are NOT affected.\n\n' + - 'This cannot be undone.' - )) return; - - // Wipe Pass 2 flags - pass2Flags = {}; - try { localStorage.removeItem(LS_PASS2_FLAGS_KEY); } catch (_) {} - - // Wipe corrections (these are shared with Pass 1 editor but the contamination - // source was Pass 2 auto-edit; clearing gives a clean slate) - state.corrections = {}; - try { localStorage.removeItem(LS_CORRECTIONS_KEY); } catch (_) {} - try { fetch(SERVER_CORRECTIONS_END, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({}) }); } catch (_) {} - - // Refresh UI - updateFlagButtons(state.currentPage); - updateCorrectionIndicator(state.currentPage); - updatePass2Banner(); - if (state.pdfDoc) showOCRPage(state.currentPage); - setStatus('Pass 2 queue reset — all Pass 2 flags and corrections cleared.'); -} - -document.getElementById('btn-pass2-reset').addEventListener('click', resetPass2); - -// ───────────────────────────────────────────────────────────────── -// PASS 2 — CHANGE SUMMARY PANEL (Step 9) -// ───────────────────────────────────────────────────────────────── - -// Walk both word arrays simultaneously and produce a list of changes. -// Simple sequential diff — fast, good enough for plain-text corrections. -function buildChangeSummary(originalText, pass1Text) { - const orig = (originalText || '').trim().split(/\s+/).filter(Boolean); - const edit = (pass1Text || '').trim().split(/\s+/).filter(Boolean); - if (orig.join(' ') === edit.join(' ')) return []; - - const changes = []; - let i = 0, j = 0; - while (i < orig.length || j < edit.length) { - if (changes.length >= 50) { changes.push({ type: 'truncated' }); break; } - if (i >= orig.length) { changes.push({ type: 'added', word: edit[j++] }); } - else if (j >= edit.length) { changes.push({ type: 'deleted', word: orig[i++] }); } - else if (orig[i] === edit[j]) { i++; j++; } - else { changes.push({ type: 'changed', from: orig[i++], to: edit[j++] }); } - } - return changes; -} - -function showPass2ChangeSummary(pageNum) { - const panel = document.getElementById('pass2-change-summary'); - if (!panel) return; - - const corr = state.corrections && state.corrections[pageNum]; - if (!corr || !corr.pass1 || currentRole !== 'pass2') { - panel.style.display = 'none'; - return; - } - - const changes = buildChangeSummary(corr.original || state.ocrPages[pageNum] || '', corr.pass1); - if (!changes.length) { panel.style.display = 'none'; return; } - - const truncated = changes.find(c => c.type === 'truncated'); - const realChanges = changes.filter(c => c.type !== 'truncated'); - const count = truncated ? '50+' : realChanges.length; - - const listHtml = realChanges.map(c => { - if (c.type === 'changed') return `~ "${escH(c.from)}" → "${escH(c.to)}"`; - if (c.type === 'added') return `+ "${escH(c.word)}"`; - if (c.type === 'deleted') return `− "${escH(c.word)}"`; - return ''; - }).join('
'); - - panel.innerHTML = - '
' + count + ' change' + (count === 1 ? '' : 's') + ' from Pass 1
' + - '
' + listHtml + (truncated ? '
…and more' : '') + '
' + - '
' + - '' + - '' + - '
'; - panel.style.display = 'block'; - - const pass1Text = corr.pass1; - - document.getElementById('btn-cs-revert-pass1').addEventListener('click', () => { - if (!confirm('Revert to Pass 1 text?\n\nYour Pass 2 changes on this page will be lost.')) return; - textEditor.value = pass1Text; - setStatus('Reverted to Pass 1 text — navigate away or click Edit to save.'); - }); - - document.getElementById('btn-cs-revert-original').addEventListener('click', () => { - const typed = prompt( - 'This will discard ALL corrections on this page — both Pass 1 and Pass 2.\n\nType REVERT to confirm:' - ); - if (typed !== 'REVERT') { setStatus('Revert cancelled.'); return; } - textEditor.value = state.ocrPages[pageNum] || ''; - delete state.corrections[pageNum]; - postCorrection(pageNum, null); - updateCorrectionIndicator(pageNum); - panel.style.display = 'none'; - setStatus('Reverted to original OCR — all corrections on this page cleared.'); - }); -} - -roleSelect.addEventListener('change', () => applyRole(roleSelect.value)); - -// ───────────────────────────────────────────────────────────────── -// PHASE 4 — FLAG / ANNOTATION SYSTEM -// ───────────────────────────────────────────────────────────────── -function updateFlagButtons(pageNum) { - const flagSrc = currentRole === 'pass2' ? pass2Flags : state.flags; - const flag = flagSrc[pageNum] || null; - btnFlagDone.classList.toggle('active', flag === 'done'); - btnFlagProblem.classList.toggle('active', flag === 'problem'); - - // Done pages are read-only in Pass 1 — disable flag buttons - const isDone = currentRole === 'pass1' && flag === 'done'; - btnFlagDone.disabled = isDone; - btnFlagProblem.disabled = isDone; -} - -function updateFlagsSummary() { - let done = 0, problem = 0; - Object.values(state.flags).forEach(f => { - if (f === 'done') done++; - else if (f === 'problem') problem++; - }); - flagsSummary.textContent = done || problem - ? '✓ ' + done + (problem ? ' ⚠ ' + problem : '') - : ''; - if (currentRole === 'pass2') updatePass2Banner(); -} - -async function saveFlags() { - try { - const res = await fetch(SERVER_FLAGS_ENDPOINT, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(state.flags), - }); - if (res.ok) return; - } catch (_) {} - // Server doesn't support it (or offline) — fall back to localStorage - try { localStorage.setItem(LS_FLAGS_KEY, JSON.stringify(state.flags)); } catch (_) {} -} - -function setFlag(type) { - const p = state.currentPage; - - if (currentRole === 'pass2') { - if (type === null) { delete pass2Flags[p]; } else { pass2Flags[p] = type; } - updateFlagButtons(p); - savePass2Flags(); - return; - } - - // Pass 1 — Done is one-way: once set it cannot be cleared by the volunteer - if (state.flags[p] === 'done') return; - - // Problem is toggleable — clicking it again removes it - if (type === 'problem' && state.flags[p] === 'problem') { - delete state.flags[p]; - } else if (type === null) { - delete state.flags[p]; - } else { - state.flags[p] = type; - } - - updateFlagButtons(p); - updateFlagsSummary(); - saveFlags(); - // Re-render page immediately so done-lock (readOnly) takes effect in Pass 1 - if (type === 'done' && currentRole === 'pass1') showOCRPage(state.currentPage); -} - -btnFlagDone.addEventListener('click', () => setFlag('done')); -btnFlagProblem.addEventListener('click', () => setFlag('problem')); - -async function loadFlagsOnStartup() { - try { - const res = await fetch('/' + SERVER_FLAGS_FILE); - if (res.ok) { - state.flags = await res.json(); - updateFlagsSummary(); - return; - } - } catch (_) {} - // Fallback: localStorage - try { - const stored = localStorage.getItem(LS_FLAGS_KEY); - if (stored) { state.flags = JSON.parse(stored); updateFlagsSummary(); } - } catch (_) {} -} - -function clearPass1Queue() { - const doneCount = Object.values(state.flags).filter(f => f === 'done').length; - if (doneCount === 0) { - setStatus('Pass 1 queue is already empty — no done pages to clear.'); - return; - } - if (!confirm( - 'Clear Pass 1 queue?\n\n' + - 'This will remove all ' + doneCount + ' done flag(s) from Pass 1.\n' + - 'The Pass 2 review queue will become empty.\n\n' + - 'Problem flags are NOT affected.\n' + - 'This cannot be undone.' - )) return; - - // Remove all done flags; keep problem flags intact - Object.keys(state.flags).forEach(k => { - if (state.flags[k] === 'done') delete state.flags[k]; - }); - updateFlagButtons(state.currentPage); - updateFlagsSummary(); // also calls updatePass2Banner if in pass2 - saveFlags(); - setStatus('Pass 1 queue cleared — ' + doneCount + ' done flag(s) removed. Pass 2 queue is now empty.'); -} - -document.getElementById('btn-clear-p1-queue').addEventListener('click', clearPass1Queue); - -function clearAllLocalData() { - if (!confirm( - 'Clear ALL saved data?\n\n' + - 'This will wipe:\n' + - ' • All Pass 1 and Pass 2 flags\n' + - ' • All text corrections\n' + - ' • Role setting\n' + - ' • Diff legend preference\n\n' + - 'This cannot be undone.' - )) return; - - // Clear localStorage - localStorage.removeItem(LS_CORRECTIONS_KEY); - localStorage.removeItem(LS_FLAGS_KEY); - localStorage.removeItem(LS_PASS2_FLAGS_KEY); - localStorage.removeItem('p44-role'); - - // Reset in-memory state - state.corrections = {}; - state.flags = {}; - pass2Flags = {}; - - // Reset role to Pass 1 - applyRole('pass1'); - - // Clear server-side files (best-effort) - fetch(SERVER_CLEAR_ENDPOINT, { method: 'POST' }).catch(() => {}); - - // Refresh UI - updateFlagButtons(state.currentPage); - updateFlagsSummary(); - updateCorrectionIndicator(state.currentPage); - if (state.pdfDoc) showOCRPage(state.currentPage); - setStatus('All saved data cleared — fresh start.'); -} - -document.getElementById('btn-clear-all').addEventListener('click', clearAllLocalData); - -// ───────────────────────────────────────────────────────────────── -// LINE NUMBERS — update and scroll-sync with textarea -// ───────────────────────────────────────────────────────────────── - -// Returns a newline-joined string of line number labels that accounts for -// soft-wrapped visual rows in the textarea. Wrapped continuation rows are -// represented by empty strings so the gutter stays in sync with scroll. -function buildWrappedLineNumbers(text, textarea) { - const lines = text.split('\n'); - if (!lines.length) return ''; - - // Lazily create a hidden canvas for text measurement (reused across calls). - if (!buildWrappedLineNumbers._canvas) { - buildWrappedLineNumbers._canvas = document.createElement('canvas'); - } - const ctx = buildWrappedLineNumbers._canvas.getContext('2d'); - const style = window.getComputedStyle(textarea); - // Compose the CSS font shorthand that canvas expects - ctx.font = style.fontSize + ' ' + style.fontFamily; - - // Width available for text inside the textarea (exclude horizontal padding). - const paddingL = parseFloat(style.paddingLeft) || 0; - const paddingR = parseFloat(style.paddingRight) || 0; - const availWidth = Math.max(50, textarea.clientWidth - paddingL - paddingR); - - const result = []; - lines.forEach((line, i) => { - result.push(String(i + 1)); - if (line.length > 0) { - // Estimate the number of extra visual rows this line wraps onto. - // We measure total line width and floor-divide by the available width. - // This is an approximation — word-wrap boundaries differ slightly — - // but it keeps the gutter aligned for typical OCR paragraph lengths. - const lineWidth = ctx.measureText(line).width; - const extraRows = Math.max(0, Math.floor(lineWidth / availWidth)); - for (let r = 0; r < extraRows; r++) result.push(''); - } - }); - - return result.join('\n'); -} - -function updateLineNumbers() { - lineNumbers.textContent = buildWrappedLineNumbers(textEditor.value, textEditor); - syncLineNumbers(); -} - -function syncLineNumbers() { - lineNumbers.scrollTop = textEditor.scrollTop; -} - -textEditor.addEventListener('input', updateLineNumbers); -textEditor.addEventListener('scroll', syncLineNumbers); - -// ───────────────────────────────────────────────────────────────── -// PHASE 5 — INLINE TEXT EDITING -// ───────────────────────────────────────────────────────────────── - -function updateCorrectionIndicator(pageNum) { - const corr = state.corrections && state.corrections[pageNum]; - const hasEdit = corr && (corr.pass1 || corr.pass2); - ocrSyncLabel.style.color = hasEdit ? 'var(--gold)' : 'var(--gold-dim)'; -} - -async function postCorrection(pageNum, corrObj) { - const body = JSON.stringify({ page: pageNum, correction: corrObj }); - try { - const res = await fetch(SERVER_CORRECTIONS_END, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body, - }); - if (res.ok) return; - } catch (_) {} - try { - const stored = JSON.parse(localStorage.getItem(LS_CORRECTIONS_KEY) || '{}'); - if (corrObj) { stored[pageNum] = corrObj; } else { delete stored[pageNum]; } - localStorage.setItem(LS_CORRECTIONS_KEY, JSON.stringify(stored)); - } catch (_) {} -} - -async function loadCorrectionsOnStartup() { - let loaded = null; - try { - const res = await fetch('/' + SERVER_CORRECTIONS_FILE); - if (res.ok) loaded = await res.json(); - } catch (_) {} - if (!loaded) { - try { - const stored = localStorage.getItem(LS_CORRECTIONS_KEY); - if (stored) loaded = JSON.parse(stored); - } catch (_) {} - } - if (loaded) { - // Migrate old flat-string corrections to new object format - Object.keys(loaded).forEach(k => { - if (typeof loaded[k] === 'string') { - if (loaded[k].trim()) { - loaded[k] = { pass1: loaded[k].trim() }; - } else { - delete loaded[k]; - } - } else if (!loaded[k] || (!loaded[k].pass1 && !loaded[k].pass2)) { - delete loaded[k]; - } - }); - state.corrections = loaded; - try { localStorage.setItem(LS_CORRECTIONS_KEY, JSON.stringify(state.corrections)); } catch (_) {} - } -} - -// ───────────────────────────────────────────────────────────────── -// PASS 2 REVERT BAR -// ───────────────────────────────────────────────────────────────── -function showPass2RevertBar(pageNum) { - const bar = document.getElementById('pass2-revert-bar'); - if (currentRole !== 'pass2') { bar.style.display = 'none'; return; } - - const corr = state.corrections && state.corrections[pageNum]; - if (!corr || !corr.pass1) { bar.style.display = 'none'; return; } - - const originalText = corr.original || state.ocrPages[pageNum] || ''; - const pass1Text = corr.pass1; - - bar.innerHTML = - 'Pass 1 corrections on this page' + - '' + - ''; - bar.style.display = 'flex'; - - document.getElementById('btn-revert-pass1').addEventListener('click', () => { - if (!confirm('Revert to Pass 1 text?\n\nYour Pass 2 changes on this page will be lost.')) return; - textEditor.value = pass1Text; - setStatus('Reverted to Pass 1 text — navigate away or click Edit to save.'); - }); - - document.getElementById('btn-revert-original').addEventListener('click', () => { - const typed = prompt( - 'This will discard ALL corrections on this page — both Pass 1 and Pass 2.\n\n' + - 'Type REVERT to confirm:' - ); - if (typed !== 'REVERT') { setStatus('Revert cancelled.'); return; } - textEditor.value = originalText; - delete state.corrections[pageNum]; - postCorrection(pageNum, null); - updateCorrectionIndicator(pageNum); - bar.style.display = 'none'; - setStatus('Reverted to original OCR — all corrections on this page cleared.'); - }); -} - -function saveCurrentPage() { - const pageNum = parseInt(textEditor.dataset.page, 10); - if (!pageNum) return; - const editedText = textEditor.value.trim(); - const passKey = currentRole === 'pass2' ? 'pass2' : 'pass1'; - const originalText = (state.corrections[pageNum] && state.corrections[pageNum].original) - || (state.ocrPages[pageNum] || '').trim(); - - if (!state.corrections[pageNum]) state.corrections[pageNum] = {}; - state.corrections[pageNum].original = state.corrections[pageNum].original || originalText; - - if (editedText && editedText !== originalText) { - state.corrections[pageNum][passKey] = editedText; - } else { - delete state.corrections[pageNum][passKey]; - } - - if (!state.corrections[pageNum].pass1 && !state.corrections[pageNum].pass2) { - delete state.corrections[pageNum]; - } - - postCorrection(pageNum, state.corrections[pageNum] || null); - updateCorrectionIndicator(pageNum); -} - -// ───────────────────────────────────────────────────────────────── -// PHASE 6 — KEYBOARD NAVIGATION -// ───────────────────────────────────────────────────────────────── -document.addEventListener('keydown', e => { - // Skip shortcuts when typing in the page-number input or the editor textarea - const tag = document.activeElement && document.activeElement.tagName; - if (tag === 'INPUT' && document.activeElement.id === 'page-input') return; - if (tag === 'TEXTAREA') return; - - switch (e.key) { - case 'ArrowRight': case 'l': - e.preventDefault(); goToPage(state.currentPage + 1); break; - case 'ArrowLeft': case 'j': - e.preventDefault(); goToPage(state.currentPage - 1); break; - case 'v': - setFlag('done'); break; - case 'p': - setFlag('problem'); break; - } -}); - -// ───────────────────────────────────────────────────────────────── -// HOOK INTO showOCRPage TO APPLY FLAGS/CORRECTIONS/EDIT STATE -// ───────────────────────────────────────────────────────────────── -// Override goToPage to save edits before navigating -function goToPage(n) { - saveCurrentPage(); - - if (!state.pdfDoc) return; - n = Math.max(1, Math.min(state.totalPages, n)); - if (state.ocrOverlay) showOCROverlay(n); - if (n === state.currentPage && !state.rendering) return; - if (state.rendering) { _pendingPage = n; return; } - state.currentPage = n; - renderCurrentPage(); -} - -// ───────────────────────────────────────────────────────────────── -// STARTUP — load persistent data -// ───────────────────────────────────────────────────────────────── -loadPass2FlagsOnStartup(); -loadFlagsOnStartup(); -loadCorrectionsOnStartup(); -// Restore saved role after all DOM refs are ready -(function() { - const saved = localStorage.getItem('p44-role') || 'pass1'; - applyRole(saved); -})(); - - - - - - - - - -