# OCR Rule-Based Parser for Appointment Extraction Full code of `parseWithRules()` from `appointments.html` inline script, with test cases. Built to replace a DeepSeek API call with zero-dependency CPU parsing. ## Design Layered extraction order (5 phases now): **Phase 0 — Extract header date BEFORE stripping**: Screenshots often have the appointment date in the top-middle header (e.g., "Monday, Jun 09, 2026"). Scan the first ~15 lines for a date pattern BEFORE Phase 1 strips metadata lines. Save as `headerDate`. After all appointments are parsed, apply `headerDate` as the fallback for any appointment missing its own date. **Phases 1-4** (unchanged): 1. Split blocks by blank lines 2. Strip separator lines (`---`, `===`) and short ALL-CAPS headers (`SCHEDULE`, `NAME`) 3. Table detection: if block has 2+ lines and header line contains `name|phone|date|time|service|vehicle|vin`, parse each line individually 4. Per-block: extract phone → VIN → date → time → duration (replacing matches with single space to preserve column gaps) 5. Split remaining on `\s{2,}` (multi-space column boundaries) — if that fails, fall back to single-space word heuristics 6. Name = first consecutive capitalized words without digits. Vehicle = chunk containing year (`19xx`/`20xx`) or known make. Service = everything else. ## Key Patterns | Field | Regex | Notes | |-------|-------|-------| | Phone | `\(?\d{3}\)?[\s.\-]*\d{3}[\s.\-]*\d{4}` | Captures `(555)123-4567`, `555.123.4567`, `555 123 4567` | | VIN | `\b[A-HJ-NPR-Z0-9]{17}\b` | Excludes I, O, Q per VIN standard | | Date (ISO) | `(\d{4})[\/\-.](\d{1,2})[\/\-.](\d{1,2})` | `2024-06-15` | | Date (US) | `(\d{1,2})[\/](\d{1,2})(?:\/(\d{4}))?` | `06/20/2024` or `06/20` | | Date (named) | `(jan\|feb\|...)[a-z]*\s+(\d{1,2})(?:[,\s]+(\d{4}))?` | `Jan 15, 2024` | | Time (HH:MM) | `(\d{1,2}):(\d{2})\s*(AM\|PM)?` | `9:00 AM`, `14:00` | | Time (bare) | `(\d{1,2})\s*(AM\|PM)` | `8am`, `1PM` | | Duration | `(\d+)\s*(?:min\|minutes?\|hrs?\|hours?)` | Converts `1 hour` → 60, `90 min` → 90 | | Vehicle (year) | `\b(19\|20)\d{2}\b` | Detects year-in-text | | Vehicle (make) | `ford\|chevy\|chevrolet\|toyota\|honda\|...` | Known make list | ## Verified Test Cases ### 1. Two appointments with double spacing ``` John Smith (555) 123-4567 2024-06-15 9:00 AM Oil Change 60 min 2018 Toyota Camry Jane Doe (555) 987-6543 06/20/2024 2:00 PM Brake Inspection 90 min 2020 Honda Civic ``` → 2 appts: `John Smith / 2024-06-15 09:00 / 2018 Toyota Camry / Oil Change`, `Jane Doe / 2024-06-20 14:00 / 2020 Honda Civic / Brake Inspection` ### 2. Headers and separator lines ``` SCHEDULE -------- Robert Brown 865-555-1234 Jan 15, 2024 8:00am Tire Rotation 1 hour Toyota Tacoma Sarah Wilson (423)555-6789 03/01/2024 1:30PM AC Repair 2 hours Jeep Grand Cherokee ``` → 2 appts (headers stripped): `Robert Brown / 2024-01-15 08:00 / Toyota Tacoma / Tire Rotation`, `Sarah Wilson / 2024-03-01 13:30 / Jeep Grand Cherokee / AC Repair` ### 3. Single appointment ``` Mike Johnson 2024-07-01 10:30 AM Transmission Fluid 2022 Ford F-150 ``` → 1 appt: `Mike Johnson / 2024-07-01 10:30 / 2022 Ford F-150 / Transmission Fluid` ### 4. Table format with header row ``` Name Phone Date Time Service Vehicle John Smith 555-123-4567 2024-06-15 9:00 AM Oil Change 2018 Toyota Camry Jane Doe 555-987-6543 06/20/2024 2:00 PM Brake Inspection 2020 Honda Civic ``` → 2 appts (header stripped, rows parsed): correct fields for both ### 5. Screenshot with header date — no per-appointment dates ``` Monday, Jun 09, 2026 8:00 AM 1.5 hrs John Smith 555-123-4567 Oil Change 2018 Toyota Camry 10:30 AM 1 hr Jane Doe 555-987-6543 Brake Inspection 2020 Honda Civic ``` → 2 appts, both get `appointmentDate: "2026-06-09"` from Phase 0 header extraction. The "Monday Jun 09" line is extracted before Phase 1 strips it. ## Phase 0 Header Date + Phase 4 Fallback (added 2026-06-09) **Problem**: Screenshots have the date in the top-middle header (e.g., "Monday, Jun 09, 2026"), but Phase 1 strips it as metadata before any appointments are parsed. Each appointment ends up with no date. **Solution**: - **Phase 0** (before Phase 1): Scan first ~15 lines with `headerDateRe` for a date pattern, call `parseDate()`, store as `headerDate`. - **Phase 4** (applied at BOTH return points — main return and table fallback): If `headerDate` exists, any appointment without `appointmentDate` gets it assigned. ```javascript // Phase 0 var headerDate = null; var headerLines = t.split('\n').slice(0, 15); var headerDateRe = /\b(?:(?:Mon|Tue|...)[,\s]+)?(?:Jan|Feb|...)[a-z]*\s+\d{1,2}(?:[,\s]+\d{4})?\b|.../i; for (var hi = 0; hi < headerLines.length; hi++) { var hm = headerLines[hi].match(headerDateRe); if (hm) { var hd = parseDate(hm[0]); if (hd) { headerDate = hd; break; } } } // Phase 4 (before each return) if (headerDate) { appointments.forEach(function(a) { if (!a.appointmentDate) a.appointmentDate = headerDate; }); } ``` **Critical**: Apply at ALL return points. The table fallback (`lines.forEach(...); return appointments;`) has its own early return that bypasses the main Phase 4 block. Add the headerDate fallback guard before that return too. ## Service Word Blocklist for Name Extraction (added 2026-06-09) **Problem**: Cross-block name carry (Step 10) picks up trailing all-caps words from `serviceType` and treats them as the next appointment's customer name. Words like "SERVICE", "REPAIR", "CHECK" get carried as names. **Solution — `isServiceWord()` helper**: ```javascript var serviceWords = {SERVICE:1,REPAIR:1,CHECK:1,MAINTENANCE:1,INSPECTION:1,DIAGNOSTIC:1, DIAG:1,REPLACE:1,REPLACEMENT:1,INSTALL:1,REMOVE:1,ADJUST:1,ALIGNMENT:1,ROTATION:1, BALANCE:1,FLUSH:1,DRAIN:1,FILL:1,TUNE:1,UP:1,ESTIMATE:1,WAITING:1,APPOINTMENT:1, REQUEST:1,CUSTOMER:1,VEHICLE:1,ADVISOR:1,TECHNICIAN:1,MECHANIC:1}; function isServiceWord(w) { w = w.toUpperCase().replace(/[^A-Z]/g,''); return serviceWords[w] || w.length > 12; } ``` **Applied in three places**: 1. **parseOne() entry** — validate `carryName` before using it: ```javascript if (carryName) { if (isServiceWord(carryName)) carryName = null; else appt.customerName = carryName; } ``` 2. **Step 7 name extraction** — skip parts that are service words, year-prefixed vehicle descriptions, or long all-caps: ```javascript var nameIdx = 0; while (nameIdx < parts.length && ( isServiceWord(parts[nameIdx]) || /^\d{4}\s/.test(parts[nameIdx]) || /^[A-Z\d\s\-]{6,}$/.test(parts[nameIdx]) )) { nameIdx++; } ``` 3. **Step 10 cross-block carry** — reject service words before carrying: ```javascript if (tn && tn[1].length > 3 && tn[1].length < 20 && !isServiceWord(tn[1])) { ... } ``` ## Pitfalls Encountered During Development 1. **Whitespace collapse order is critical**: Collapsing `\s+` to single space BEFORE splitting on `\s{2,}` destroys column boundaries. Must split on multi-spaces first, then clean each part. 2. **Bracket mismatch from refactoring**: Extracting inline code into a `parseBlock()` helper left a leftover `});` from the old `blocks.forEach()` closure. Caused `SyntaxError: Unexpected token ')'`. 3. **Dead function declarations**: Two `handleFile` declarations in same scope — second silently overwrites first. Always delete dead code. 4. **Global dependency gaps**: Inline scripts need `window.closeModal`, `window.escapeHtml`, `window.batchCreateAppointments` — module exports aren't accessible.