OCR-Viewer (#1)
Co-authored-by: nathan <nathan.kehler@gmail.com> Reviewed-on: #1
This commit is contained in:
721
prompts/ocr-viewer-prompt.md
Normal file
721
prompts/ocr-viewer-prompt.md
Normal file
@@ -0,0 +1,721 @@
|
||||
# P44 OCR Viewer — Rebuild Prompts
|
||||
*Reference: ocr-viewer-audit.md — read this before starting any step.*
|
||||
*Each step must be confirmed working before the next step starts.*
|
||||
*Three files: p44-ocr-viewer.html / p44-ocr-viewer.css / p44-ocr-viewer.js*
|
||||
|
||||
---
|
||||
|
||||
## Before you start
|
||||
|
||||
Read `ocr-viewer-audit.md` in full. It contains the complete audit of the previous codebase, the root causes of every bug we hit, and the rebuild specification. Every architectural decision in these prompts comes from that document.
|
||||
|
||||
Key rules that must never be violated in this rebuild:
|
||||
- `#text-output` is display only — never set `contentEditable` on it
|
||||
- `#text-editor` is the only edit surface — always a `<textarea>`, always plain text
|
||||
- No markdown parser — OCR output is plain text, display with `<pre>`
|
||||
- No confidence scoring — removed by design
|
||||
- Each step confirmed working before the next begins
|
||||
|
||||
---
|
||||
|
||||
## Step 1 — HTML/CSS shell
|
||||
|
||||
**No JavaScript. Layout and visual design only.**
|
||||
|
||||
Create three empty files: `p44-ocr-viewer.html`, `p44-ocr-viewer.css`, `p44-ocr-viewer.js`
|
||||
|
||||
Build the complete HTML structure and CSS. Nothing should be functional.
|
||||
|
||||
**HTML structure required:**
|
||||
|
||||
```
|
||||
#app (grid: toolbar / pass2-banner / panels / footer)
|
||||
|
||||
#toolbar
|
||||
h1 "⚜ P44 OCR VIEWER"
|
||||
#btn-load-ch button "▶ Calgary Highlanders — Sep 44"
|
||||
PDF override: label.file-btn > input#input-pdf[type=file accept=.pdf]
|
||||
OCR override: label.file-btn > input#input-text[type=file accept=.txt,.md]
|
||||
JSON override: label.file-btn > input#input-json[type=file accept=.json]
|
||||
#btn-example "Load sample"
|
||||
#btn-sync-scroll "Sync Scroll" (class active by default)
|
||||
role switcher: label "ROLE" + select#role-select
|
||||
option value="pass1" "Pass 1 — Volunteer"
|
||||
option value="pass2" "Pass 2 — Senior Editor"
|
||||
#btn-clear-all "⚠ Clear all data" (red-tinted border and text)
|
||||
|
||||
#pass2-banner (display:none by default)
|
||||
span#pass2-banner-text
|
||||
button#btn-pass2-reset "↺ Reset Pass 2"
|
||||
|
||||
#panels (two-column flex)
|
||||
|
||||
Left panel div.panel
|
||||
div.panel-header
|
||||
span "Source Document — Page Scan"
|
||||
span#zoom-label "100%"
|
||||
button#btn-zoom-reset "1:1"
|
||||
div.page-nav
|
||||
button#btn-prev "◀" disabled
|
||||
input#page-input type=number value=1 min=1
|
||||
button#btn-next "▶" disabled
|
||||
span#page-label "/ —"
|
||||
div.panel-body#image-panel-body
|
||||
div#image-placeholder
|
||||
div#img-spinner style=display:none
|
||||
div#img-ph-text "War diary scan loads automatically."
|
||||
div#pdf-container style=display:none
|
||||
canvas#pdf-canvas
|
||||
div#overlay
|
||||
|
||||
Right panel div.panel
|
||||
div.panel-header
|
||||
span "OCR Text Output"
|
||||
span#ocr-sync-label
|
||||
div#correction-dot style=display:none title="This page has been edited"
|
||||
button#btn-flag-verified class="tb-btn flag-btn" "✓ Verified"
|
||||
button#btn-flag-review class="tb-btn flag-btn" "⚑ Review"
|
||||
button#btn-flag-error class="tb-btn flag-btn" "✗ Error"
|
||||
button#btn-flag-clear class="tb-btn flag-btn" "Clear"
|
||||
button#btn-edit class="tb-btn" "Edit"
|
||||
div.panel-body#text-panel-body
|
||||
div#text-placeholder
|
||||
div#txt-spinner style=display:none
|
||||
div#txt-ph-text "OCR transcript loads automatically."
|
||||
div#text-output style=display:none
|
||||
textarea#text-editor style=display:none
|
||||
|
||||
#footer
|
||||
span#status-msg "Ready."
|
||||
span#flags-summary
|
||||
span#kbd-hint "← → navigate v/r/e flag esc close"
|
||||
```
|
||||
|
||||
**CSS variables — use exactly these:**
|
||||
```css
|
||||
:root {
|
||||
--bg: #1a1a2e;
|
||||
--surface: #16213e;
|
||||
--surface2: #0f3460;
|
||||
--gold: #c8a84b;
|
||||
--gold-dim: #8a6f2e;
|
||||
--text: #ddd4bb;
|
||||
--text-dim: #7a7260;
|
||||
--text-muted:#4a4538;
|
||||
--border: #2e2e4e;
|
||||
--green: #4caf70;
|
||||
--green-bg: #1a3326;
|
||||
--amber: #e8a838;
|
||||
--amber-bg: #2e2010;
|
||||
--red: #d05050;
|
||||
--red-bg: #2e1a1a;
|
||||
--toolbar-h: 52px;
|
||||
--footer-h: 34px;
|
||||
}
|
||||
```
|
||||
|
||||
**CSS layout rules:**
|
||||
- `#app`: CSS grid `grid-template-rows: var(--toolbar-h) auto 1fr var(--footer-h)`, `height: 100vh`, `overflow: hidden`
|
||||
- `#panels`: flex row, each `.panel` takes `width: 50%`, `display: flex`, `flex-direction: column`, `overflow: hidden`
|
||||
- `.panel-body`: `flex: 1`, `overflow-y: auto`, `overflow-x: hidden`
|
||||
- `#pass2-banner`: `display: none`, slim flex row, `background: #1a1200`, `border-bottom: 1px solid var(--gold-dim)`, `padding: 6px 14px`, `font-size: 11px`, `color: var(--amber)`
|
||||
- `#text-editor`: `display: none`, `width: 100%`, `height: 100%`, `resize: none`, `font-family: Georgia serif`, `font-size: 12.5px`, `line-height: 1.75`, `background: var(--surface2)`, `color: var(--text)`, `border: none`, `border-top: 1px solid var(--gold-dim)`, `padding: 16px 20px`, `box-sizing: border-box`, `outline: none`
|
||||
- `#text-editor:focus`: `border-top-color: var(--gold)`
|
||||
- `.tb-btn`: `padding: 3px 9px`, `background: transparent`, `border: 1px solid var(--gold-dim)`, `border-radius: 3px`, `color: var(--text-dim)`, `font-family: Georgia serif`, `font-size: 10px`, `cursor: pointer`, `letter-spacing: 0.04em`
|
||||
- `.tb-btn:hover`: `border-color: var(--gold)`, `color: var(--gold)`
|
||||
- `.tb-btn.active`: `border-color: var(--gold)`, `color: var(--gold)`
|
||||
- `.file-btn`: same as `.tb-btn` but as a `<label>` with hidden `<input type="file">` inside
|
||||
- `#btn-clear-all`: `border-color: #d05050`, `color: #d05050`
|
||||
- `#btn-clear-all:hover`: `border-color: #e07070`, `color: #e07070`
|
||||
- `.flag-btn.active`: `border-color: var(--gold)`, `color: var(--gold)`
|
||||
- `#correction-dot`: `width: 8px`, `height: 8px`, `border-radius: 50%`, `background: var(--gold)`, `display: none`, `flex-shrink: 0`
|
||||
- `.panel-header`: `display: flex`, `align-items: center`, `gap: 6px`, `padding: 0 10px`, `height: 38px`, `background: var(--surface)`, `border-bottom: 1px solid var(--border)`, `flex-shrink: 0`, `font-size: 9px`, `letter-spacing: 0.1em`, `color: var(--text-dim)`, `text-transform: uppercase`
|
||||
- `#toolbar`: `display: flex`, `align-items: center`, `gap: 8px`, `padding: 0 12px`, `background: var(--surface)`, `border-bottom: 2px solid var(--gold-dim)`
|
||||
- `#toolbar h1`: `font-size: 13px`, `font-weight: normal`, `letter-spacing: 0.1em`, `color: var(--gold)`, `white-space: nowrap`
|
||||
- `.divider`: `width: 1px`, `height: 26px`, `background: var(--border)`, `flex-shrink: 0`
|
||||
- `#footer`: `display: flex`, `align-items: center`, `justify-content: space-between`, `padding: 0 14px`, `background: var(--surface)`, `border-top: 1px solid var(--border)`, `font-size: 10px`, `color: var(--text-dim)`
|
||||
- `#pdf-container`: `position: relative`, `display: inline-block`
|
||||
- `#overlay`: `position: absolute`, `top: 0`, `left: 0`, `pointer-events: none`
|
||||
- `.word-box`: `position: absolute`, `cursor: crosshair`, `border: 1px solid transparent`, `border-radius: 2px`
|
||||
- `.word-box:hover, .word-box.highlight`: `background: rgba(200,168,75,0.18)`, `border-color: var(--gold-dim)`
|
||||
- `.panel-placeholder`: `display: flex`, `flex-direction: column`, `align-items: center`, `justify-content: center`, `height: 100%`, `color: var(--text-dim)`, `font-size: 12px`, `text-align: center`, `gap: 10px`
|
||||
- `.spinner`: `width: 24px`, `height: 24px`, `border: 2px solid var(--border)`, `border-top-color: var(--gold)`, `border-radius: 50%`, `animation: spin 0.8s linear infinite`
|
||||
- `@keyframes spin`: `to { transform: rotate(360deg) }`
|
||||
- `#page-input`: `width: 48px`, `text-align: center`, `background: var(--surface2)`, `border: 1px solid var(--border)`, `color: var(--text)`, `font-family: Georgia serif`, `font-size: 12px`, `padding: 2px 4px`, `border-radius: 3px`
|
||||
- `select#role-select`: same styling as `.tb-btn` but as a select element, `background: var(--surface2)`
|
||||
- `#ocr-sync-label`: `font-size: 9px`, `color: var(--gold-dim)`, `letter-spacing: 0.06em`
|
||||
|
||||
**Do not write any JavaScript yet.**
|
||||
|
||||
Confirm when complete by listing every `id` and every CSS class defined.
|
||||
|
||||
---
|
||||
|
||||
## Step 2 — PDF loading and page navigation
|
||||
|
||||
**Add to `p44-ocr-viewer.js` only. Do not change HTML or CSS.**
|
||||
|
||||
Add the following in this order:
|
||||
|
||||
1. PDF.js worker setup:
|
||||
```javascript
|
||||
if (typeof pdfjsLib !== 'undefined') {
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc =
|
||||
'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js';
|
||||
}
|
||||
```
|
||||
|
||||
2. Data paths:
|
||||
```javascript
|
||||
const REAL_PDF_PATH = './Inputs/Calgary-Highlanders_War-Diary_Sep44.pdf';
|
||||
const REAL_TEXT_PATH = './Inputs/ocr-output/Calgary-Highlanders_War-Diary_Sep44_olmocr.md';
|
||||
```
|
||||
|
||||
3. State object — only these properties to start:
|
||||
```javascript
|
||||
const state = {
|
||||
mode: 'none', // 'pdf' | 'image' | 'none'
|
||||
pdfDoc: null,
|
||||
currentPage: 1,
|
||||
totalPages: 0,
|
||||
zoomScale: 1.0, // Ctrl+scroll zoom multiplier (0.5–4.0)
|
||||
wordData: [],
|
||||
rendering: false,
|
||||
lastRenderTime: 0,
|
||||
ocrPages: {}, // { [pageNum]: string } — populated in Step 3
|
||||
flags: {}, // populated in Step 5
|
||||
corrections: {}, // populated in Step 6
|
||||
editMode: false, // populated in Step 6
|
||||
};
|
||||
```
|
||||
|
||||
4. All DOM refs (getElementById for every id in the HTML)
|
||||
|
||||
5. `escH(s)` utility function
|
||||
|
||||
6. `setStatus(msg, warn)` utility function
|
||||
|
||||
7. `showImgSpinner(on)`, `showTxtSpinner(on)`, `showImgPlaceholder(html)`, `showTxtPlaceholder(html)` utility functions
|
||||
|
||||
8. `loadPDFBuffer(arrayBuffer)` — loads PDF into PDF.js, sets `state.pdfDoc`, `state.totalPages`, `state.mode = 'pdf'`, calls `renderCurrentPage()`
|
||||
|
||||
9. `renderCurrentPage()` — fits page to panel width, applies `state.zoomScale`, uses DPR for HiDPI, renders to `#pdf-canvas`, calls `buildPDFWordOverlay()`. Wrap `showOCRPage()` call in try/catch (showOCRPage is added in Step 3). Include `_pendingPage` queue and debounce guard.
|
||||
|
||||
10. `buildPDFWordOverlay(page, vp, dpr)` — builds word boxes from `state.wordData` if loaded, otherwise leaves overlay blank
|
||||
|
||||
11. `makeWordBox(id, x, y, w, h, title)` — creates positioned div with hover highlight
|
||||
|
||||
12. `goToPage(n)` — validates range, guards against re-render of same page, calls `renderCurrentPage()`. No edit mode save yet (added in Step 6).
|
||||
|
||||
13. `_pendingPage = null` module variable
|
||||
|
||||
14. `updateZoomLabel()` — updates `#zoom-label` text
|
||||
|
||||
15. Event listeners:
|
||||
- `#btn-prev` click → `goToPage(state.currentPage - 1)`
|
||||
- `#btn-next` click → `goToPage(state.currentPage + 1)`
|
||||
- `#page-input` change → `goToPage(parseInt(value))`
|
||||
- `#btn-zoom-reset` click → reset `state.zoomScale` to 1.0, re-render
|
||||
- `window resize` → re-render if PDF loaded and not rendering
|
||||
- `#input-pdf` change → read file, call `loadPDFBuffer()`
|
||||
- `#input-json` change → read file, parse JSON, set `state.wordData`, re-render
|
||||
|
||||
16. Ctrl+scroll zoom on `#image-panel-body`:
|
||||
- **MUST check `e.ctrlKey` — only zoom when Ctrl is held**
|
||||
- Only call `e.preventDefault()` when `e.ctrlKey` is true
|
||||
- Without Ctrl: let the event pass through normally for scrolling
|
||||
- With Ctrl: adjust `state.zoomScale` by ±0.1, clamp to 0.5–4.0, re-render
|
||||
- After re-render, scroll to keep cursor anchor point in view
|
||||
|
||||
17. `DOMContentLoaded` auto-load:
|
||||
```javascript
|
||||
window.addEventListener('DOMContentLoaded', () => {
|
||||
if (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1') {
|
||||
setTimeout(loadCalgaryHighlanders, 200);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
18. `loadCalgaryHighlanders()` — fetches PDF only for now (OCR text fetch added in Step 3). Uses `Promise.allSettled`.
|
||||
|
||||
**Test: PDF loads, pages navigate with buttons and keyboard input, zoom works with Ctrl+scroll, normal scroll works without Ctrl.**
|
||||
|
||||
---
|
||||
|
||||
## Step 3 — OCR text loading and plain text display
|
||||
|
||||
**Add to `p44-ocr-viewer.js` only. Do not change HTML or CSS.**
|
||||
|
||||
1. `parseOCRByPage(rawText)`:
|
||||
- Splits on `## Page N` headings
|
||||
- Stores content between headings in `state.ocrPages[N]`
|
||||
- **Does NOT include the `## Page N` heading line in the content**
|
||||
- `buf = []` not `buf = [line]`
|
||||
|
||||
2. `loadOCRText(rawText)`:
|
||||
- Calls `parseOCRByPage(rawText)`
|
||||
- Does NOT call `showOCRPage()` — only calls it if `state.pdfDoc` is already loaded
|
||||
- This prevents double-render on startup
|
||||
|
||||
3. `showOCRPage(pageNum)`:
|
||||
- Clears `textOutput.innerHTML = ''`
|
||||
- Sets `textOutput.dataset.page = pageNum`
|
||||
- Hides placeholder, shows `textOutput`
|
||||
- If no OCR pages loaded: shows "OCR text not loaded yet" message
|
||||
- If no content for this page: shows "No OCR text for page N" message
|
||||
- Gets display text: `corrections[pageNum]?.pass1 || state.ocrPages[pageNum]` (corrections object added in Step 6, guard with optional chaining for now)
|
||||
- Renders as: `<pre style="white-space:pre-wrap;font-family:inherit;font-size:12.5px;line-height:1.75;padding:16px 20px;">{escH(displayText)}</pre>`
|
||||
- Scrolls text panel to top
|
||||
- Updates `#ocr-sync-label` text
|
||||
|
||||
4. Update `loadCalgaryHighlanders()` to also fetch `REAL_TEXT_PATH` and call `loadOCRText()`
|
||||
|
||||
5. Add `#input-text` change event listener → `loadOCRText()`
|
||||
|
||||
6. Add `showOCRPage(state.currentPage)` call inside `renderCurrentPage()` after PDF page renders
|
||||
|
||||
**Test: OCR text loads alongside PDF, correct page shown for each page number, no `## Page N` heading visible in text panel, plain text renders correctly.**
|
||||
|
||||
---
|
||||
|
||||
## Step 4 — Synchronized scrolling
|
||||
|
||||
**Add to `p44-ocr-viewer.js` only. Do not change HTML or CSS.**
|
||||
|
||||
1. Module variables:
|
||||
```javascript
|
||||
let _syncScrolling = false;
|
||||
let _syncActive = true;
|
||||
```
|
||||
|
||||
2. `onImgScroll()` — when left panel scrolls, scroll right panel to same proportional position. Use `requestAnimationFrame` to reset `_syncScrolling` flag.
|
||||
|
||||
3. `onTxtScroll()` — when right panel scrolls, scroll left panel to same proportional position.
|
||||
|
||||
4. Attach scroll listeners to `#image-panel-body` and `#text-panel-body`
|
||||
|
||||
5. `#btn-sync-scroll` click → toggle `_syncActive`, toggle `.active` class on button
|
||||
|
||||
**Test: Scrolling either panel scrolls the other proportionally. Toggle button disables sync. Normal mouse wheel scroll works on left panel (no zoom without Ctrl).**
|
||||
|
||||
---
|
||||
|
||||
## Step 5 — Flag system
|
||||
|
||||
**Add to `p44-ocr-viewer.js` only. Do not change HTML or CSS.**
|
||||
|
||||
1. `updateFlagButtons(pageNum)` — toggles `.active` class on flag buttons based on `state.flags[pageNum]`
|
||||
|
||||
2. `updateFlagsSummary()` — counts verified/review/error flags, updates `#flags-summary` text
|
||||
|
||||
3. `setFlag(type)` — sets or clears `state.flags[state.currentPage]`, calls `updateFlagButtons()`, `updateFlagsSummary()`, `saveFlags()`
|
||||
|
||||
4. `saveFlags()`:
|
||||
- POST to `/save-flags` with `state.flags` as JSON body
|
||||
- On failure: fallback to `localStorage.setItem('p44-flags', ...)`
|
||||
|
||||
5. `loadFlagsOnStartup()`:
|
||||
- Fetch `/flags.json`
|
||||
- On failure: fallback to `localStorage.getItem('p44-flags')`
|
||||
- Call `updateFlagsSummary()` after loading
|
||||
|
||||
6. `clearPass1Queue()` — removes all `verified` flags, keeps review/error, confirm dialog required
|
||||
|
||||
7. Event listeners:
|
||||
- `#btn-flag-verified` → `setFlag('verified')`
|
||||
- `#btn-flag-review` → `setFlag('review')`
|
||||
- `#btn-flag-error` → `setFlag('error')`
|
||||
- `#btn-flag-clear` → `setFlag(null)`
|
||||
- `#btn-clear-p1-queue` → `clearPass1Queue()`
|
||||
|
||||
8. Call `loadFlagsOnStartup()` on `DOMContentLoaded`
|
||||
|
||||
9. Call `updateFlagButtons(pageNum)` at the end of `showOCRPage()`
|
||||
|
||||
**Test: Flags save and persist across refresh. Footer summary updates. Clear queue removes only verified flags.**
|
||||
|
||||
---
|
||||
|
||||
## Step 6 — Edit mode (Pass 1 only)
|
||||
|
||||
**This is the most critical step. Read the architecture rules before starting.**
|
||||
|
||||
**Architecture rules — must not be violated:**
|
||||
- `#text-output` is NEVER set to `contentEditable`. It is display only.
|
||||
- `#text-editor` (`<textarea>`) is the ONLY edit surface.
|
||||
- When edit mode is active: hide `#text-output`, show `#text-editor`
|
||||
- When edit mode is inactive: hide `#text-editor`, show `#text-output`
|
||||
- `#text-editor` always contains plain text — never HTML
|
||||
|
||||
**Correction data model:**
|
||||
```javascript
|
||||
state.corrections[pageNum] = {
|
||||
original: string, // raw OCR snapshot, taken ONCE on first edit-open, never overwritten
|
||||
pass1: string, // Pass 1 saved text (absent if unchanged from original)
|
||||
pass2: string, // Pass 2 saved text (added in Step 8, absent for now)
|
||||
}
|
||||
```
|
||||
|
||||
**Add to `p44-ocr-viewer.js`:**
|
||||
|
||||
1. `updateCorrectionIndicator(pageNum)` — shows/hides `#correction-dot` based on whether `corrections[pageNum]?.pass1` exists
|
||||
|
||||
2. `postCorrection(pageNum, corrObj)`:
|
||||
- POST to `/save-corrections` with `{ page: pageNum, correction: corrObj }`
|
||||
- On failure: fallback to localStorage `p44-corrections`
|
||||
|
||||
3. `loadCorrectionsOnStartup()`:
|
||||
- Fetch `/corrections.json`
|
||||
- On failure: fallback to localStorage
|
||||
- Remove any entries where both `pass1` and `pass2` are absent
|
||||
- Set `state.corrections`
|
||||
|
||||
4. `enterEditMode()`:
|
||||
```
|
||||
- Get pageNum = state.currentPage
|
||||
- If corrections[pageNum] does not exist: create { original: state.ocrPages[pageNum].trim() }
|
||||
- If corrections[pageNum].original does not exist: set it to state.ocrPages[pageNum].trim()
|
||||
- Set textEditor.value = corrections[pageNum].pass1 || corrections[pageNum].original
|
||||
- Set textEditor.dataset.page = pageNum
|
||||
- Hide textOutput (display:none)
|
||||
- Show textEditor (display:block)
|
||||
- Focus textEditor
|
||||
- Set state.editMode = true
|
||||
- Update btnEdit text to "Editing", add .active class
|
||||
```
|
||||
|
||||
5. `exitEditMode()`:
|
||||
```
|
||||
- If !state.editMode: return
|
||||
- Get pageNum = parseInt(textEditor.dataset.page)
|
||||
- Get editedText = textEditor.value.trim()
|
||||
- Get originalText = corrections[pageNum]?.original || state.ocrPages[pageNum]?.trim() || ''
|
||||
- If editedText !== originalText AND editedText is not empty:
|
||||
corrections[pageNum].pass1 = editedText
|
||||
Else:
|
||||
delete corrections[pageNum].pass1
|
||||
- If neither pass1 nor pass2 exist: delete corrections[pageNum] entirely
|
||||
- Call postCorrection(pageNum, corrections[pageNum] || null)
|
||||
- Call updateCorrectionIndicator(pageNum)
|
||||
- Hide textEditor (display:none), clear textEditor.value
|
||||
- Show textOutput (display:block)
|
||||
- Set state.editMode = false
|
||||
- Update btnEdit text to "Edit", remove .active class
|
||||
- Call showOCRPage(pageNum) to re-render display with saved correction
|
||||
```
|
||||
|
||||
6. Update `goToPage()` — call `exitEditMode()` at the very top before any other logic
|
||||
|
||||
7. `#btn-edit` click → toggle edit mode
|
||||
|
||||
8. Update `showOCRPage()` display text logic:
|
||||
```javascript
|
||||
const corr = state.corrections[pageNum];
|
||||
const displayText = (corr?.pass1) || state.ocrPages[pageNum] || '';
|
||||
```
|
||||
|
||||
9. Call `loadCorrectionsOnStartup()` on `DOMContentLoaded`
|
||||
|
||||
10. Call `updateCorrectionIndicator(pageNum)` at end of `showOCRPage()`
|
||||
|
||||
**Test thoroughly before proceeding:**
|
||||
- Enter edit mode: textarea appears, display hidden
|
||||
- Make a change and exit: display re-renders with correction, gold dot appears
|
||||
- Navigate away and back: correction persists, dot still shows
|
||||
- Enter and exit without changing: no correction saved, no dot
|
||||
- Refresh page: correction loads from localStorage/server, dot shows correctly
|
||||
- Confirm `## Page N` never appears in the textarea
|
||||
|
||||
---
|
||||
|
||||
## Step 7 — Corrections persistence in launch_viewer.py
|
||||
|
||||
**Add to `launch_viewer.py` only. Do not change any viewer files.**
|
||||
|
||||
Add a `do_POST` method to `QuietHandler` that handles these endpoints:
|
||||
|
||||
1. `POST /save-flags` — read JSON body, write to `flags.json` in ROOT
|
||||
|
||||
2. `POST /save-corrections` — read JSON body (`{ page, correction }`), load existing `corrections.json`, update the entry for `page`, write back
|
||||
|
||||
3. `POST /save-pass2-flags` — read JSON body, write to `pass2_flags.json` in ROOT
|
||||
|
||||
4. `POST /clear-all-data` — delete `flags.json`, `corrections.json`, `pass2_flags.json` if they exist, respond 200
|
||||
|
||||
All endpoints:
|
||||
- Read body with `self.rfile.read(int(self.headers['Content-Length']))`
|
||||
- Parse with `json.loads()`
|
||||
- Respond `200 OK` on success
|
||||
- Respond `400` on parse error
|
||||
- Never crash the server on bad input — wrap in try/except
|
||||
|
||||
**Test: Save a flag, restart the server, reload viewer — flag persists from file not just localStorage.**
|
||||
|
||||
---
|
||||
|
||||
## Step 8 — Role switcher and Pass 2 mode
|
||||
|
||||
**Add to `p44-ocr-viewer.js` only. Do not change HTML or CSS.**
|
||||
|
||||
1. Module variables:
|
||||
```javascript
|
||||
let currentRole = 'pass1';
|
||||
let pass2Flags = {};
|
||||
const PASS1_LABELS = { verified: '✓ Verified', review: '⚑ Review', error: '✗ Error' };
|
||||
const PASS2_LABELS = { verified: '✓ Pass 2 approved', review: '⚑ Needs work', error: '✕ Rejected' };
|
||||
```
|
||||
|
||||
2. `getVerifiedPages()` — returns sorted array of page numbers where `state.flags[n] === 'verified'`
|
||||
|
||||
3. `updatePass2NavButtons()` — in Pass 2 mode, disables prev/next if no verified pages before/after current
|
||||
|
||||
4. `updatePass2Banner()` — updates banner text with count of verified pages ready for review
|
||||
|
||||
5. `applyRole(role)`:
|
||||
- Sets `currentRole`
|
||||
- Saves to `localStorage('p44-role')`
|
||||
- Shows/hides `#pass2-banner`
|
||||
- Updates flag button labels to PASS1 or PASS2 labels
|
||||
- Calls `updatePass2Banner()` if pass2
|
||||
- Re-renders current page via `showOCRPage(state.currentPage)`
|
||||
|
||||
6. Update `#btn-prev` / `#btn-next` click handlers — in Pass 2 mode, jump between verified pages only
|
||||
|
||||
7. Update `showOCRPage()` — at the end, if `currentRole === 'pass2'`, call `enterEditMode()` if not already in edit mode. Also show pass2-specific display text: `corr?.pass2 || corr?.pass1 || state.ocrPages[pageNum]`
|
||||
|
||||
8. `savePass2Flags()` — POST to `/save-pass2-flags`, fallback to localStorage `p44-pass2-flags`
|
||||
|
||||
9. `loadPass2FlagsOnStartup()` — fetch `/pass2_flags.json`, fallback to localStorage
|
||||
|
||||
10. Update `setFlag()` — in Pass 2 mode, write to `pass2Flags` and call `savePass2Flags()` instead of `state.flags`
|
||||
|
||||
11. Update `updateFlagButtons()` — read from `pass2Flags` in Pass 2 mode
|
||||
|
||||
12. `resetPass2()` — clears `pass2Flags` and all corrections, confirm dialog, cannot be undone
|
||||
|
||||
13. `#btn-pass2-reset` click → `resetPass2()`
|
||||
|
||||
14. `#role-select` change → `applyRole(value)`
|
||||
|
||||
15. On `DOMContentLoaded`, restore role: `applyRole(localStorage.getItem('p44-role') || 'pass1')`
|
||||
|
||||
16. Call `loadPass2FlagsOnStartup()` on `DOMContentLoaded`
|
||||
|
||||
**Test: Switch roles, Pass 2 nav jumps between verified pages, Pass 2 flags stored separately, role persists on refresh.**
|
||||
|
||||
---
|
||||
|
||||
## Step 9 — Pass 2 change summary panel
|
||||
|
||||
**Add to HTML, CSS, and JS.**
|
||||
|
||||
**HTML — add inside `#text-panel-body` after `#text-placeholder`, before `#text-output`:**
|
||||
```html
|
||||
<div id="pass2-change-summary" style="display:none;"></div>
|
||||
```
|
||||
|
||||
**CSS — add:**
|
||||
```css
|
||||
#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: var(--amber); }
|
||||
#pass2-change-summary .cs-added { color: var(--green); }
|
||||
#pass2-change-summary .cs-deleted { color: var(--red); }
|
||||
#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: var(--red); color: var(--red); }
|
||||
```
|
||||
|
||||
**JS — add:**
|
||||
|
||||
1. `buildChangeSummary(originalText, pass1Text)`:
|
||||
- Split both strings on whitespace: `str.trim().split(/\s+/)`
|
||||
- Walk both arrays simultaneously
|
||||
- Produce array of `{ type: 'changed'|'added'|'deleted', from?, to?, word? }`
|
||||
- Cap at 50 changes, add `{ type: 'truncated' }` if over
|
||||
- Return empty array if strings are identical
|
||||
|
||||
2. `showPass2ChangeSummary(pageNum)`:
|
||||
- Get `corr = state.corrections[pageNum]`
|
||||
- If no `corr?.pass1` or `currentRole !== 'pass2'`: hide panel, return
|
||||
- Build changes from `corr.original` vs `corr.pass1`
|
||||
- If no changes: hide panel, return
|
||||
- Render `cs-title` with change count
|
||||
- Render `cs-list` with each change coloured by type
|
||||
- Render two action buttons: "↺ Revert to Pass 1" and "⚠ Revert to original OCR"
|
||||
- Show panel
|
||||
- Wire revert buttons:
|
||||
- Revert to Pass 1: `confirm()` dialog, set `textEditor.value = corr.pass1`, status message
|
||||
- Revert to original: `prompt('Type REVERT to confirm')`, if confirmed delete `corrections[pageNum]`, call `postCorrection(pageNum, null)`, update indicator, hide summary, status message
|
||||
|
||||
3. Call `showPass2ChangeSummary(state.currentPage)` at end of `enterEditMode()`
|
||||
|
||||
4. Hide `#pass2-change-summary` at start of `exitEditMode()`
|
||||
|
||||
**Test: Switch to Pass 2, navigate to a page with Pass 1 corrections — change summary appears above textarea showing exactly what was changed. Revert to Pass 1 restores Pass 1 text. Revert to original requires typing REVERT and clears all corrections.**
|
||||
|
||||
---
|
||||
|
||||
## Step 10 — Keyboard navigation
|
||||
|
||||
**Add to `p44-ocr-viewer.js` only.**
|
||||
|
||||
1. `document.addEventListener('keydown', e => {...})`
|
||||
|
||||
2. Guard: if `state.editMode` is true, return immediately (let all keys go to textarea)
|
||||
|
||||
3. Guard: if focus is on any `INPUT`, `TEXTAREA`, or `SELECT`, return
|
||||
|
||||
4. Key mappings:
|
||||
- `ArrowRight` or `l` → `goToPage(state.currentPage + 1)`
|
||||
- `ArrowLeft` or `j` → `goToPage(state.currentPage - 1)`
|
||||
- `v` → `setFlag('verified')`
|
||||
- `r` → `setFlag('review')`
|
||||
- `e` → `setFlag('error')`
|
||||
- `Escape` → if edit mode active: `exitEditMode()`. Otherwise: nothing. **Do NOT reference `zoomLens` — that element does not exist.**
|
||||
|
||||
**Test: Arrow keys navigate pages, v/r/e set flags, Escape exits edit mode, no errors thrown.**
|
||||
|
||||
---
|
||||
|
||||
## Step 11 — Clear all data
|
||||
|
||||
**Add to `p44-ocr-viewer.js` and `launch_viewer.py`.**
|
||||
|
||||
**JS — add `clearAllLocalData()`:**
|
||||
```javascript
|
||||
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\n' +
|
||||
'This cannot be undone.'
|
||||
)) return;
|
||||
|
||||
localStorage.removeItem('p44-corrections');
|
||||
localStorage.removeItem('p44-flags');
|
||||
localStorage.removeItem('p44-pass2-flags');
|
||||
localStorage.removeItem('p44-role');
|
||||
|
||||
state.corrections = {};
|
||||
state.flags = {};
|
||||
pass2Flags = {};
|
||||
|
||||
applyRole('pass1');
|
||||
updateFlagButtons(state.currentPage);
|
||||
updateFlagsSummary();
|
||||
updateCorrectionIndicator(state.currentPage);
|
||||
if (state.pdfDoc) showOCRPage(state.currentPage);
|
||||
|
||||
fetch('/clear-all-data', { method: 'POST' }).catch(() => {});
|
||||
setStatus('All saved data cleared — fresh start.');
|
||||
}
|
||||
```
|
||||
|
||||
Wire `#btn-clear-all` click → `clearAllLocalData()`
|
||||
|
||||
**`launch_viewer.py` — confirm `/clear-all-data` endpoint exists from Step 7. If not added yet, add it now.**
|
||||
|
||||
**Test: Click clear, confirm, all flags gone, all corrections gone, role reset, page re-renders as raw OCR, server files deleted.**
|
||||
|
||||
---
|
||||
|
||||
## Step 12 — Sample data
|
||||
|
||||
**Add to `p44-ocr-viewer.js` only.**
|
||||
|
||||
1. Add `SAMPLE_TEXT` constant (plain prose — no markdown)
|
||||
|
||||
2. Add `SAMPLE_JSON` constant (word bbox data)
|
||||
|
||||
3. `buildSampleImageDataURL()` — canvas-drawn sample page image
|
||||
|
||||
4. `loadSampleData()`:
|
||||
- Sets `state.mode = 'image'`
|
||||
- Draws sample image to canvas
|
||||
- Loads sample JSON word data
|
||||
- Calls `loadOCRText(SAMPLE_TEXT)`
|
||||
- Status: `'Sample data loaded.'` — **do not reference any removed features**
|
||||
|
||||
5. `#btn-example` click → `loadSampleData()`
|
||||
|
||||
**Test: Sample button loads image and OCR text, page displays correctly.**
|
||||
|
||||
---
|
||||
|
||||
## Step 13 — Final cleanup and launch_viewer.py review
|
||||
|
||||
**Review all three files against this checklist:**
|
||||
|
||||
**JS cleanup:**
|
||||
- [ ] No reference to `zoomLens` anywhere
|
||||
- [ ] No `contentEditable` set on `#text-output` anywhere
|
||||
- [ ] No `convertMarkdown()` or `inlineMd()` function
|
||||
- [ ] No `sanitizeCorrection()` function
|
||||
- [ ] No `stripHTML()` function
|
||||
- [ ] No word-level diff functions (`wordDiff`, `lcsWords`, `tokenizeWords`, `renderDiff`)
|
||||
- [ ] No diff legend DOM refs or event listeners
|
||||
- [ ] `state.scale` removed (it was dead — there is no scale dropdown)
|
||||
- [ ] `state.ocrRawFull` removed (dead — never read)
|
||||
- [ ] `p44_diff_legend_dismissed` NOT in `clearAllLocalData()` (key was never written)
|
||||
- [ ] localStorage keys are consistent: `p44-flags`, `p44-pass2-flags`, `p44-corrections`, `p44-role`
|
||||
|
||||
**launch_viewer.py review:**
|
||||
- [ ] `/save-flags` POST handler exists
|
||||
- [ ] `/save-corrections` POST handler exists
|
||||
- [ ] `/save-pass2-flags` POST handler exists
|
||||
- [ ] `/clear-all-data` POST handler exists
|
||||
- [ ] All handlers wrapped in try/except
|
||||
- [ ] `Cache-Control: no-store` headers present on all responses
|
||||
|
||||
**Final test — full workflow:**
|
||||
1. Load Calgary Highlanders
|
||||
2. Navigate to page 10
|
||||
3. Flag as Verified (Pass 1)
|
||||
4. Enter edit mode, make a correction, exit
|
||||
5. Refresh — correction and flag persist
|
||||
6. Switch to Pass 2
|
||||
7. Navigate to page 10 (only verified page)
|
||||
8. Change summary shows Pass 1 edits
|
||||
9. Make a Pass 2 correction
|
||||
10. Revert to original (type REVERT)
|
||||
11. Clear all data
|
||||
12. Refresh — everything is gone
|
||||
|
||||
---
|
||||
|
||||
*End of rebuild prompts.*
|
||||
Reference in New Issue
Block a user