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
-
-
-
-
War diaries available for transcription
-
-
-
-
-
-
-
-
-
-
-
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
-
-
-
-
-
-
-
-
-
-
-
-
- Pass 2 mode · 0 pages ready for review
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- War diary scan loads automatically.
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- 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);
-})();
-
-
-
-
-
-
-
-
-
-