diff --git a/docs/user-guide.md b/docs/user-guide.md index d28dd735..336a7a73 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -149,10 +149,11 @@ More you can say: - "Stacky, I'll get the eggs at the market instead" moves them to the market list. - "Stacky, Homer should book the tickets by Friday" puts a name and a day on an item. - "Stacky, what's on the supermarket list?" shows what is still open. -- "Stacky, we're back from camping, reset the packing list." Start a list you use again and again (packing, the weekly chores) as a recurring list: its ticks stay until you reset it. +- "Stacky, we're back from camping, reset the packing list." Start a list you use again and again (packing, the weekly chores) as a checklist: its ticks stay until you reset it. +- "Stacky, Bart has the dentist on 5 November" puts it on the family's calendar list. Start one with "Stacky, start a calendar called Dates". It keeps its entries in date order, the next one on top, and each one leaves the day after its date, ticked or not. An entry needs its date, so Stacky asks for one if you leave it out. - "Stacky, we don't need the hardware store list any more" deletes it. If it still has open items, Stacky names them and asks first. What was bought stays in the archive, so a list started again under the same name still knows where things go. -**Letters and notes put their to-dos on lists too.** When a letter in **#Documents** asks you to do something ("sign the permission slip by 23 October"), the archivist puts it on the list of the letter's topic, with the day and a link back to the letter. A short note in your own words, pinned with πŸ“Œ in a [topic room](#topic-rooms) ("we should take out the trash by Friday"), does the same. Long pasted text and links never turn into to-dos: someone else's tips are not your chores. Tick these off with Stacky like anything else. +**Letters and notes put their to-dos on lists too.** When a letter in **#Documents** asks you to do something ("sign the permission slip by 23 October"), the archivist puts it on the list of the letter's topic, with the day and a link back to the letter. A short note in your own words, pinned with πŸ“Œ in a [topic room](#topic-rooms) ("we should take out the trash by Friday"), does the same. Long pasted text and links never turn into to-dos: someone else's tips are not your chores. Tick these off with Stacky like anything else. A letter's dates (the parents' evening, the dentist, the school play) go on the family's calendar list the same way, with the child it names and a link back to the letter. If you have no calendar list yet, the archivist starts one called Dates. **A list belongs to a topic.** In a topic room, Stacky uses that topic's lists. To tie an existing list to a room: "Stacky, the supermarket list belongs to this room." diff --git a/lib/stack/frontmatter.py b/lib/stack/frontmatter.py index ae0a21a6..8d233968 100644 --- a/lib/stack/frontmatter.py +++ b/lib/stack/frontmatter.py @@ -91,7 +91,7 @@ class TypeSchema: "list": TypeSchema( required={"type", "title"}, list_fields={"topics", "sections"}, - scalar_fields={"title", "description", "recurring"}, + scalar_fields={"title", "description", "kind"}, ), } diff --git a/lib/stack/lists.py b/lib/stack/lists.py index a5b4f8a8..4ddfd2ec 100644 --- a/lib/stack/lists.py +++ b/lib/stack/lists.py @@ -79,6 +79,18 @@ _PERSON_PATH = re.compile(r"^[^/]+/people/([^/]+)\.md$") +# What a list is for decides what happens to an item over time +# (`kind` in the frontmatter, the family memory's field for the variant +# of a type). A todo list hands a ticked item to its archive the night +# after; a checklist (packing, the weekly chores) keeps its ticks until +# it is reset; a calendar keeps its entries in date order, and an entry +# leaves the day after its date, ticked or not. +TODO = "todo" +CHECKLIST = "checklist" +CALENDAR = "calendar" +KINDS = (TODO, CHECKLIST, CALENDAR) + + class ListError(ValueError): """A file that is not a list, or whose frontmatter does not parse.""" @@ -235,7 +247,7 @@ class FamilyList: topics: list[str] = field(default_factory=list) sections: list[str] = field(default_factory=list) items: list[Item] = field(default_factory=list) - recurring: bool = False + kind: str = TODO @property def open_items(self) -> list[Item]: @@ -259,6 +271,30 @@ def topic_list_description(title: str, language: str) -> str: return _TOPIC_LIST_DESCRIPTION.get(code, _TOPIC_LIST_DESCRIPTION["en"]).format(title=title) +# The calendar the archivist starts when the family has none, in the +# household language. +_CALENDAR = { + "de": ("termine", "Termine", "Termine der Familie, jeder mit Datum"), + "en": ("dates", "Dates", "The family's dates, each with its day"), +} + + +def _calendar_names(language: str) -> tuple[str, str, str]: + return _CALENDAR.get((language or "").strip().lower()[:2], _CALENDAR["en"]) + + +def calendar_slug(language: str) -> str: + return _calendar_names(language)[0] + + +def calendar_title(language: str) -> str: + return _calendar_names(language)[1] + + +def calendar_description(language: str) -> str: + return _calendar_names(language)[2] + + def list_path(slug: str, *, bucket: str) -> str: """The vault path of the list `slug` in `bucket`.""" return f"{bucket}/{LISTS_DIR}/{slug}.md" @@ -275,6 +311,9 @@ def read_list(text: str, *, path: str) -> FamilyList: errors = validate(fm) if errors: raise ListError(f"{path}: {'; '.join(errors)}") + kind = str(fm.get("kind") or TODO) + if kind not in KINDS: + raise ListError(f"{path}: kind is {kind!r}, not one of {', '.join(KINDS)}") items: list[Item] = [] section = "" @@ -290,29 +329,25 @@ def read_list(text: str, *, path: str) -> FamilyList: topics=list(fm.get("topics") or []), sections=list(fm.get("sections") or []), items=items, - recurring=fm.get("recurring") is True, + kind=kind, ) def new_list(title: str, *, description: str = "", topics: list[str] | None = None, - sections: list[str] | None = None, recurring: bool = False) -> str: - """The text of a new, empty list file. - - A recurring list (a packing list, the weekly chores) keeps its ticked - items, so it can be reopened whole; every other list hands them to - its archive the night after. - """ + sections: list[str] | None = None, kind: str = TODO) -> str: + """The text of a new, empty list file; a todo list leaves `kind` out.""" fm = {"type": "list", "title": title, "description": description, "topics": list(topics or []), "sections": list(sections or [])} - if recurring: - fm["recurring"] = True + if kind != TODO: + fm["kind"] = kind return f"---\n{dump(fm)}\n---\n" # ── Changing a list ────────────────────────────────────────────────────── def set_details(text: str, *, title: str | None = None, description: str | None = None, - topics: list[str] | None = None, sections: list[str] | None = None) -> str: + topics: list[str] | None = None, sections: list[str] | None = None, + kind: str | None = None) -> str: """The list text with its frontmatter changed; `None` leaves a field as it is. New `sections` are the new walking order: the body's section blocks @@ -324,6 +359,10 @@ def set_details(text: str, *, title: str | None = None, description: str | None ("topics", topics), ("sections", sections)): if value is not None: fm[key] = value + if kind == TODO: + fm.pop("kind", None) + elif kind is not None: + fm["kind"] = kind lines = _body(text).splitlines() if sections is not None: lines = _in_walking_order(lines, sections) @@ -360,16 +399,21 @@ def insert_item(text: str, item: Item, *, path: str) -> str: A section that has no heading yet gets one, placed in the order the list's `sections` give; a section the list does not name goes after all the others. An item without a section goes above the first - heading, where nothing has to be named to find it. Every other line - stays as it is. + heading, where nothing has to be named to find it; on a calendar, + before the first entry dated later, so the next date is on top. + Every other line stays as it is. """ head, body = _split(text) lines = body.splitlines() line = format_item(item, path=path) - sections = list(parse(text).get("sections") or []) + fm = parse(text) + sections = list(fm.get("sections") or []) if not item.section: first = next((n for n, ln in enumerate(lines) if _HEADING.match(ln)), len(lines)) + if fm.get("kind") == CALENDAR and item.due and \ + (later := _first_dated_after(lines, first, item.due, path)) is not None: + return _join(head, lines[:later] + [line] + lines[later:]) at = _after_last_content(lines, 0, first) return _join(head, lines[:at] + [line] + _gap_before(lines, at)) @@ -386,6 +430,15 @@ def insert_item(text: str, item: Item, *, path: str) -> str: return _join(head, lines[:at] + before + block + after + lines[at:]) +def _first_dated_after(lines: list[str], end: int, due: str, path: str) -> int | None: + """The first line before `end` with an item dated after `due`, or undated.""" + for n in range(end): + other = parse_item(lines[n], path=path) + if other and (not other.due or other.due > due): + return n + return None + + def _heading_line(lines: list[str], section: str) -> int | None: want = section.casefold() for n, ln in enumerate(lines): @@ -517,24 +570,42 @@ def stamp_ticks(text: str, *, path: str, today: str) -> str: return text -def archive_ticked(text: str, archives: dict[str, str], *, path: str, today: str, - bucket: str) -> tuple[str, dict[str, str], list[Item]]: - """Move the items ticked before `today` from a list into its archive. +def over(lst: FamilyList, today: str) -> list[Item]: + """The items that leave the list tonight, for its archive. + + An item ticked before `today`, and on a calendar also an entry dated + before `today`: the day after an appointment it is over, whether + anyone ticked it or not. An item ticked today stays, and a checklist + keeps every tick until it is reset. + """ + if lst.kind == CHECKLIST: + return [] + return [i for i in lst.items + if (i.done and i.done_on and i.done_on < today) + or (lst.kind == CALENDAR and i.due and i.due < today)] + + +def archive_month(item: Item) -> str: + """The month (`YYYY-MM`) an item is archived under: when it was ticked, else its date.""" + return (item.done_on or item.due)[:7] + + +def archive_over(text: str, archives: dict[str, str], *, path: str, today: str, + bucket: str) -> tuple[str, dict[str, str], list[Item]]: + """Move the items that are `over` from a list into its archive. `archives` maps an archive path to its current text ("" or missing for a month with no file yet). Returns the list text, the archive - texts that changed, and the items moved. An item ticked today stays, - and a recurring list keeps every tick until it is reset. + texts that changed, and the items moved. """ lst = read_list(text, path=path) - if lst.recurring: - return text, {}, [] - moving = [i for i in lst.items if i.done and i.done_on and i.done_on < today] + moving = over(lst, today) changed: dict[str, str] = {} for item in moving: - target = archive_path(lst.slug, item.done_on[:7], bucket=bucket) + month = archive_month(item) + target = archive_path(lst.slug, month, bucket=bucket) current = changed.get(target) or archives.get(target) or \ - new_list(f"{lst.title}, {item.done_on[:7]}", sections=lst.sections) + new_list(f"{lst.title}, {month}", sections=lst.sections) changed[target] = insert_item(current, item, path=target) # Last line first, so removing one item never moves the lines of the next. for item in sorted(moving, key=lambda i: i.line, reverse=True): diff --git a/stacklets/agent/runtime/list_tool.py b/stacklets/agent/runtime/list_tool.py index 920280c0..1725a867 100644 --- a/stacklets/agent/runtime/list_tool.py +++ b/stacklets/agent/runtime/list_tool.py @@ -83,7 +83,7 @@ def done(self, slug: str) -> None: "Item operation: add, tick, untick, remove, or move to another " "list. reset reopens every ticked item. create starts a new " "list when none fits, such as a store the family has no list " - "for yet. set changes a list's title, description, topic or " + "for yet. set changes a list's title, description, topic, kind or " "sections, such as linking it to this room's topic. delete " "removes a whole list nobody needs any more.", enum=("add", "tick", "untick", "remove", "move", "reset", "create", "set", @@ -121,6 +121,14 @@ def done(self, slug: str) -> None: "walks through it. Leave out for a list without sections.", nullable=True, ), + kind=StringSchema( + "For create or set: checklist for a list used again and again " + "(packing): ticks stay until reset. calendar for appointments: " + "every add needs due, the entry leaves after its day. Leave out " + "for a to-do list.", + enum=("todo", "checklist", "calendar"), + nullable=True, + ), confirmed=BooleanSchema( description="For delete: true only after the person said yes to " "deleting a list that still has open items.", @@ -163,13 +171,14 @@ async def execute( title: str | None = None, about: str | None = None, sections: "list[str] | None" = None, + kind: str | None = None, confirmed: bool | None = None, ) -> str: now = current_turn() actor = now.person or "someone" if op in ("create", "set"): - args = _details_args(op, list, title, about, topic, sections) + args = _details_args(op, list, title, about, topic, sections, kind) elif op == "delete": slug = (list or "").strip() args = ["stack", "memory", "list", "delete", slug] @@ -236,7 +245,7 @@ def _item_args(op, slug, items, topic, section, people, due, to) -> "list[str]": return args -def _details_args(op, slug, title, about, topic, sections) -> "list[str]": +def _details_args(op, slug, title, about, topic, sections, kind=None) -> "list[str]": """`stack memory list create|set [] --title ...`: a list's own details. `create` makes a missing slug from the title; `set` changes only what is @@ -253,6 +262,8 @@ def _details_args(op, slug, title, about, topic, sections) -> "list[str]": args += ["--topic", topic] for section in sections or []: args += ["--section", section] + if kind: + args += ["--kind", kind] return args diff --git a/stacklets/docs/bot/archivist.py b/stacklets/docs/bot/archivist.py index d9624fac..64b98f76 100644 --- a/stacklets/docs/bot/archivist.py +++ b/stacklets/docs/bot/archivist.py @@ -517,6 +517,8 @@ def _build_services(self) -> ArchivistServices: # returns None and logs the reason; writes become silent skips. mirror = self._build_mirror() vault = VaultContext(language=self.language, shared_bucket=self.shared_bucket) + if mirror: + mirror.vault = vault pipeline = DocumentPipeline( paperless=paperless, classifier=classifier, diff --git a/stacklets/docs/bot/git_mirror.py b/stacklets/docs/bot/git_mirror.py index 505dd5c5..fc79555c 100644 --- a/stacklets/docs/bot/git_mirror.py +++ b/stacklets/docs/bot/git_mirror.py @@ -45,15 +45,20 @@ import asyncio import json import os +import re import secrets from dataclasses import dataclass, field from pathlib import Path +from typing import TYPE_CHECKING from loguru import logger from stack.forgejo import ForgejoClient, ForgejoError from diary_room import household_now + +if TYPE_CHECKING: + from vault_context import VaultContext from vault_entry import ( slug, capture_hash, @@ -68,17 +73,24 @@ fold_email_message, ) from stack.lists import ( # noqa: E402 + CALENDAR, LISTS_DIR, + Item, Link, ListError, action_item, + calendar_description, + calendar_slug, + calendar_title, insert_item, list_path, new_list, + person_link, read_list, relink, topic_list_description, ) +from stack.people import resolve # noqa: E402 # The shared family knowledge vault. The memory stacklet creates and @@ -92,6 +104,23 @@ BOT_EMAIL = "archivist-bot@local" +# ── Dates ──────────────────────────────────────────────────────────────── + +_ISO_DAY = re.compile(r"^\d{4}-\d{2}-\d{2}$") + + +def _dates(raw) -> list[tuple[str, str]]: + """The classifier's `dates` as (what, YYYY-MM-DD); anything malformed is left out.""" + out = [] + for entry in raw if isinstance(raw, list) else []: + if not isinstance(entry, dict): + continue + what, day = str(entry.get("what") or "").strip(), str(entry.get("date") or "").strip() + if what and _ISO_DAY.match(day): + out.append((what, day)) + return out + + def _validate_on_write(frontmatter: dict) -> None: """Log (never block) if a filing's frontmatter violates the vault format. @@ -168,6 +197,10 @@ class GitMirror: # SHARED_BUCKET β†’ archivist β†’ here. shared_bucket: str = "family" paperless_version: str = "" + # The family's people and calendar, read from the local copy of the + # family memory; without it, dates go on a calendar started here and + # name nobody. + vault: VaultContext | None = None _setup_done: bool = field(default=False, init=False) _creds: MirrorCreds | None = field(default=None, init=False) @@ -536,17 +569,11 @@ async def _fold_action_items_into_list( The list is started when the topic has none, titled with the topic's name as classified (`topic_name`, "Haus & Garten"), else - with its slug made readable. Each item links the - record it came from. A record's items go on the list once: when - the list already has items from it, a refile adds none, because - the model words them differently each time and the family may - have ticked, reworded or removed them since. When a refile renamed - the record (`previous_path`), those items are linked to its new - path instead. An item whose text is already on the list is left - alone too. An item due before `today` (the household's date when - not given) stays off the list: an old letter filed late, or a - refile of last year's bill, has nothing left to do. The record's - summary still names it. + with its slug made readable. Each item links the record it came + from and goes on as `_put_on_list` says. An item due before + `today` (the household's date when not given) stays off the list: + an old letter filed late, or a refile of last year's bill, has + nothing left to do. The record's summary still names it. """ source = Link(record_title, record_path) if record_path else None today = today or household_now().date().isoformat() @@ -554,22 +581,54 @@ async def _fold_action_items_into_list( and (not i.due or i.due >= today)] if not (topic and items): return False - path = list_path(topic, bucket=self.shared_bucket) + title = topic_name or topic.replace("-", " ").title() + return await self._put_on_list( + client, path=list_path(topic, bucket=self.shared_bucket), + start=new_list(title, topics=[topic], description=topic_list_description( + title, os.environ.get("LANGUAGE", ""))), + items=items, record_path=record_path, previous_path=previous_path, + message=message, author_name=author_name, author_email=author_email, + ) + + async def _put_on_list( + self, + client: ForgejoClient, + *, + path: str, + start: str, + items: list[Item], + record_path: str, + previous_path: str, + message: str, + author_name: str, + author_email: str, + ) -> bool: + """Add a record's `items` to the list at `path`, started as `start` if missing. + + A record's items go on the list once: when the list already has + items from it, a refile adds none, because the model words them + differently each time and the family may have ticked, reworded or + removed them since. When a refile renamed the record + (`previous_path`), those items are linked to its new path instead. + An item already on the list is left alone: the same text, and on a + calendar the same date too, since the school's Elternabend comes + round again. + """ try: existing = await asyncio.to_thread( client.get_file, self.repo_owner, REPO_NAME, path, ) prior = (existing or {}).get("content") or "" - title = topic_name or topic.replace("-", " ").title() - content = prior or new_list( - title, topics=[topic], - description=topic_list_description(title, os.environ.get("LANGUAGE", ""))) + content = prior or start if previous_path and previous_path != record_path: content = relink(content, previous_path, record_path, path=path) - already = {s for i in read_list(content, path=path).items for s in i.sources} + lst = read_list(content, path=path) + + def key(i: Item) -> tuple[str, str]: + return i.text.casefold(), i.due if lst.kind == CALENDAR else "" + already = {s for i in lst.items for s in i.sources} for item in items if record_path not in already else []: - on_list = read_list(content, path=path).items - if not any(i.text.casefold() == item.text.casefold() for i in on_list): + if key(item) not in {key(i) for i in read_list(content, path=path).items}: content = insert_item(content, item, path=path) if content == prior: return False @@ -582,10 +641,10 @@ async def _fold_action_items_into_list( ) return True except ListError as e: - logger.warning("[git-mirror] {} is not a list, action items not added: {}", path, e) + logger.warning("[git-mirror] {} is not a list, items not added: {}", path, e) return False except ForgejoError as e: - logger.warning("[git-mirror] Action items not added to {}: {}", path, e) + logger.warning("[git-mirror] Items not added to {}: {}", path, e) return False async def _fold_document_todos( @@ -618,6 +677,51 @@ async def _fold_document_todos( today=today, ) + async def _fold_document_dates( + self, + client: ForgejoClient, + *, + classification: dict, + record_path: str, + record_title: str, + previous_path: str = "", + today: str = "", + ) -> bool: + """Put a document's dates on the family's calendar. + + Each entry is assigned to the people the document names, as far + as the family has declared them, and links the document. The + calendar is the family's list of `kind: calendar` (`VaultContext`), + started when there is none. A date already past stays off, as a + past action item does. + """ + today = today or household_now().date().isoformat() + people = self._person_links(classification) + source = Link(record_title, record_path) + entries = [Item(what, due=day, links=(*people, source)) + for what, day in _dates(classification.get("dates")) if day >= today] + if not entries: + return False + language = os.environ.get("LANGUAGE", "") + path, title = (self.vault.calendar() if self.vault else None) or \ + (list_path(calendar_slug(language), bucket=self.shared_bucket), calendar_title(language)) + return await self._put_on_list( + client, path=path, + start=new_list(title, description=calendar_description(language), kind=CALENDAR), + items=entries, record_path=record_path, previous_path=previous_path, + message="chore(lists): archivist added dates to the calendar", + author_name=BOT_USERNAME, author_email=BOT_EMAIL, + ) + + def _person_links(self, classification: dict) -> tuple[Link, ...]: + """Links to the declared people a classification names; unknown names are left out.""" + names = classification.get("persons") or [] + known = (self.vault.people() if self.vault else None) or [] + found = [p for n in ([names] if isinstance(names, str) else names) + if isinstance(n, str) and (p := resolve(n, known))] + return tuple(person_link(p.id, p.first_name, bucket=self.shared_bucket) + for p in dict.fromkeys(found)) + async def _fold_capture_todos( self, client: ForgejoClient, @@ -786,6 +890,9 @@ async def publish( await self._fold_document_todos(client, classification=classification, record_path=target_path, record_title=title, previous_path=existing_path or "") + await self._fold_document_dates(client, classification=classification, + record_path=target_path, record_title=title, + previous_path=existing_path or "") logger.info("[git-mirror] {} #{} β†’ {}", verb, paperless_id, target_path) return True diff --git a/stacklets/docs/bot/pipeline.py b/stacklets/docs/bot/pipeline.py index 2aed6bec..181c10e5 100644 --- a/stacklets/docs/bot/pipeline.py +++ b/stacklets/docs/bot/pipeline.py @@ -1138,7 +1138,8 @@ def _build_classify_prompt(*, ocr_text: str, person_names: list[str], "correspondent_facts": ["STABLE facts about the SENDER organization that are useful on every future document from them: address, phone, email, website, IBAN, your customer/membership/policy number with them. NOT facts about THIS document (totals, invoice numbers, dates β€” those go in facts). Empty list if none visible."], "summary": "2-3 sentence summary. Lead with the document type ({ex['doc_types']}, or its equivalent) and the correspondent; include the document's date and any total amount; name the person(s) involved (use the human note when the document doesn't name them). Examples show the shape, not the wording: {ex['doc_summaries']} Omit a field only when the document genuinely lacks it; never invent.", "facts": ["key structured facts about THIS document, one bullet per fact. Top-level facts: totals, account/invoice/policy numbers, dates, plan/tariff names, deadlines β€” e.g. 'Total: EUR 90.00', 'Invoice: #12345', 'Plan: Premium'. Line items: when the document lists individual purchases or services, include each one as its own bullet with quantity/unit-price/total β€” e.g. 'Donuts 5x EUR 5.00', 'Cola 1x EUR 2.42', 'Reparatur BremsbelΓ€ge EUR 240.00'. Don't fabricate line items the document doesn't print; a one-line receipt has no line items, only a total."], - "action_items": [{{"action": "what needs to happen", "due": "YYYY-MM-DD or null"}}] + "action_items": [{{"action": "what needs to happen", "due": "YYYY-MM-DD or null"}}], + "dates": [{{"what": "the event, in a few words", "date": "YYYY-MM-DD"}}] }} Rules: @@ -1156,6 +1157,7 @@ def _build_classify_prompt(*, ocr_text: str, person_names: list[str], - correspondent_facts: stable across documents from the same sender. Address and customer numbers belong here; this month's total does not. - facts: concrete numbers, dates, account numbers, amounts that describe THIS document. When the document itemises purchases or services, each line item is its own fact bullet alongside the top-level totals. Empty list if none. - action_items: manual family tasks only (pay by transfer, sign/return form, book, cancel by date). NOT: direct debit, SEPA mandate, card on file, standing order, info or confirmation only. [] if none. +- dates: events someone in the family may attend (appointment, school event, performance, parents' evening, trip). Range: its last day as date, the range in what. NOT: deadlines (action_items), payments, opening hours. [] if none. Document text: --- diff --git a/stacklets/docs/bot/vault_context.py b/stacklets/docs/bot/vault_context.py index da4c821e..64cd73e2 100644 --- a/stacklets/docs/bot/vault_context.py +++ b/stacklets/docs/bot/vault_context.py @@ -25,6 +25,7 @@ people_prompt_section, persons_prompt_section, ) +from stack.lists import CALENDAR, LISTS_DIR, ListError, read_list from stack.people import Person, load_people @@ -69,6 +70,21 @@ def correspondents_section(self) -> str: correspondents = load_correspondents_from_vault(path, self.shared_bucket) return correspondents_prompt_section(correspondents) + def calendar(self) -> tuple[str, str] | None: + """(path, title) of the family's calendar list, the first by name; None without one.""" + path = self._vault_path() + if path is None: + return None + for file in sorted((path / self.shared_bucket / LISTS_DIR).glob("*.md")): + rel = f"{self.shared_bucket}/{LISTS_DIR}/{file.name}" + try: + lst = read_list(file.read_text(encoding="utf-8"), path=rel) + except (OSError, ListError): + continue + if lst.kind == CALENDAR: + return rel, lst.title + return None + def people(self) -> list[Person] | None: """The family's declared people; None until a person file exists. diff --git a/stacklets/memory/bot/cli/lists.py b/stacklets/memory/bot/cli/lists.py index b9537e86..985a4a3d 100644 --- a/stacklets/memory/bot/cli/lists.py +++ b/stacklets/memory/bot/cli/lists.py @@ -1,10 +1,11 @@ -"""Archive the ticked items of every list, on the nightly sweep. +"""Archive what is over on every list, on the nightly sweep. An item ticked today stays on its list for the rest of the day, so "did we get eggs?" still has an answer in the store. The night after, it moves to the list's archive, `/lists/archive//.md`, and the list holds only what is still open. Ticks made by hand without -a date get today's date first, and leave the night after. +a date get today's date first, and leave the night after. On a +calendar, an entry also leaves the night after its date. Everything moves in one commit by the memory bot, written to Forgejo against the hash each file was read at, so a tick made while this runs @@ -33,8 +34,10 @@ from stack.lists import ( # noqa: E402 LISTS_DIR, ListError, + archive_month, + archive_over, archive_path, - archive_ticked, + over, read_list, stamp_ticks, ) @@ -56,18 +59,18 @@ async def run(llm, argv: list[str]) -> int: changes, moved = archive_lists(client, bucket=bucket, today=_today()) if changes and "--dry-run" not in argv: client.change_files(REPO_OWNER, REPO_NAME, changes, - message=f"lists: archive {moved} ticked item(s)", + message=f"lists: archive {moved} item(s) that are over", author_name=BOT_USERNAME, author_email=BOT_EMAIL) except (ForgejoError, OSError) as e: _err(f"lists not archived: {e}") return 1 - print(f"archived {moved} ticked item(s)" if moved else "nothing to archive") + print(f"archived {moved} item(s)" if moved else "nothing to archive") return 0 def archive_lists(client: ForgejoClient, *, bucket: str, today: str) -> tuple[list[FileChange], int]: - """The file changes that archive every list's ticked items, and how many moved. + """The file changes that archive what is over on every list, and how many moved. A file that is not a list is reported and left alone. """ @@ -81,8 +84,7 @@ def archive_lists(client: ForgejoClient, *, bucket: str, text = current.get("content") or "" try: stamped = stamp_ticks(text, path=path, today=today) - months = {i.done_on[:7] for i in read_list(stamped, path=path).items - if i.done and i.done_on and i.done_on < today} + months = {archive_month(i) for i in over(read_list(stamped, path=path), today)} except ListError as e: _err(f" {e}; left alone") continue @@ -92,7 +94,7 @@ def archive_lists(client: ForgejoClient, *, bucket: str, target = archive_path(slug, month, bucket=bucket) if found := client.get_file(REPO_OWNER, REPO_NAME, target): archives[target], shas[target] = found.get("content") or "", found.get("sha", "") - after, written, items = archive_ticked(stamped, archives, path=path, + after, written, items = archive_over(stamped, archives, path=path, today=today, bucket=bucket) if after != text: changes.append(FileChange("update", path, after, sha=current.get("sha", ""))) diff --git a/stacklets/memory/cli/list.py b/stacklets/memory/cli/list.py index f2828b9c..2f3acd04 100644 --- a/stacklets/memory/cli/list.py +++ b/stacklets/memory/cli/list.py @@ -41,6 +41,9 @@ from stack.links import go_page, public # noqa: E402 from stack.lists import ( # noqa: E402 ARCHIVE_DIR, + CALENDAR, + KINDS, + TODO, LISTS_DIR, FamilyList, Item, @@ -73,12 +76,15 @@ stack memory list tick|untick|remove einkaufsliste Joghurt --as homer stack memory list tick "Erlaubniszettel" --as marge on whichever list it is stack memory list move einkaufsliste Eier --to markt --as marge - stack memory list create packliste --title Packliste --recurring --as marge + stack memory list create packliste --title Packliste --kind checklist --as marge ticked items stay until a reset + stack memory list create termine --title Termine --kind calendar --as marge + in date order, each entry gone + the day after its date stack memory list reset packliste --as marge reopen every ticked item stack memory list set einkaufsliste --topic einkauf --as marge - the list's title, description, topics - or walking order (--section, in order) + the list's title, description, topics, + kind or walking order (--section, in order) stack memory list delete baumarkt [--yes] --as marge --yes: even with items still open """ @@ -114,7 +120,7 @@ def _parser() -> argparse.ArgumentParser: create.add_argument("--description", default="") create.add_argument("--topic", action="append", default=[]) create.add_argument("--section", action="append", default=[]) - create.add_argument("--recurring", action="store_true") + create.add_argument("--kind", choices=KINDS, default=KINDS[0]) add = verbs.add_parser("add", add_help=False) # The list is optional: `add Joghurt` puts a known item where it was @@ -145,6 +151,7 @@ def _parser() -> argparse.ArgumentParser: details.add_argument("--description") details.add_argument("--topic", action="append") details.add_argument("--section", action="append") + details.add_argument("--kind", choices=KINDS) reset = verbs.add_parser("reset", add_help=False) reset.add_argument("slug") @@ -207,9 +214,11 @@ def show(vault: Path, slug: str | None, *, show_all: bool) -> dict: print("No lists yet. Start one with `stack memory list create`.") for lst in lists: about = f" ({lst.description})" if lst.description else "" - print(f"{lst.slug}: {lst.title}, {len(lst.open_items)} open{about}") + kind = f" ({lst.kind})" if lst.kind != TODO else "" + print(f"{lst.slug}: {lst.title}{kind}, {len(lst.open_items)} open{about}") return {"lists": [{"slug": x.slug, "title": x.title, "description": x.description, - "topics": x.topics, "open": len(x.open_items)} for x in lists]} + "kind": x.kind, "topics": x.topics, "open": len(x.open_items)} + for x in lists]} path, lst = _find_list(vault, slug) items = lst.items if show_all else lst.open_items @@ -292,13 +301,13 @@ def _find_list(vault: Path, slug: str) -> tuple[str, FamilyList]: # ── Writing ────────────────────────────────────────────────────────────── def change_details(vault: Path, config, ns, actor: str) -> dict: - """Change a list's title, description, topics or walking order.""" + """Change a list's title, description, topics, kind or walking order.""" path, lst = _find_list(vault, ns.slug) given = {"title": ns.title, "description": ns.description, - "topics": _named(ns.topic), "sections": _named(ns.section)} + "topics": _named(ns.topic), "sections": _named(ns.section), "kind": ns.kind} changed = [name for name, value in given.items() if value is not None] if not changed: - raise Refusal("name what to change: --title, --description, --topic or --section") + raise Refusal("name what to change: --title, --description, --topic, --section or --kind") def _apply(prior: str) -> str: return set_details(prior, **given) @@ -320,7 +329,7 @@ def create(vault: Path, config, ns, actor: str) -> dict: raise Refusal(f"the list {slug!r} already exists") path = list_path(slug, bucket=_shared_bucket(config)) text = new_list(ns.title.strip(), description=ns.description.strip(), - topics=ns.topic, sections=ns.section, recurring=ns.recurring) + topics=ns.topic, sections=ns.section, kind=ns.kind) def _create(prior: str) -> str: if (prior or "").strip(): @@ -359,6 +368,9 @@ def add(vault: Path, config, ns, actor: str) -> dict: due = ns.due if due and not _ISO_DAY.match(due): raise Refusal(f"--due {due!r} is not a date like 2026-10-14") + undated = [by_slug[t][1].title for t in plan if by_slug[t][1].kind == CALENDAR and not due] + if undated: + raise Refusal(f"an entry on {undated[0]} needs its date: --due YYYY-MM-DD") people = _people(vault, config, ns.people) return _combined([_add_to(config, *by_slug[target], entries, people, due, actor) for target, entries in plan.items()]) diff --git a/tests/unit/framework/test_lists.py b/tests/unit/framework/test_lists.py index 812be31e..47a39d8b 100644 --- a/tests/unit/framework/test_lists.py +++ b/tests/unit/framework/test_lists.py @@ -24,7 +24,8 @@ Link, Known, archive_path, - archive_ticked, + archive_over, + over, known_items, stamp_ticks, ListError, @@ -151,6 +152,20 @@ def test_a_list_needs_only_a_title(self): assert text == "---\ntype: list\ntitle: Garage aufrΓ€umen\n---\n" assert read_list(text, path="family/lists/garage.md").items == [] + def test_a_list_is_a_todo_list_unless_its_kind_says_otherwise(self): + """`kind` is the family memory's field for the variant of a type.""" + path = "family/lists/packliste.md" + assert read_list(new_list("Garage aufrΓ€umen"), path=path).kind == "todo" + checklist = new_list("Packliste", kind="checklist") + assert "kind: checklist\n" in checklist + assert read_list(checklist, path=path).kind == "checklist" + + def test_a_kind_it_does_not_know_is_refused_with_the_ones_it_does(self): + """A family member editing the file by hand learns what to write.""" + with pytest.raises(ListError, match="todo, checklist"): + read_list("---\ntype: list\ntitle: Packliste\nkind: packing\n---\n", + path="family/lists/packliste.md") + def test_a_file_that_is_not_a_list_is_refused(self): with pytest.raises(ListError, match="not a list"): read_list("---\ntype: note\ntitle: Einkauf\ntimestamp: x\n---\n", @@ -221,14 +236,14 @@ def _shopped(self) -> str: return text def test_the_list_keeps_what_is_open_and_what_was_ticked_today(self): - text, _, moved = archive_ticked(self._shopped(), {}, path=GROCERIES, + text, _, moved = archive_over(self._shopped(), {}, path=GROCERIES, today=self.TODAY, bucket="family") assert [i.text for i in moved] == ["Γ„pfel", "Joghurt"] assert [(i.text, i.done) for i in read_list(text, path=GROCERIES).items] == \ [("Birnen", False), ("Eier", True)] def test_each_item_goes_to_the_month_it_was_ticked_in_its_section(self): - _, archives, _ = archive_ticked(self._shopped(), {}, path=GROCERIES, + _, archives, _ = archive_over(self._shopped(), {}, path=GROCERIES, today=self.TODAY, bucket="family") october = archive_path("einkaufsliste", "2026-10", bucket="family") assert sorted(archives) == [archive_path("einkaufsliste", "2026-09", bucket="family"), @@ -239,7 +254,7 @@ def test_each_item_goes_to_the_month_it_was_ticked_in_its_section(self): [("Γ„pfel", "GemΓΌse & Obst", "2026-10-11")] def test_an_emptied_section_loses_its_heading(self): - text, _, _ = archive_ticked(self._shopped(), {}, path=GROCERIES, + text, _, _ = archive_over(self._shopped(), {}, path=GROCERIES, today="2026-10-13", bucket="family") assert "Milchprodukte" not in text.split("---\n", 2)[2] @@ -248,26 +263,62 @@ def test_a_month_already_archived_gains_the_new_items(self): earlier = insert_item(new_list("Einkaufsliste, 2026-10", sections=STORE), Item("Milch", done=True, done_on="2026-10-02", section="Milchprodukte & Eier"), path=october) - _, archives, _ = archive_ticked(self._shopped(), {october: earlier}, path=GROCERIES, + _, archives, _ = archive_over(self._shopped(), {october: earlier}, path=GROCERIES, today=self.TODAY, bucket="family") assert [i.text for i in read_list(archives[october], path=october).items] == \ ["Γ„pfel", "Milch"] - def test_a_recurring_list_keeps_its_ticks_for_the_next_reset(self): - packing = new_list("Packliste", recurring=True) + "\n- [x] Zelt βœ… 2026-10-01\n" + def test_a_checklist_keeps_its_ticks_for_the_next_reset(self): + packing = new_list("Packliste", kind="checklist") + "\n- [x] Zelt βœ… 2026-10-01\n" path = "family/lists/packliste.md" - assert read_list(packing, path=path).recurring - assert archive_ticked(packing, {}, path=path, today=self.TODAY, + assert archive_over(packing, {}, path=path, today=self.TODAY, bucket="family") == (packing, {}, []) def test_a_tick_made_by_hand_gets_a_date_first(self): text = _groceries().rstrip("\n") + "\n\n- [x] Batterien\n" stamped = stamp_ticks(text, path=GROCERIES, today=self.TODAY) assert "- [x] Batterien βœ… 2026-10-12" in stamped - assert archive_ticked(stamped, {}, path=GROCERIES, today=self.TODAY, + assert archive_over(stamped, {}, path=GROCERIES, today=self.TODAY, bucket="family")[2] == [] +class TestACalendar: + """The family's dates: next one on top, each gone the day after it.""" + + PATH = list_path("termine", bucket="family") + + def _dates(self, *entries: tuple[str, str]) -> str: + text = new_list("Termine", kind="calendar") + for name, due in entries: + text = insert_item(text, Item(name, due=due), path=self.PATH) + return text + + def test_entries_stay_in_date_order_whenever_they_come_in(self): + text = self._dates(("Elternabend", "2026-11-17"), ("Theater", "2026-11-03"), + ("Zahnarzt", "2026-11-05"), ("Laternenumzug", "2026-11-11")) + assert [i.text for i in read_list(text, path=self.PATH).items] == \ + ["Theater", "Zahnarzt", "Laternenumzug", "Elternabend"] + + def test_an_entry_leaves_the_day_after_its_date_ticked_or_not(self): + text = self._dates(("Theater", "2026-11-03"), ("Zahnarzt", "2026-11-05")) + lst = read_list(text, path=self.PATH) + assert [i.text for i in over(lst, "2026-11-05")] == ["Theater"], \ + "on its own day the appointment is still ahead" + + def test_it_is_archived_under_the_month_of_its_date(self): + text = self._dates(("Theater", "2026-11-03"), ("Zahnarzt", "2026-11-05")) + kept, archives, moved = archive_over(text, {}, path=self.PATH, + today="2026-12-01", bucket="family") + assert [i.text for i in moved] == ["Theater", "Zahnarzt"] + assert list(archives) == [archive_path("termine", "2026-11", bucket="family")] + assert read_list(kept, path=self.PATH).items == [] + + def test_a_todo_past_its_due_date_stays_until_someone_ticks_it(self): + """A deadline missed is still a thing to do; only an appointment is simply over.""" + text = _add(_groceries(), "Rezept abholen", due="2026-10-01") + assert over(read_list(text, path=GROCERIES), "2026-10-12") == [] + + class TestKnownItems: def _archive(self, slug, *items) -> tuple[str, object]: path = archive_path(slug, "2026-10", bucket="family") @@ -304,6 +355,12 @@ def test_title_and_description_change_alone(self): path=GROCERIES) assert (lst.title, lst.description, lst.sections) == ("Supermarkt", "Rewe", STORE) + def test_a_list_becomes_a_checklist_and_back(self): + """A todo list is the default, so turning back leaves no `kind` behind.""" + checklist = set_details(_groceries(), kind="checklist") + assert read_list(checklist, path=GROCERIES).kind == "checklist" + assert "kind:" not in set_details(checklist, kind="todo") + def test_no_topics_leaves_none(self): assert read_list(set_details(_groceries(), topics=[]), path=GROCERIES).topics == [] diff --git a/tests/unit/stacklets/test_agent_vault_tools.py b/tests/unit/stacklets/test_agent_vault_tools.py index 42f73462..8cd00657 100644 --- a/tests/unit/stacklets/test_agent_vault_tools.py +++ b/tests/unit/stacklets/test_agent_vault_tools.py @@ -204,6 +204,8 @@ def test_the_allowlist_stays_read_only(api): {"list": "baumarkt", "op": "create", "title": "Baumarkt", "about": "Werkzeug und Material", "topic": "einkaeufe-besorgungen", "sections": ["Schrauben", "Farbe"], "items": ["ignored"]}), + ("list_edit", "list", {"op": "create", "title": "Packliste", "kind": "checklist"}), + ("list_edit", "list", {"list": "packliste", "op": "set", "kind": "todo"}), ]) def test_the_flags_a_tool_sends_are_flags_the_cli_accepts( memory_cli, vault_tools, tool_name, command, kwargs): diff --git a/tests/unit/stacklets/test_git_mirror.py b/tests/unit/stacklets/test_git_mirror.py index cb14ddef..91b73afc 100644 --- a/tests/unit/stacklets/test_git_mirror.py +++ b/tests/unit/stacklets/test_git_mirror.py @@ -583,6 +583,89 @@ async def test_a_personal_capture_adds_to_no_list(self, mirror): assert client.puts == [] +class TestDatesOnTheFamilysCalendar: + """A letter's dates go on the calendar: in date order, for the child, linked back.""" + + LETTER = "family/documents/2026/10/2026-10-07-elternbrief-p80.md" + + @pytest.fixture + def family(self, tmp_path, monkeypatch, mirror): + """The Simpsons' family memory on disk, as the archivist's copy holds it.""" + sys.path.insert(0, str(_REPO_ROOT / "stacklets")) # memory.lib, for VaultContext + from vault_context import VaultContext + + vault = tmp_path / "vault" + (vault / "family" / "people").mkdir(parents=True) + (vault / "family" / "lists").mkdir() + (vault / "family" / "people" / "lisa.md").write_text( + "---\ntype: person\ntitle: Lisa Simpson\n---\n") + monkeypatch.setenv("MEMORY_VAULT_DIR", str(vault)) + monkeypatch.setenv("LANGUAGE", "de") + mirror.vault = VaultContext(language="de", shared_bucket="family") + return vault + + async def _file(self, mirror, client, dates, *, persons=("Lisa",), path=LETTER): + await mirror._fold_document_dates( + client, classification={"persons": list(persons), "dates": dates}, + record_path=path, record_title="Elternbrief Grundschule", today="2026-10-13") + + @pytest.mark.asyncio + async def test_a_school_letter_starts_the_calendar_with_its_dates(self, mirror, family): + client = _FakeTodoClient() + await self._file(mirror, client, [ + {"what": "Elternabend", "date": "2026-11-17"}, + {"what": "Theater", "date": "2026-11-03"}, + {"what": "Interkulturelles Familienfest", "date": "2026-10-10"}, + ]) + + written = client.files["family/lists/termine.md"]["content"] + assert "title: Termine\n" in written and "kind: calendar\n" in written + assert written.index("Theater") < written.index("Elternabend"), "next date on top" + assert ("- [ ] Theater [Lisa](../people/lisa.md) [Elternbrief Grundschule]" + "(../documents/2026/10/2026-10-07-elternbrief-p80.md) πŸ“… 2026-11-03") in written + assert "Familienfest" not in written, "a date already past stays off" + + @pytest.mark.asyncio + async def test_dates_join_the_calendar_the_family_already_has(self, mirror, family): + own = "---\ntype: list\ntitle: Kalender\nkind: calendar\n---\n\n- [ ] Zahnarzt πŸ“… 2026-11-05\n" + (family / "family" / "lists" / "kalender.md").write_text(own) + client = _FakeTodoClient({"family/lists/kalender.md": {"content": own, "sha": "k1"}}) + + await self._file(mirror, client, [{"what": "Theater", "date": "2026-11-03"}]) + + assert "family/lists/termine.md" not in client.files + written = client.files["family/lists/kalender.md"]["content"] + assert written.index("Theater") < written.index("Zahnarzt") + assert client.puts[0]["sha"] == "k1" + + @pytest.mark.asyncio + async def test_someone_the_family_has_not_declared_is_left_off_the_entry(self, mirror, family): + client = _FakeTodoClient() + await self._file(mirror, client, [{"what": "Theater", "date": "2026-11-03"}], + persons=("Lisa", "Frau Krabappel")) + + written = client.files["family/lists/termine.md"]["content"] + assert "[Lisa](../people/lisa.md)" in written and "Krabappel" not in written + + @pytest.mark.asyncio + async def test_the_same_event_next_year_is_a_new_entry(self, mirror, family): + """Unlike a to-do, an appointment is told apart by its date as much as its name.""" + client = _FakeTodoClient() + await self._file(mirror, client, [{"what": "Elternabend", "date": "2026-11-17"}]) + await self._file(mirror, client, [{"what": "Elternabend", "date": "2027-03-09"}], + path="family/documents/2027/02/2027-02-20-elternbrief-p91.md") + + assert client.files["family/lists/termine.md"]["content"].count("Elternabend") == 2 + + @pytest.mark.asyncio + async def test_a_letter_without_dates_touches_no_list(self, mirror, family): + client = _FakeTodoClient() + await self._file(mirror, client, []) + await self._file(mirror, client, [{"what": "Theater", "date": "im November"}]) + + assert client.puts == [] + + # ── Captures ───────────────────────────────────────────────────────────── # # A capture is a non-Paperless source (today: a pasted URL processed by diff --git a/tests/unit/stacklets/test_memory_list_archive.py b/tests/unit/stacklets/test_memory_list_archive.py index 37cad5cf..d6235d1b 100644 --- a/tests/unit/stacklets/test_memory_list_archive.py +++ b/tests/unit/stacklets/test_memory_list_archive.py @@ -77,3 +77,23 @@ def test_a_quiet_night_changes_nothing(httpserver): client = ForgejoClient(url=httpserver.url_for(""), token="bottoken") assert nightly.archive_lists(client, bucket="family", today="2026-10-12") == ([], 0) + + +def test_the_morning_after_an_appointment_it_is_off_the_calendar(httpserver): + calendar = ("---\ntype: list\ntitle: Termine\nkind: calendar\n---\n\n" + "- [ ] Theater πŸ“… 2026-11-03\n- [ ] Elternabend πŸ“… 2026-11-17\n") + httpserver.expect_request(f"{CONTENTS}/family/lists", method="GET").respond_with_json( + [{"type": "file", "path": "family/lists/termine.md"}]) + httpserver.expect_request(f"{CONTENTS}/family/lists/termine.md", method="GET") \ + .respond_with_json(_file(calendar, "cal-sha")) + httpserver.expect_request(f"{CONTENTS}/family/lists/archive/termine/2026-11.md", + method="GET").respond_with_data("", status=404) + client = ForgejoClient(url=httpserver.url_for(""), token="bottoken") + + changes, moved = nightly.archive_lists(client, bucket="family", today="2026-11-04") + + assert moved == 1 + by_path = {c.path: c for c in changes} + left = read_list(by_path["family/lists/termine.md"].content, path="family/lists/termine.md") + assert [i.text for i in left.items] == ["Elternabend"] + assert by_path["family/lists/archive/termine/2026-11.md"].operation == "create" diff --git a/tests/unit/stacklets/test_memory_list_cli.py b/tests/unit/stacklets/test_memory_list_cli.py index d513edd9..c06a5f19 100644 --- a/tests/unit/stacklets/test_memory_list_cli.py +++ b/tests/unit/stacklets/test_memory_list_cli.py @@ -108,7 +108,7 @@ def test_show_names_every_list_with_what_is_open(self, run, groceries): result = run("show") assert result["lists"] == [{"slug": "einkaufsliste", "title": "Einkaufsliste", "description": "Was wir im Supermarkt brauchen", - "topics": [], "open": 1}] + "kind": "todo", "topics": [], "open": 1}] def test_a_list_that_exists_is_not_started_again(self, run, groceries): result = run("create", "einkaufsliste", "--title", "Noch eine", "--as", "homer") @@ -118,6 +118,26 @@ def test_a_list_named_by_its_title_alone_gets_a_slug_from_it(self, run, store): result = run("create", "--title", "Garage aufrΓ€umen", "--as", "homer") assert result["path"] == "family/lists/garage-aufraumen.md" + def test_a_packing_list_is_started_as_a_checklist(self, run, store): + """Its ticks stay for the next trip; a todo list would archive them overnight.""" + result = run("create", "packliste", "--title", "Packliste", "--kind", "checklist", + "--as", "marge") + assert "kind: checklist\n" in store.text(result["path"]) + + def test_a_list_turns_into_a_checklist(self, run, store, groceries): + run("set", "einkaufsliste", "--kind", "checklist", "--as", "marge") + assert "kind: checklist\n" in store.text("family/lists/einkaufsliste.md") + + def test_an_appointment_needs_its_date(self, run, store): + """A calendar is ordered by date and cleared by it; an entry without one would stay forever.""" + run("create", "termine", "--title", "Termine", "--kind", "calendar", "--as", "marge") + refused = run("add", "termine", "Elternabend", "--as", "marge") + assert "needs its date" in refused["error"] + run("add", "termine", "Elternabend", "--due", "2026-11-17", "--as", "marge") + run("add", "termine", "Theater", "--due", "2026-11-03", "--as", "marge") + assert store.text("family/lists/termine.md").index("Theater") < \ + store.text("family/lists/termine.md").index("Elternabend") + def test_a_slug_is_lowercase_words_and_dashes(self, run): assert "not a slug" in run("create", "Einkauf Liste", "--title", "x", "--as", "marge")["error"]