Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions docs/user-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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."

Expand Down
2 changes: 1 addition & 1 deletion lib/stack/frontmatter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
),
}

Expand Down
121 changes: 96 additions & 25 deletions lib/stack/lists.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand Down Expand Up @@ -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]:
Expand All @@ -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"
Expand All @@ -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 = ""
Expand All @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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))

Expand All @@ -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):
Expand Down Expand Up @@ -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):
Expand Down
17 changes: 14 additions & 3 deletions stacklets/agent/runtime/list_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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.",
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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 [<list>] --title ...`: a list's own details.

`create` makes a missing slug from the title; `set` changes only what is
Expand All @@ -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


Expand Down
2 changes: 2 additions & 0 deletions stacklets/docs/bot/archivist.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading
Loading