"""Input cleaning and limits: the old build's cleanText, cleanDeep and validation regexes, now on the server. Every string that enters the database goes through `clean_text` with the field's limit. Control characters (except tab, LF and CR), DEL, the zero-width and bidi override characters are removed, the text is NFC normalised, trimmed and cut to the limit. """ from __future__ import annotations import re import unicodedata from typing import Any # C0 controls except tab (09), LF (0A) and CR (0D); DEL; zero-width and bidi marks; isolates. _STRIP = re.compile("[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f​-‏‪-‮⁦-⁩]") RX_HEAT = re.compile(r"^[A-Za-z0-9][A-Za-z0-9 ./#_-]{0,39}$") RX_STAMP = re.compile(r"^[A-Za-z0-9-]{1,10}$") RX_EMP = re.compile(r"^[A-Z0-9-]{1,20}$") RX_PROJECT = re.compile(r"^[A-Z0-9][A-Z0-9-]{0,19}$") RX_LOT = re.compile(r"^[A-Z0-9]{1,3}$") # Character limits per field. Reasons for IT edits and send-backs need at least REASON_MIN. LIMITS: dict[str, int] = { "title": 200, "customer": 120, "drawing": 60, "rev": 10, "wps": 40, "op": 40, "notes": 2000, "reason": 500, "note": 500, "heat": 40, "stamp": 10, "location": 120, "part_id": 60, "description": 240, "material": 120, "length": 40, "project_number": 20, "project_name": 120, "po": 60, "lot": 3, "unit_name": 120, "doc_title": 160, "ecn": 40, "name": 160, "order_pin": 40, "file_name": 160, "job_note": 200, "filler": 40, "preheat": 40, "weld_text": 40, } REASON_MIN = 8 MAX_BODY_BYTES = 200_000 def clean_text(value: Any, max_len: int = 2000) -> str: """The old cleanText: strip controls and invisible marks, NFC, trim, cut. None becomes ''.""" if value is None: return "" text = value if isinstance(value, str) else str(value) text = _STRIP.sub("", text) text = unicodedata.normalize("NFC", text).strip() return text[:max_len] def clean_opt(value: Any, max_len: int) -> str | None: """clean_text, but an empty result is None (for nullable columns).""" text = clean_text(value, max_len) return text or None def clean_deep(value: Any, max_len: int = 4000, depth: int = 0) -> Any: """Clean every string inside a JSON-like value. Depth is capped at 12 like the old build.""" if depth > 12: return value if isinstance(value, str): return clean_text(value, max_len) if isinstance(value, list): return [clean_deep(x, max_len, depth + 1) for x in value] if isinstance(value, dict): return {str(k)[:120]: clean_deep(v, max_len, depth + 1) for k, v in value.items()} return value def limit(field: str) -> int: return LIMITS.get(field, 2000) def reason_ok(value: Any) -> str | None: """A cleaned reason of at least REASON_MIN characters, else None.""" text = clean_text(value, LIMITS["reason"]) return text if len(text) >= REASON_MIN else None