Functional Weave
Code in Rust

todo.item@1.0.0

impl/python/validate_draft.py

4,564 bytes · the Python implementation · view raw

Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.

from typing import Any, List, Optional, Sequence, Tuple

from .dates_add_days import days_in_month  ← from dates.add-days ^1.0.0 · built alongside by fune
from .todo_item_types import TodoDraft, TodoValidation

MAX_TITLE = 200
MAX_NOTES = 2000
MAX_TAGS = 10
MAX_TAG_LENGTH = 30
MAX_INTERVAL = 999

_PRIORITIES = ("none", "low", "medium", "high")
_FREQUENCIES = ("daily", "weekdays", "weekly", "monthly", "yearly")
_SPACE = " \t\n\r\x0b\x0c"


def trim_space(text: str) -> str:
    """Trim ASCII whitespace only, so "trimmed" means the same in every language."""
    return text.strip(_SPACE)


def code_point_length(text: str) -> int:
    return len(text)


def _digits(text: str, start: int, stop: int) -> bool:
    return all("0" <= ch <= "9" for ch in text[start:stop])


def is_iso_date(text: Any) -> bool:
    """A real calendar date written YYYY-MM-DD, years 0001 to 9999. Never raises."""
    if not isinstance(text, str) or len(text) != 10 or text[4] != "-" or text[7] != "-":
        return False
    if not (_digits(text, 0, 4) and _digits(text, 5, 7) and _digits(text, 8, 10)):
        return False
    year, month, day = int(text[0:4]), int(text[5:7]), int(text[8:10])
    return year >= 1 and 1 <= month <= 12 and 1 <= day <= days_in_month(year, month)


def _is_tag_form(tag: str) -> bool:
    if tag == "" or tag[0] == "-" or tag[-1] == "-":
        return False
    for i, ch in enumerate(tag):
        ok = ("a" <= ch <= "z") or ("0" <= ch <= "9") or (ch == "-" and tag[i - 1] != "-")
        if not ok:
            return False
    return True


def _title_error(title: str) -> Optional[str]:
    trimmed = trim_space(title)
    if trimmed == "":
        return "Enter a title."
    if any(ord(ch) < 0x20 or ord(ch) == 0x7F for ch in trimmed):
        return "Keep the title to one line, without control characters."
    if len(trimmed) > MAX_TITLE:
        return f"Use no more than {MAX_TITLE} characters for the title."
    return None


def _tags_error(tags: Sequence[str]) -> Optional[str]:
    if len(tags) > MAX_TAGS:
        return f"Use no more than {MAX_TAGS} tags."
    seen: List[str] = []
    for tag in tags:
        if not _is_tag_form(tag):
            return f'Tag "{tag}" can only use lower-case letters, digits and single hyphens.'
        if len(tag) > MAX_TAG_LENGTH:
            return f'Tag "{tag}" is longer than {MAX_TAG_LENGTH} characters.'
        if tag in seen:
            return f'Tag "{tag}" is listed twice.'
        seen.append(tag)
    return None


def _recurrence_error(draft: Any) -> Optional[str]:
    rule = draft.recurrence
    if rule is None:
        return None
    if rule.frequency not in _FREQUENCIES:
        return "Choose how it repeats: daily, weekdays, weekly, monthly or yearly."
    interval = rule.interval
    if isinstance(interval, bool) or not isinstance(interval, int) or interval < 1 or interval > MAX_INTERVAL:
        return f"Repeat every 1 to {MAX_INTERVAL} days, weeks, months or years."
    if rule.frequency == "weekdays" and interval != 1:
        return "Weekday repeats cannot skip weeks; use an interval of 1."
    if not is_iso_date(rule.anchor):
        return "Enter the repeat start as a real date, YYYY-MM-DD."
    if draft.due is None:
        return "A repeating todo needs a due date."
    if is_iso_date(draft.due) and rule.anchor > draft.due:
        return "The repeat cannot start after the due date."
    return None


def draft_errors(draft: Any) -> List[Tuple[str, str]]:
    """The edit rules as ordered (field, message) pairs; works on a Todo too."""
    errors: List[Tuple[str, str]] = []
    title = _title_error(draft.title)
    if title is not None:
        errors.append(("title", title))
    if draft.notes is not None and len(draft.notes) > MAX_NOTES:
        errors.append(("notes", f"Use no more than {MAX_NOTES} characters for the notes."))
    if draft.priority not in _PRIORITIES:
        errors.append(("priority", "Choose a priority: none, low, medium or high."))
    if draft.due is not None and not is_iso_date(draft.due):
        errors.append(("due", "Enter the due date as a real date, YYYY-MM-DD."))
    tags = _tags_error(draft.tags)
    if tags is not None:
        errors.append(("tags", tags))
    recurrence = _recurrence_error(draft)
    if recurrence is not None:
        errors.append(("recurrence", recurrence))
    return errors


def validate_draft(draft: TodoDraft) -> TodoValidation:
    """Check what a person typed into the add or edit form; the title is judged trimmed."""
    errors = dict(draft_errors(draft))
    return TodoValidation(valid=len(errors) == 0, errors=errors)