# 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.
