"""Settings → Timeline, as the reader applies it. Three settings, read together: - timeline.areas: [[key, label], …], the areas in the order the page lists them. - timeline.types: {type: {"area": key, "shown": bool}}, each only where it departs from the catalog. An "area" there sorts every entry of the type under it; without one, an entry sorts under the area it was written with. - timeline.area_moved: {removed key: key its entries now sort under}. Nothing here is written onto an entry, so a change re-sorts the whole history, and a hidden type's entries come back when it is shown again. """ from __future__ import annotations import re from dataclasses import dataclass, field from sqlalchemy import and_, false, or_, true from . import catalog from steward.models.timeline import TimelineEntry @dataclass class Config: areas: list[list[str]] types: dict[str, dict] = field(default_factory=dict) moved: dict[str, str] = field(default_factory=dict) @property def labels(self) -> dict[str, str]: return {key: label for key, label in self.areas} def follow(self, area: str) -> str: """Where an area's entries sort now: through any removals, to a live area, or Other.""" seen = set() while area in self.moved and area not in seen: seen.add(area) area = self.moved[area] return area if area in self.labels else catalog.OTHER[0] def type_area(self, type_: str) -> str | None: """The area Settings sorts every entry of this type under, or None.""" return (self.types.get(type_) or {}).get("area") or None def shown(self, type_: str) -> bool: override = (self.types.get(type_) or {}).get("shown") if override is not None: return bool(override) known = catalog.TYPES.get(type_) return known.shown if known else True def area_of(self, entry) -> str: return self.follow(self.type_area(entry.type) or entry.area) def area_label(self, key: str) -> str: return self.labels.get(key) or catalog.OTHER[1] def hidden_types(self) -> set[str]: keys = set(catalog.TYPES) | set(self.types) return {k for k in keys if not self.shown(k)} def area_filter(self, area: str): """A SQL condition for "entries sorting under `area`".""" sources = [k for k in [*self.labels, *self.moved, catalog.OTHER[0]] if self.follow(k) == area] fixed = {t for t, o in self.types.items() if o.get("area")} to_here = [t for t in fixed if self.follow(self.types[t]["area"]) == area] written = TimelineEntry.area.in_(sources) if sources else false() if area == catalog.OTHER[0]: # An area nobody listed (a plugin's, gone) sorts under Other too. written = or_(written, TimelineEntry.area.notin_([*self.labels, *self.moved])) unfixed = TimelineEntry.type.notin_(fixed) if fixed else true() return or_(TimelineEntry.type.in_(to_here) if to_here else false(), and_(unfixed, written)) def kind_types(kind: str) -> list[str]: return [t.key for t in catalog.TYPES.values() if t.kind == kind] async def load(db) -> Config: from steward.core.settings import get_setting areas = await get_setting(db, "timeline.areas") or catalog.DEFAULT_AREAS return Config(areas=[list(a) for a in areas], types=dict(await get_setting(db, "timeline.types") or {}), moved=dict(await get_setting(db, "timeline.area_moved") or {})) def slug(label: str) -> str: return re.sub(r"[^a-z0-9]+", "_", label.strip().lower()).strip("_")[:32] def edited(cfg: Config, form) -> tuple[Config, str | None]: """The settings Settings → Timeline's form asks for, or the reason it can't have them. The form carries, per area, `area_label_` and `area_move_` ("" to keep it, else the key its entries go to); `new_area` for one more; and per type `type_area_` ("" for as written) and `type_shown_` (present when ticked).""" areas, moved = [], dict(cfg.moved) for key, label in cfg.areas: dest = (form.get(f"area_move_{key}") or "").strip() if dest: if dest == key or dest not in cfg.labels: return cfg, f"Pick another area for {label}'s entries to go to." moved[key] = dest continue areas.append([key, (form.get(f"area_label_{key}") or "").strip() or label]) new = (form.get("new_area") or "").strip() if new: key = slug(new) if not key or key == catalog.OTHER[0] or key in {k for k, _ in cfg.areas}: return cfg, f"There is already an area like “{new}”; pick another name." areas.append([key, new]) moved.pop(key, None) if not areas: return cfg, "Keep at least one area." live = {k for k, _ in areas} # An area listed again is no longer moved; one moved into an area that # was itself moved goes on to wherever that went (Config.follow). moved = {k: d for k, d in moved.items() if k not in live} after = Config(areas=areas, moved=moved) types = {} for key, t in catalog.TYPES.items(): override = {} area = (form.get(f"type_area_{key}") or "").strip() if area: # A type sorted under an area removed in this same save follows its entries. area = after.follow(area) if area not in live: return cfg, f"“{t.label}” names an area that isn't listed." override["area"] = area shown = f"type_shown_{key}" in form if shown != t.shown: override["shown"] = shown if override: types[key] = override # Types of a plugin that isn't loaded keep what they had. types.update({k: v for k, v in cfg.types.items() if k not in catalog.TYPES}) after.types = types return after, None async def save(db, cfg: Config) -> None: from steward.core.settings import set_setting await set_setting(db, "timeline.areas", cfg.areas) await set_setting(db, "timeline.types", cfg.types) await set_setting(db, "timeline.area_moved", cfg.moved)