from typing import Any, List, Optional, Sequence, Tuple from .dates_add_days import days_in_month 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)