"""Build the final filename/PDF title from OCR (and, later, speech) metadata. Pattern: YYYY-MM-DD_vendor[_doc-title].pdf. When the document's own date can't be read, the scan date (capture timestamp from the video) is used instead so files still sort chronologically and never get an opaque "UNSORTED" prefix; export_summary.csv records that the date came from the scan, not the document. Module 4 (speech) isn't built yet; once speech transcription lands, a spoken description will take priority here. """ from __future__ import annotations import re from dataclasses import dataclass from datetime import datetime def slugify(text: str, max_len: int = 40) -> str: text = text.lower() text = re.sub(r"[^a-z0-9]+", "_", text).strip("_") text = text[:max_len].strip("_") return text or "doc" @dataclass class NamingResult: stem: str # filename without extension source: str # "ocr" | "scan_date" | "none" ("speech" once module 4 exists) _TIME_RE = re.compile(r"^([01]\d|2[0-3]):([0-5]\d)$") def build_name( date: str | None, vendor: str | None, fallback_dt: datetime, max_vendor_len: int = 40, form_code: str | None = None, tax_year: str | None = None, org_name: str | None = None, time: str | None = None, doc_title: str | None = None, party: str | None = None, ) -> NamingResult: """date must already be normalized to YYYY-MM-DD, time to HH:MM, or None. Time is appended to the date (e.g. 2023-03-20_1525) whenever it was extracted, so two same-day visits to the same vendor (Walmart in the morning and again in the evening) don't collide or silently overwrite. doc_title is a recognized printed title slug ("transaction_history", "invoice"), appended after the vendor unless it would be redundant. party is an account holder / SHORTNAME when printed on the document. """ vendor_slug = slugify(vendor, max_vendor_len) if vendor else None party_slug = slugify(party, max_vendor_len) if party else None # Tax slips: prefer 2023_T4_York_University over a date-based name. if form_code: org_slug = slugify(org_name, max_vendor_len) if org_name else None if not org_slug and vendor: stripped = vendor if stripped.upper().startswith(form_code.upper()): stripped = stripped[len(form_code) :].strip() org_slug = slugify(stripped, max_vendor_len) if stripped else None year = tax_year or (date[:4] if date else None) code = form_code.upper() if year and org_slug: return NamingResult(stem=f"{year}_{code}_{org_slug}", source="ocr") if year: return NamingResult(stem=f"{year}_{code}", source="ocr") if org_slug: return NamingResult(stem=f"{code}_{org_slug}", source="ocr") return NamingResult(stem=code.lower(), source="ocr") if date: date_part = date if time and _TIME_RE.match(time): date_part = f"{date}_{time.replace(':', '')}" source = "ocr" else: # Scan date beats an opaque "UNSORTED" prefix: files still sort, # and the CSV records that the date is when it was scanned. date_part = fallback_dt.strftime("%Y-%m-%d") source = "scan_date" title_slug = slugify(doc_title, max_vendor_len) if doc_title else None parts = [date_part] if vendor_slug: parts.append(vendor_slug) # Skip the title when the vendor line already says the same thing # (Tesseract often picks the printed title as the "vendor"). if title_slug and not (vendor_slug and title_slug in vendor_slug): parts.append(title_slug) # Account-holder shortname after the doc type — disambiguates family # members who share a bank (LEVIT I vs LEVIT M). if party_slug and party_slug not in parts and ( not vendor_slug or party_slug not in vendor_slug ): parts.append(party_slug) if len(parts) == 1: parts.append("document" if date else "scan") if not date: source = "none" return NamingResult(stem="_".join(parts), source=source) def dedupe_stem(stem: str, used: set[str]) -> str: """Append _2, _3, ... if stem was already used in this run.""" if stem not in used: used.add(stem) return stem i = 2 while f"{stem}_{i}" in used: i += 1 deduped = f"{stem}_{i}" used.add(deduped) return deduped