|
# retoor <retoor@molodetz.nl>
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from datetime import datetime
|
|
from typing import Any
|
|
|
|
from .schema import InteractionRequest, WidgetModel, flatten_widgets
|
|
|
|
YES = frozenset({"y", "yes", "true", "1", "ok", "confirm", "ja", "oui", "j"})
|
|
NO = frozenset({"n", "no", "false", "0", "nee", "non"})
|
|
CANCEL = frozenset({"cancel", "c", "abort", "quit", "annuleren", "stop"})
|
|
DATE_FORMATS = (
|
|
"%d/%m/%Y",
|
|
"%d-%m-%Y",
|
|
"%d.%m.%Y",
|
|
"%d/%m/%y",
|
|
"%d-%m-%y",
|
|
"%Y-%m-%d",
|
|
)
|
|
|
|
|
|
def parse_plain_reply(
|
|
request: InteractionRequest, text: str
|
|
) -> tuple[str, dict[str, Any], str | None]:
|
|
raw = (text or "").strip()
|
|
if not raw:
|
|
return "error", {}, "Empty reply."
|
|
lower = raw.lower()
|
|
if lower in CANCEL:
|
|
return "cancelled", {}, None
|
|
|
|
widgets = [w for w in flatten_widgets(request.widgets) if w.type != "//"]
|
|
if not widgets:
|
|
return "submitted", {}, None
|
|
|
|
if len(widgets) == 1:
|
|
return _parse_single(widgets[0], raw, strict=True)
|
|
|
|
if not _looks_like_multi_answer(raw, widgets):
|
|
return "error", {}, "Not a structured answer for the open form."
|
|
|
|
values: dict[str, Any] = {}
|
|
chunks = [part.strip() for part in re.split(r"[;\n]+", raw) if part.strip()]
|
|
if len(chunks) == 1 and "," in chunks[0] and len(widgets) > 1:
|
|
chunks = [part.strip() for part in chunks[0].split(",") if part.strip()]
|
|
if len(chunks) != len(widgets):
|
|
return (
|
|
"error",
|
|
{},
|
|
f"Expected {len(widgets)} values (one per field), got {len(chunks)}.",
|
|
)
|
|
for widget, chunk in zip(widgets, chunks):
|
|
status, partial, err = _parse_single(widget, chunk, strict=True)
|
|
if status != "submitted":
|
|
return status, {}, err
|
|
values.update(partial)
|
|
return "submitted", values, None
|
|
|
|
|
|
def _looks_like_multi_answer(raw: str, widgets: list[WidgetModel]) -> bool:
|
|
if ";" in raw or "\n" in raw:
|
|
return True
|
|
if "," in raw and len(widgets) > 1:
|
|
return True
|
|
return False
|
|
|
|
|
|
def _parse_single(
|
|
widget: WidgetModel, raw: str, *, strict: bool = False
|
|
) -> tuple[str, dict[str, Any], str | None]:
|
|
text = raw.strip()
|
|
lower = text.lower()
|
|
if lower in CANCEL:
|
|
return "cancelled", {}, None
|
|
name = widget.name
|
|
if widget.type == "confirm":
|
|
if lower in YES:
|
|
return "submitted", {name: True}, None
|
|
if lower in NO:
|
|
return "submitted", {name: False}, None
|
|
return "error", {}, "Reply yes/no (or ja/nee)."
|
|
if widget.type in ("choice", "select"):
|
|
value = _match_option(widget, text)
|
|
if value is None:
|
|
return "error", {}, f"Unknown option for {name}."
|
|
return "submitted", {name: value}, None
|
|
if widget.type == "choice_multi":
|
|
parts = [p.strip() for p in re.split(r"[,\s]+", text) if p.strip()]
|
|
values: list[str] = []
|
|
for part in parts:
|
|
matched = _match_option(widget, part)
|
|
if matched is None:
|
|
return "error", {}, f"Unknown option {part!r} for {name}."
|
|
if matched not in values:
|
|
values.append(matched)
|
|
if widget.required and not values:
|
|
return "error", {}, f"{name} requires at least one option."
|
|
return "submitted", {name: values}, None
|
|
if widget.type == "number":
|
|
if strict and not re.fullmatch(r"[+-]?\d+(?:[.,]\d+)?", text):
|
|
return "error", {}, f"{name} must be a number."
|
|
normalized = text.replace(",", ".")
|
|
try:
|
|
number = float(normalized) if "." in normalized else int(normalized)
|
|
except ValueError:
|
|
return "error", {}, f"{name} must be a number."
|
|
if widget.min is not None and number < widget.min:
|
|
return "error", {}, f"{name} must be ≥ {widget.min}."
|
|
if widget.max is not None and number > widget.max:
|
|
return "error", {}, f"{name} must be ≤ {widget.max}."
|
|
return "submitted", {name: number}, None
|
|
if widget.type == "date":
|
|
if strict and not _looks_like_date(text):
|
|
return "error", {}, f"{name} must be a date as DD/MM/YYYY."
|
|
european = _parse_date_european(text)
|
|
if european is None:
|
|
return "error", {}, f"{name} must be a date as DD/MM/YYYY."
|
|
return "submitted", {name: european}, None
|
|
if widget.type == "text":
|
|
if strict and _looks_like_command(text):
|
|
return "error", {}, f"{name} does not look like a field answer."
|
|
if widget.max_length and len(text) > widget.max_length:
|
|
return "error", {}, f"{name} exceeds max length {widget.max_length}."
|
|
if widget.required and not text:
|
|
return "error", {}, f"{name} is required."
|
|
return "submitted", {name: text}, None
|
|
return "error", {}, f"Unsupported widget type {widget.type}."
|
|
|
|
|
|
def _looks_like_date(text: str) -> bool:
|
|
return bool(
|
|
re.fullmatch(r"\d{1,2}[/.-]\d{1,2}[/.-]\d{2,4}", text.strip())
|
|
or re.fullmatch(r"\d{4}-\d{2}-\d{2}", text.strip())
|
|
)
|
|
|
|
|
|
def _looks_like_command(text: str) -> bool:
|
|
value = text.strip().lower()
|
|
if not value:
|
|
return False
|
|
if value.startswith("/"):
|
|
return True
|
|
starters = (
|
|
"show me ",
|
|
"laat ",
|
|
"geef ",
|
|
"toon ",
|
|
"please ",
|
|
"can you ",
|
|
"could you ",
|
|
"i want ",
|
|
"ik wil ",
|
|
"volgende",
|
|
"next ",
|
|
)
|
|
return any(value.startswith(prefix) for prefix in starters)
|
|
|
|
|
|
def _parse_date_european(text: str) -> str | None:
|
|
value = text.strip()
|
|
for fmt in DATE_FORMATS:
|
|
try:
|
|
return datetime.strptime(value, fmt).strftime("%d/%m/%Y")
|
|
except ValueError:
|
|
continue
|
|
return None
|
|
|
|
|
|
def _match_option(widget: WidgetModel, text: str) -> str | None:
|
|
raw = text.strip()
|
|
if raw.isdigit():
|
|
index = int(raw)
|
|
if 1 <= index <= len(widget.options):
|
|
option = widget.options[index - 1]
|
|
if not option.disabled:
|
|
return option.value
|
|
lower = raw.lower()
|
|
for option in widget.options:
|
|
if option.disabled:
|
|
continue
|
|
if option.value == raw or option.value.lower() == lower:
|
|
return option.value
|
|
if option.label.lower() == lower:
|
|
return option.value
|
|
return None
|