provozni zaloha

This commit is contained in:
lachtan
2026-06-24 08:11:12 +02:00
parent 9295dba19f
commit 1db3ec4756
97 changed files with 7698 additions and 817 deletions

View File

@@ -18,6 +18,7 @@ Reply to the user in their own language.
| "every day at 9" / "every weekday at 9:30" | `add --cron "0 9 * * *"` |
| "on 2026-06-15 at 18:00" / "once at …" | `add --at "2026-06-15T18:00:00"` |
| "randomly 2× between 08:00 and 20:00" | `add --random-times-per-day 2 --random-window 08:00-20:00` |
| "randomly 2× a week between 08:00 and 20:00" | `add --random-times-per-week 2 --random-window 08:00-20:00` |
| "what reminders arrived today / since when" | `delivered [--since YYYY-MM-DD]` |
| "what goes out today / tomorrow / this week" | `upcoming [--date YYYY-MM-DD \| --days N]` |
| list all reminders | `list` |
@@ -31,30 +32,40 @@ uv run skills/remind/scripts/remind_cli.py <command> --help
## Behavioral contract
**Showing read results.** `list`, `upcoming`, and `delivered` return text for the user — present it, never collapse to a count. For `list`, rewrite the raw output into a compact, readable form of your own: **one reminder per line**, schedules paraphrased to natural language (`30 9 * * 1-5` → "9:30 on weekdays"). Show **only enabled** reminders — skip disabled ones; keep each shown reminder's `#display-id` exactly as the CLI printed it (so `--id` still matches — gaps from skipped disabled ones are fine). Don't print the `[enabled]` marker.
**`list`** returns readable text. Each reminder:
```
#<id> text [enabled|disabled]
#<display-id> text [enabled|disabled]
cron: 0 9 * * *
at: 2026-06-15T18:00:00
random: 2× daily 09:0021:00 (1-5) from 2026-06-01
random: 2× weekly 08:0020:00
```
An empty store prints `(no active reminders)`.
**Display IDs** (`#1`, `#2`, …) are sequential positions among active reminders, computed on the fly — never the internal DB id. They renumber after every `remove`, so always run `list` first when unsure. The internal DB id is never shown to the user; do not surface the `id` field from mutation JSON as `#…`.
A weekly random schedule fires `N` times across the week (MonSun) on `N` distinct
random days, one random time each inside the window. `--random-days`/`--random-from`/
`--random-until` narrow the eligible days; a partial week at a from/until edge squeezes
the full weekly count into the days that remain (no proration).
**Mutations** (`add`, `edit`, `remove`, `enable`, `disable`) return JSON: `{"added": …}`, `{"edited": …}`, etc. Errors go to stderr with a non-zero exit code.
**Selecting a reminder:** `edit`, `remove`, `enable`, `disable` accept `--keyword` (case-insensitive substring) or `--id` (exact). An ambiguous keyword match returns `{"error": "ambiguous", "matches": []}` — retry with `--id <n>`. Run `list` to see ids.
**Selecting a reminder:** `edit`, `remove`, `enable`, `disable` accept `--keyword` (case-insensitive substring) or `--id` (the **display ID** from `list`). An ambiguous keyword match returns `{"error": "ambiguous", "matches": [{"display_id": n, "text": …}]}` — retry with `--id <display-id>`. Run `list` to see current display IDs.
**`delivered`** reads the `reminder_fires` table (delivered rows only, Prague local time). Defaults to today; `--since YYYY-MM-DD` widens the window. The agent never sees deliveries happen — this is the only window into them.
**`upcoming`** returns readable text: each scheduled fire as `YYYY-MM-DD HH:MM #id text (type)`, sorted by time. It shows the *plan* (computed from the schedules), not actual deliveries — use `delivered` for those. Defaults to the rest of today; `--date` shows one whole day, `--days N` the next N calendar days. An empty window prints `(nothing scheduled in this window)`.
**`upcoming`** returns readable text: each scheduled fire as `YYYY-MM-DD HH:MM #display-id text (type)`, sorted by time. The `#display-id` matches the one in `list`. It shows the *plan* (computed from the schedules), not actual deliveries — use `delivered` for those. Defaults to the rest of today; `--date` shows one whole day, `--days N` the next N calendar days. An empty window prints `(nothing scheduled in this window)`.
**`remove`** is a soft delete.
## Editing reminders
**To fix or change wording:** use `edit --id <n> --text "…"` (get the id from `list`),
**To fix or change wording:** use `edit --id <display-id> --text "…"` (get the display ID from `list`),
or `edit --keyword <kw> --text "…"`.
**NEVER remove + re-add a reminder just to change its text** — that loses the delivery history and changes the id.

View File

@@ -49,6 +49,7 @@ CREATE TABLE IF NOT EXISTS schedule_random (
days_filter TEXT,
from_date TEXT,
until_date TEXT,
period TEXT NOT NULL DEFAULT 'day' CHECK(period IN ('day', 'week')),
CHECK(window_start < window_end)
);
@@ -79,9 +80,21 @@ def get_db(path: Path) -> sqlite3.Connection:
conn.execute("PRAGMA journal_mode = WAL")
conn.execute("PRAGMA foreign_keys = ON")
conn.row_factory = sqlite3.Row
_migrate(conn)
return conn
def _migrate(conn: sqlite3.Connection) -> None:
"""Idempotently bring an existing DB up to the current schema.
init_db only runs on a missing file, so live DBs never see schema additions.
Each step is guarded to be a no-op once applied.
"""
columns = {row["name"] for row in conn.execute("PRAGMA table_info(schedule_random)")}
if columns and "period" not in columns:
conn.execute("ALTER TABLE schedule_random ADD COLUMN period TEXT NOT NULL DEFAULT 'day'")
def init_db(path: Path) -> None:
"""Create tables and indexes if they don't exist."""
path.parent.mkdir(parents=True, exist_ok=True)

View File

@@ -42,11 +42,11 @@ def fires_in_window(conn, start: datetime, end: datetime) -> list[dict]:
return fires
def format_upcoming(fires: list[dict]) -> list[str]:
def format_upcoming(fires: list[dict], id_to_display: dict[int, int]) -> list[str]:
if not fires:
return ["(nothing scheduled in this window)"]
return [
f"{f['fire_time']:%Y-%m-%d %H:%M} #{f['id']} {f['text']} ({f['schedule_type']})"
f"{f['fire_time']:%Y-%m-%d %H:%M} #{id_to_display[f['id']]} {f['text']} ({f['schedule_type']})"
for f in fires
]
@@ -93,7 +93,7 @@ def _random_fires(conn, start: datetime, end: datetime) -> list[dict]:
rows = conn.execute(
"""
SELECT r.id, r.text, sr.times_per_day, sr.window_start, sr.window_end,
sr.days_filter, sr.from_date, sr.until_date
sr.days_filter, sr.from_date, sr.until_date, sr.period
FROM reminders r
JOIN schedule_random sr ON sr.reminder_id = r.id
WHERE r.enabled = 1 AND r.deleted_at IS NULL

View File

@@ -12,20 +12,25 @@ same result, so no state needs to be persisted.
from __future__ import annotations
import random
from datetime import date, datetime, time
from datetime import date, datetime, time, timedelta
MIN_GAP_MIN = 15 # minimum gap between fire times in minutes; tune here
DAYS_PER_WEEK = 7
def compute_fire_times(target_date: date, text: str, cfg: dict) -> list[datetime]:
"""Deterministic fire times for one day.
Returns [] when the day falls outside the days/from/until filters. Raises
ValueError on a malformed config (bad window, days, dates, or when the
requested count cannot fit the window with MIN_GAP_MIN spacing) — these are
structural and validated before any date filter, so the same call validates
a config regardless of the date passed in.
With period 'day' (default) the count is per day; with 'week' it is per week,
spread across distinct days. Returns [] when the day falls outside the
days/from/until filters. Raises ValueError on a malformed config (bad window,
days, dates, or an infeasible count) — these are structural and validated
before any date filter, so the same call validates a config regardless of the
date passed in.
"""
if cfg.get("period", "day") == "week":
return _weekly_fire_times(target_date, text, cfg)
count = _parse_count(cfg.get("times_per_day"))
start, end = parse_window(cfg.get("window"))
day_set = _parse_days(cfg["days"]) if cfg.get("days") is not None else None
@@ -54,6 +59,44 @@ def compute_fire_times(target_date: date, text: str, cfg: dict) -> list[datetime
return [datetime.combine(target_date, _minute_to_time(m)) for m in minutes]
def _weekly_fire_times(target_date: date, text: str, cfg: dict) -> list[datetime]:
"""Deterministic fire times for target_date within a weekly schedule.
Picks `count` distinct days (MonSun week) eligible under the days/from/until
filters, one random time per chosen day inside the window. Seeded by the
week, not the day, so every day of the same week computes the identical plan
and this returns only the slice landing on target_date.
"""
count = _parse_count(cfg.get("times_per_day"))
start, end = parse_window(cfg.get("window"))
day_set = _parse_days(cfg["days"]) if cfg.get("days") is not None else None
from_date = _parse_date(cfg["from"]) if cfg.get("from") is not None else None
until_date = _parse_date(cfg["until"]) if cfg.get("until") is not None else None
week_capacity = len(day_set) if day_set is not None else DAYS_PER_WEEK
if count > week_capacity:
raise ValueError(
f"{count} times per week need {count} eligible days, but only {week_capacity} match the filter"
)
week_start = target_date - timedelta(days=target_date.weekday())
eligible = [
day
for offset in range(DAYS_PER_WEEK)
for day in [week_start + timedelta(days=offset)]
if (from_date is None or day >= from_date)
and (until_date is None or day <= until_date)
and (day_set is None or _cron_weekday(day) in day_set)
]
if not eligible:
return []
rnd = random.Random(f"{week_start.isoformat()}|{text}|week")
chosen = sorted(rnd.sample(eligible, min(count, len(eligible))))
fires = [datetime.combine(day, _minute_to_time(start + rnd.randint(0, end - start))) for day in chosen]
return [fire for fire in fires if fire.date() == target_date]
def _parse_count(raw: object) -> int:
if not isinstance(raw, int) or isinstance(raw, bool) or raw < 1:
raise ValueError(f"times_per_day must be an int >= 1, got {raw!r}")
@@ -91,6 +134,7 @@ def random_cfg_from_row(row) -> dict:
cfg = {
"times_per_day": row["times_per_day"],
"window": f"{minutes_to_hhmm(row['window_start'])}-{minutes_to_hhmm(row['window_end'])}",
"period": row["period"],
}
if row["days_filter"]:
cfg["days"] = row["days_filter"]

View File

@@ -20,9 +20,10 @@ from pathlib import Path
from zoneinfo import ZoneInfo
from croniter import croniter
from db import get_db, init_db, log_operation
from db import log_operation
from forecast import fires_in_window, format_upcoming, window_for
from random_times import compute_fire_times, minutes_to_hhmm, parse_window
from random_times import compute_fire_times, minutes_to_hhmm
import store
WORKSPACE = Path(__file__).resolve().parent.parent.parent.parent
DEFAULT_DB_PATH = WORKSPACE / "db" / "reminders.sqlite"
@@ -34,130 +35,107 @@ def _now() -> str:
return datetime.now(timezone.utc).isoformat(timespec="seconds")
def _ensure_db() -> None:
if not DB_PATH.exists():
init_db(DB_PATH)
def _build_random(args: argparse.Namespace) -> dict | None:
"""Assemble and validate the random schedule block, or None if no --random-* flag given."""
"""Assemble and validate the random schedule block, or None if no --random-* flag given.
--random-times-per-day and --random-times-per-week are mutually exclusive; the
latter selects the weekly period (count spread across distinct days of the week).
"""
per_day = args.random_times_per_day
per_week = getattr(args, "random_times_per_week", None)
if per_day is not None and per_week is not None:
raise ValueError(
"--random-times-per-day and --random-times-per-week are mutually exclusive"
)
period = "week" if per_week is not None else "day"
count = per_week if per_week is not None else per_day
fields = {
"times_per_day": args.random_times_per_day,
"times_per_day": count,
"window": args.random_window,
"days": args.random_days,
"from": args.random_from,
"until": args.random_until,
}
if all(value is None for value in fields.values()):
if count is None and all(value is None for value in fields.values()):
return None
if fields["times_per_day"] is None or fields["window"] is None:
raise ValueError("random schedule needs --random-times-per-day and --random-window")
if count is None or fields["window"] is None:
raise ValueError(
"random schedule needs --random-times-per-day or --random-times-per-week, plus --random-window"
)
cfg = {key: value for key, value in fields.items() if value is not None}
cfg["period"] = period
compute_fire_times(date(2000, 1, 1), "validation", cfg)
return cfg
def _insert_schedules(conn, reminder_id: int, args: argparse.Namespace, random_cfg: dict | None) -> None:
if args.at:
for at_str in args.at:
conn.execute(
"INSERT INTO schedule_at (reminder_id, at_datetime) VALUES (?, ?)",
(reminder_id, at_str),
)
if args.cron:
for expr in args.cron:
conn.execute(
"INSERT INTO schedule_cron (reminder_id, cron_expr) VALUES (?, ?)",
(reminder_id, expr),
)
if random_cfg:
start, end = parse_window(random_cfg["window"])
conn.execute(
"""
INSERT INTO schedule_random
(reminder_id, times_per_day, window_start, window_end, days_filter, from_date, until_date)
VALUES (?, ?, ?, ?, ?, ?, ?)
""",
(
reminder_id,
random_cfg["times_per_day"],
start,
end,
random_cfg.get("days"),
random_cfg.get("from"),
random_cfg.get("until"),
),
def _schedule_lines(conn, reminder_id: int) -> list[str]:
"""Human-readable schedule descriptions for one reminder, in at/cron/random order."""
schedules = store.schedules_for(conn, reminder_id)
lines = []
for r in schedules["at"]:
lines.append(f"at: {r['at_datetime']}")
for r in schedules["cron"]:
lines.append(f"cron: {r['cron_expr']}")
for r in schedules["random"]:
window = (
f"{minutes_to_hhmm(r['window_start'])}{minutes_to_hhmm(r['window_end'])}"
)
def _fetch_reminder(conn, reminder_id: int) -> dict:
row = conn.execute(
"SELECT id, text, enabled, timezone, created_at, updated_at, deleted_at FROM reminders WHERE id = ?",
(reminder_id,),
).fetchone()
if row is None:
raise ValueError(f"reminder {reminder_id} not found")
reminder = dict(row)
reminder["at"] = [
dict(r) for r in conn.execute(
"SELECT id, at_datetime FROM schedule_at WHERE reminder_id = ?", (reminder_id,)
).fetchall()
]
reminder["cron"] = [
dict(r) for r in conn.execute(
"SELECT id, cron_expr FROM schedule_cron WHERE reminder_id = ?", (reminder_id,)
).fetchall()
]
reminder["random"] = [
dict(r) for r in conn.execute(
"SELECT id, times_per_day, window_start, window_end, days_filter, from_date, until_date FROM schedule_random WHERE reminder_id = ?",
(reminder_id,),
).fetchall()
]
return reminder
def _find_by_keyword(conn, keyword: str) -> list[dict]:
escaped = keyword.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
rows = conn.execute(
"SELECT id, text, enabled, timezone, created_at, updated_at, deleted_at "
"FROM reminders WHERE text LIKE ? ESCAPE '\\' AND deleted_at IS NULL",
(f"%{escaped}%",),
).fetchall()
return [dict(r) for r in rows]
cadence = "weekly" if r["period"] == "week" else "daily"
parts = [f"random: {r['times_per_day']}× {cadence} {window}"]
if r["days_filter"]:
parts.append(f"({r['days_filter']})")
if r["from_date"]:
parts.append(f"from {r['from_date']}")
if r["until_date"]:
parts.append(f"until {r['until_date']}")
lines.append(" ".join(parts))
return lines
def _resolve_one(conn, args: argparse.Namespace) -> dict | None:
"""Resolve exactly one active reminder by --id (exact) or --keyword (substring).
"""Resolve exactly one active reminder by --id (display ID) or --keyword (substring).
Prints a JSON error to stderr and returns None when no/ambiguous match. Ambiguous
matches include each id so the caller can retry with --id.
--id is the display ID shown by `list`/`upcoming` (1-based position among active
reminders), not the internal DB id. Prints a JSON error to stderr and returns None
when no/ambiguous match. Ambiguous matches include each display ID so the caller
can retry with --id.
"""
rid = getattr(args, "id", None)
if rid is not None:
row = conn.execute(
"SELECT id, text, enabled, timezone, created_at, updated_at, deleted_at "
"FROM reminders WHERE id = ? AND deleted_at IS NULL",
(rid,),
).fetchone()
if row is None:
print(json.dumps({"error": "no match", "id": rid}), file=sys.stderr)
display_id = getattr(args, "id", None)
if display_id is not None:
order = store.active_display_order(conn)
idx = display_id - 1
if idx < 0 or idx >= len(order):
print(
json.dumps({"error": "no match", "display_id": display_id}),
file=sys.stderr,
)
return None
return dict(row)
return store.find_active_by_id(conn, order[idx])
keyword = (args.keyword or "").strip().lower()
if not keyword:
print(json.dumps({"error": "provide --id or --keyword"}), file=sys.stderr)
return None
matches = _find_by_keyword(conn, keyword)
matches = store.find_active_by_keyword(conn, keyword)
if len(matches) == 0:
print(json.dumps({"error": "no match", "keyword": args.keyword}), file=sys.stderr)
print(
json.dumps({"error": "no match", "keyword": args.keyword}), file=sys.stderr
)
return None
if len(matches) > 1:
order = store.active_display_order(conn)
display_of = {nid: i + 1 for i, nid in enumerate(order)}
print(
json.dumps(
{"error": "ambiguous", "matches": [{"id": m["id"], "text": m["text"]} for m in matches]},
{
"error": "ambiguous",
"matches": [
{"display_id": display_of[m["id"]], "text": m["text"]}
for m in matches
],
},
ensure_ascii=False,
),
file=sys.stderr,
@@ -167,50 +145,18 @@ def _resolve_one(conn, args: argparse.Namespace) -> dict | None:
def cmd_list(_args: argparse.Namespace) -> int:
_ensure_db()
conn = get_db(DB_PATH)
try:
rows = conn.execute(
"SELECT id, text, enabled FROM reminders WHERE deleted_at IS NULL ORDER BY id"
).fetchall()
with store.connection(DB_PATH) as conn:
rows = store.list_active(conn)
if not rows:
print("(no active reminders)")
return 0
for row in rows:
rid = row["id"]
for display_id, row in enumerate(rows, start=1):
status = "enabled" if row["enabled"] else "disabled"
print(f"#{rid} {row['text']} [{status}]")
for line in _schedule_lines(conn, rid):
print(f"#{display_id} {row['text']} [{status}]")
for line in _schedule_lines(conn, row["id"]):
print(f" {line}")
return 0
finally:
conn.close()
def _schedule_lines(conn, reminder_id: int) -> list[str]:
"""Human-readable schedule descriptions for one reminder, in at/cron/random order."""
lines = []
for r in conn.execute("SELECT at_datetime FROM schedule_at WHERE reminder_id = ?", (reminder_id,)):
lines.append(f"at: {r['at_datetime']}")
for r in conn.execute("SELECT cron_expr FROM schedule_cron WHERE reminder_id = ?", (reminder_id,)):
lines.append(f"cron: {r['cron_expr']}")
random_rows = conn.execute(
"SELECT times_per_day, window_start, window_end, days_filter, from_date, until_date "
"FROM schedule_random WHERE reminder_id = ?",
(reminder_id,),
)
for r in random_rows:
window = f"{minutes_to_hhmm(r['window_start'])}{minutes_to_hhmm(r['window_end'])}"
parts = [f"random: {r['times_per_day']}× daily {window}"]
if r["days_filter"]:
parts.append(f"({r['days_filter']})")
if r["from_date"]:
parts.append(f"from {r['from_date']}")
if r["until_date"]:
parts.append(f"until {r['until_date']}")
lines.append(" ".join(parts))
return lines
def cmd_add(args: argparse.Namespace) -> int:
@@ -226,7 +172,10 @@ def cmd_add(args: argparse.Namespace) -> int:
return 1
if not args.at and not args.cron and not random_cfg:
print(json.dumps({"error": "provide --cron, --at, or --random-* options"}), file=sys.stderr)
print(
json.dumps({"error": "provide --cron, --at, or --random-* options"}),
file=sys.stderr,
)
return 1
if args.at:
@@ -234,66 +183,73 @@ def cmd_add(args: argparse.Namespace) -> int:
try:
datetime.fromisoformat(at_str)
except ValueError as exc:
print(json.dumps({"error": f"invalid --at datetime: {exc}"}), file=sys.stderr)
print(
json.dumps({"error": f"invalid --at datetime: {exc}"}),
file=sys.stderr,
)
return 1
if args.cron:
for expr in args.cron:
if not croniter.is_valid(expr):
print(json.dumps({"error": f"invalid cron expression: {expr!r}"}), file=sys.stderr)
print(
json.dumps({"error": f"invalid cron expression: {expr!r}"}),
file=sys.stderr,
)
return 1
_ensure_db()
conn = get_db(DB_PATH)
try:
conn.execute("BEGIN")
now = _now()
cur = conn.execute(
"INSERT INTO reminders (text, enabled, timezone, created_at, updated_at) VALUES (?, 1, 'Europe/Prague', ?, ?)",
(text, now, now),
)
reminder_id = cur.lastrowid
_insert_schedules(conn, reminder_id, args, random_cfg)
conn.execute("COMMIT")
reminder = _fetch_reminder(conn, reminder_id)
with store.transaction(DB_PATH) as conn:
now = _now()
reminder_id = store.insert_reminder(conn, text, now)
store.insert_schedules(conn, reminder_id, args.at, args.cron, random_cfg)
with store.connection(DB_PATH) as conn:
reminder = store.fetch_reminder(conn, reminder_id)
log_operation("ADD", reminder_id, f'text="{text}"')
print(json.dumps({"added": reminder}, ensure_ascii=False))
return 0
except Exception as exc:
conn.execute("ROLLBACK")
print(json.dumps({"error": str(exc)}), file=sys.stderr)
return 1
finally:
conn.close()
def cmd_remove(args: argparse.Namespace) -> int:
_ensure_db()
conn = get_db(DB_PATH)
try:
target = _resolve_one(conn, args)
if target is None:
return 1
with store.connection(DB_PATH) as conn:
target = _resolve_one(conn, args)
if target is None:
return 1
rid = target["id"]
rid = target["id"]
conn.execute("BEGIN")
conn.execute("UPDATE reminders SET deleted_at = ?, updated_at = ? WHERE id = ?", (_now(), _now(), rid))
conn.execute("COMMIT")
with store.transaction(DB_PATH) as conn:
store.soft_delete(conn, rid, _now())
with store.connection(DB_PATH) as conn:
reminder = store.fetch_reminder(conn, rid)
log_operation("REMOVE", rid, f'text="{target["text"]}"')
reminder = _fetch_reminder(conn, rid)
print(json.dumps({"removed": reminder}, ensure_ascii=False))
return 0
except Exception as exc:
conn.execute("ROLLBACK")
print(json.dumps({"error": str(exc)}), file=sys.stderr)
return 1
finally:
conn.close()
def cmd_edit(args: argparse.Namespace) -> int:
if args.replace_schedules and not (args.at or args.cron or args.random_times_per_day or args.random_window):
print(json.dumps({"error": "--replace-schedules requires at least one --cron/--at/--random-* option"}), file=sys.stderr)
if args.replace_schedules and not (
args.at
or args.cron
or args.random_times_per_day
or args.random_times_per_week
or args.random_window
):
print(
json.dumps(
{
"error": "--replace-schedules requires at least one --cron/--at/--random-* option"
}
),
file=sys.stderr,
)
return 1
new_text = None
@@ -309,81 +265,65 @@ def cmd_edit(args: argparse.Namespace) -> int:
print(json.dumps({"error": str(exc)}), file=sys.stderr)
return 1
_ensure_db()
conn = get_db(DB_PATH)
try:
target = _resolve_one(conn, args)
if target is None:
return 1
with store.connection(DB_PATH) as conn:
target = _resolve_one(conn, args)
if target is None:
return 1
rid = target["id"]
rid = target["id"]
conn.execute("BEGIN")
now = _now()
with store.transaction(DB_PATH) as conn:
now = _now()
if new_text is not None:
store.update_text(conn, rid, new_text, now)
log_operation("EDIT", rid, f'text="{new_text}"')
if args.replace_schedules:
store.delete_schedules(conn, rid)
store.insert_schedules(conn, rid, args.at, args.cron, random_cfg)
store.touch(conn, rid, now)
log_operation("EDIT", rid, "schedules replaced")
if new_text is not None:
conn.execute("UPDATE reminders SET text = ?, updated_at = ? WHERE id = ?", (new_text, now, rid))
log_operation("EDIT", rid, f'text="{new_text}"')
if args.replace_schedules:
conn.execute("DELETE FROM schedule_at WHERE reminder_id = ?", (rid,))
conn.execute("DELETE FROM schedule_cron WHERE reminder_id = ?", (rid,))
conn.execute("DELETE FROM schedule_random WHERE reminder_id = ?", (rid,))
_insert_schedules(conn, rid, args, random_cfg)
conn.execute("UPDATE reminders SET updated_at = ? WHERE id = ?", (now, rid))
log_operation("EDIT", rid, "schedules replaced")
conn.execute("COMMIT")
reminder = _fetch_reminder(conn, rid)
with store.connection(DB_PATH) as conn:
reminder = store.fetch_reminder(conn, rid)
print(json.dumps({"edited": reminder}, ensure_ascii=False))
return 0
except Exception as exc:
conn.execute("ROLLBACK")
print(json.dumps({"error": str(exc)}), file=sys.stderr)
return 1
finally:
conn.close()
def cmd_enable(args: argparse.Namespace) -> int:
_ensure_db()
conn = get_db(DB_PATH)
try:
target = _resolve_one(conn, args)
if target is None:
return 1
rid = target["id"]
conn.execute("UPDATE reminders SET enabled = 1, updated_at = ? WHERE id = ?", (_now(), rid))
log_operation("ENABLE", rid, None)
reminder = _fetch_reminder(conn, rid)
with store.connection(DB_PATH) as conn:
target = _resolve_one(conn, args)
if target is None:
return 1
rid = target["id"]
store.set_enabled(conn, rid, True, _now())
log_operation("ENABLE", rid, None)
reminder = store.fetch_reminder(conn, rid)
print(json.dumps({"enabled": reminder}, ensure_ascii=False))
return 0
except Exception as exc:
print(json.dumps({"error": str(exc)}), file=sys.stderr)
return 1
finally:
conn.close()
def cmd_disable(args: argparse.Namespace) -> int:
_ensure_db()
conn = get_db(DB_PATH)
try:
target = _resolve_one(conn, args)
if target is None:
return 1
rid = target["id"]
conn.execute("UPDATE reminders SET enabled = 0, updated_at = ? WHERE id = ?", (_now(), rid))
log_operation("DISABLE", rid, None)
reminder = _fetch_reminder(conn, rid)
with store.connection(DB_PATH) as conn:
target = _resolve_one(conn, args)
if target is None:
return 1
rid = target["id"]
store.set_enabled(conn, rid, False, _now())
log_operation("DISABLE", rid, None)
reminder = store.fetch_reminder(conn, rid)
print(json.dumps({"disabled": reminder}, ensure_ascii=False))
return 0
except Exception as exc:
print(json.dumps({"error": str(exc)}), file=sys.stderr)
return 1
finally:
conn.close()
def cmd_delivered(args: argparse.Namespace) -> int:
@@ -392,90 +332,135 @@ def cmd_delivered(args: argparse.Namespace) -> int:
Answers 'what reminders arrived today?'. fire_time/delivered_at are stored in
Prague local time, so no conversion is needed. Defaults to today (Prague).
"""
_ensure_db()
conn = get_db(DB_PATH)
try:
with store.connection(DB_PATH) as conn:
since = (args.since or "").strip()
if since:
try:
date.fromisoformat(since)
except ValueError as exc:
print(json.dumps({"error": f"invalid --since date: {exc}"}), file=sys.stderr)
print(
json.dumps({"error": f"invalid --since date: {exc}"}),
file=sys.stderr,
)
return 1
rows = conn.execute(
"""
SELECT f.delivered_at, r.text
FROM reminder_fires f
JOIN reminders r ON r.id = f.reminder_id
WHERE f.status = 'delivered' AND f.fire_time >= ?
ORDER BY f.delivered_at
""",
(since,),
).fetchall()
rows = store.delivered_since(conn, since)
else:
today = datetime.now(PRAGUE).date().isoformat()
rows = conn.execute(
"""
SELECT f.delivered_at, r.text
FROM reminder_fires f
JOIN reminders r ON r.id = f.reminder_id
WHERE f.status = 'delivered' AND substr(f.fire_time, 1, 10) = ?
ORDER BY f.delivered_at
""",
(today,),
).fetchall()
rows = store.delivered_today(conn, today)
for row in rows:
print(f"{row['delivered_at']} {row['text']}")
return 0
finally:
conn.close()
def cmd_upcoming(args: argparse.Namespace) -> int:
"""List scheduled fires in a time window (the plan, not deliveries — see `delivered`)."""
_ensure_db()
conn = get_db(DB_PATH)
try:
now = datetime.now(PRAGUE).replace(tzinfo=None)
start, end = window_for(now, args.date, args.days)
for line in format_upcoming(fires_in_window(conn, start, end)):
print(line)
return 0
with store.connection(DB_PATH) as conn:
now = datetime.now(PRAGUE).replace(tzinfo=None)
start, end = window_for(now, args.date, args.days)
id_to_display = {
nid: i + 1 for i, nid in enumerate(store.active_display_order(conn))
}
for line in format_upcoming(
fires_in_window(conn, start, end), id_to_display
):
print(line)
return 0
except ValueError as exc:
print(json.dumps({"error": str(exc)}), file=sys.stderr)
return 1
finally:
conn.close()
def main() -> None:
parser = argparse.ArgumentParser(description="CRUD for reminders (SQLite backed)")
sub = parser.add_subparsers(dest="command", required=True)
sub.add_parser("list", help="List all active reminders as JSON")
sub.add_parser("list", help="List all active reminders as readable text")
add_p = sub.add_parser("add", help="Add a new reminder")
add_p.add_argument("--text", required=True, help="Reminder text")
add_p.add_argument("--cron", action="append", metavar="EXPR", help="Cron expression (repeatable)")
add_p.add_argument("--at", action="append", metavar="ISO_DATETIME", help="One-time datetime ISO 8601 (repeatable)")
add_p.add_argument("--random-times-per-day", type=int, dest="random_times_per_day", metavar="N", help="Random schedule: fires per day")
add_p.add_argument("--random-window", dest="random_window", metavar="HH:MM-HH:MM", help="Random schedule: daily time window")
add_p.add_argument("--random-days", dest="random_days", metavar="DOW", help="Random schedule: cron day-of-week filter")
add_p.add_argument("--random-from", dest="random_from", metavar="YYYY-MM-DD", help="Random schedule: start date")
add_p.add_argument("--random-until", dest="random_until", metavar="YYYY-MM-DD", help="Random schedule: end date")
add_p.add_argument(
"--cron", action="append", metavar="EXPR", help="Cron expression (repeatable)"
)
add_p.add_argument(
"--at",
action="append",
metavar="ISO_DATETIME",
help="One-time datetime ISO 8601 (repeatable)",
)
add_p.add_argument(
"--random-times-per-day",
type=int,
dest="random_times_per_day",
metavar="N",
help="Random schedule: fires per day",
)
add_p.add_argument(
"--random-times-per-week",
type=int,
dest="random_times_per_week",
metavar="N",
help="Random schedule: fires per week (distinct days)",
)
add_p.add_argument(
"--random-window",
dest="random_window",
metavar="HH:MM-HH:MM",
help="Random schedule: daily time window",
)
add_p.add_argument(
"--random-days",
dest="random_days",
metavar="DOW",
help="Random schedule: cron day-of-week filter",
)
add_p.add_argument(
"--random-from",
dest="random_from",
metavar="YYYY-MM-DD",
help="Random schedule: start date",
)
add_p.add_argument(
"--random-until",
dest="random_until",
metavar="YYYY-MM-DD",
help="Random schedule: end date",
)
remove_p = sub.add_parser("remove", help="Remove a reminder by keyword or id (soft delete)")
remove_p = sub.add_parser(
"remove", help="Remove a reminder by keyword or id (soft delete)"
)
remove_p.add_argument("--keyword", help="Substring to match against reminder text")
remove_p.add_argument("--id", type=int, help="Exact reminder id (disambiguates duplicate texts)")
remove_p.add_argument(
"--id", type=int, help="Display ID from list (disambiguates duplicate texts)"
)
edit_p = sub.add_parser("edit", help="Edit a reminder by keyword or id")
edit_p.add_argument("--keyword", help="Substring to match against reminder text")
edit_p.add_argument("--id", type=int, help="Exact reminder id (disambiguates duplicate texts)")
edit_p.add_argument(
"--id", type=int, help="Display ID from list (disambiguates duplicate texts)"
)
edit_p.add_argument("--text", help="New reminder text")
edit_p.add_argument("--replace-schedules", action="store_true", help="Replace all schedules with new ones")
edit_p.add_argument("--cron", action="append", metavar="EXPR", help="Cron expression (repeatable)")
edit_p.add_argument("--at", action="append", metavar="ISO_DATETIME", help="One-time datetime (repeatable)")
edit_p.add_argument("--random-times-per-day", type=int, dest="random_times_per_day", metavar="N")
edit_p.add_argument(
"--replace-schedules",
action="store_true",
help="Replace all schedules with new ones",
)
edit_p.add_argument(
"--cron", action="append", metavar="EXPR", help="Cron expression (repeatable)"
)
edit_p.add_argument(
"--at",
action="append",
metavar="ISO_DATETIME",
help="One-time datetime (repeatable)",
)
edit_p.add_argument(
"--random-times-per-day", type=int, dest="random_times_per_day", metavar="N"
)
edit_p.add_argument(
"--random-times-per-week", type=int, dest="random_times_per_week", metavar="N"
)
edit_p.add_argument("--random-window", dest="random_window", metavar="HH:MM-HH:MM")
edit_p.add_argument("--random-days", dest="random_days", metavar="DOW")
edit_p.add_argument("--random-from", dest="random_from", metavar="YYYY-MM-DD")
@@ -483,18 +468,33 @@ def main() -> None:
enable_p = sub.add_parser("enable", help="Enable a reminder by keyword or id")
enable_p.add_argument("--keyword")
enable_p.add_argument("--id", type=int, help="Exact reminder id")
enable_p.add_argument("--id", type=int, help="Display ID from list")
disable_p = sub.add_parser("disable", help="Disable a reminder by keyword or id")
disable_p.add_argument("--keyword")
disable_p.add_argument("--id", type=int, help="Exact reminder id")
disable_p.add_argument("--id", type=int, help="Display ID from list")
delivered_p = sub.add_parser("delivered", help="List reminders delivered to the user (default: today)")
delivered_p.add_argument("--since", metavar="YYYY-MM-DD", help="List deliveries on/after this date instead of today")
delivered_p = sub.add_parser(
"delivered", help="List reminders delivered to the user (default: today)"
)
delivered_p.add_argument(
"--since",
metavar="YYYY-MM-DD",
help="List deliveries on/after this date instead of today",
)
upcoming_p = sub.add_parser("upcoming", help="List scheduled fires in a window (default: rest of today)")
upcoming_p.add_argument("--date", metavar="YYYY-MM-DD", help="Show fires for this whole day")
upcoming_p.add_argument("--days", type=int, metavar="N", help="Show fires for the next N calendar days (incl. today)")
upcoming_p = sub.add_parser(
"upcoming", help="List scheduled fires in a window (default: rest of today)"
)
upcoming_p.add_argument(
"--date", metavar="YYYY-MM-DD", help="Show fires for this whole day"
)
upcoming_p.add_argument(
"--days",
type=int,
metavar="N",
help="Show fires for the next N calendar days (incl. today)",
)
args = parser.parse_args()
dispatch = {

View File

@@ -22,8 +22,9 @@ from pathlib import Path
from zoneinfo import ZoneInfo
from croniter import croniter
from db import get_db, init_db, log_operation
from db import log_operation
from random_times import compute_fire_times, random_cfg_from_row
import store
WORKSPACE = Path(__file__).resolve().parent.parent.parent.parent
DEFAULT_DB_PATH = WORKSPACE / "db" / "reminders.sqlite"
@@ -60,50 +61,17 @@ def _due_at(conn, now: datetime) -> list[dict]:
"""Find due one-time reminders."""
since = (now - timedelta(seconds=TOLERANCE_SECONDS)).isoformat(timespec="seconds")
until = now.isoformat(timespec="seconds")
rows = conn.execute(
"""
SELECT r.id, r.text, sa.id AS schedule_id, sa.at_datetime AS fire_time
FROM reminders r
JOIN schedule_at sa ON sa.reminder_id = r.id
WHERE r.enabled = 1 AND r.deleted_at IS NULL
AND sa.at_datetime > ?
AND sa.at_datetime <= ?
AND NOT EXISTS (
SELECT 1 FROM reminder_fires rf
WHERE rf.reminder_id = r.id AND rf.schedule_id = sa.id
AND rf.schedule_type = 'at' AND rf.fire_time = sa.at_datetime
AND rf.status = 'delivered'
)
""",
(since, until),
).fetchall()
return [{**dict(r), "schedule_type": "at"} for r in rows]
return [{**row, "schedule_type": "at"} for row in store.due_at(conn, since, until)]
def _due_cron(conn, now: datetime) -> list[dict]:
"""Find due cron reminders."""
rows = conn.execute(
"""
SELECT r.id, r.text, sc.id AS schedule_id, sc.cron_expr
FROM reminders r
JOIN schedule_cron sc ON sc.reminder_id = r.id
WHERE r.enabled = 1 AND r.deleted_at IS NULL
"""
).fetchall()
due = []
for row in rows:
for row in store.enabled_cron(conn):
prev = croniter(row["cron_expr"], now + timedelta(seconds=1)).get_prev(datetime)
if 0 <= (now - prev).total_seconds() < TOLERANCE_SECONDS:
fire_iso = prev.isoformat(timespec="seconds")
already = conn.execute(
"""
SELECT 1 FROM reminder_fires
WHERE reminder_id = ? AND schedule_id = ? AND schedule_type = 'cron'
AND fire_time = ? AND status = 'delivered'
""",
(row["id"], row["schedule_id"], fire_iso),
).fetchone()
if not already:
if not store.is_fire_delivered(conn, row["id"], row["schedule_id"], "cron", fire_iso):
due.append({
"id": row["id"],
"text": row["text"],
@@ -116,17 +84,8 @@ def _due_cron(conn, now: datetime) -> list[dict]:
def _due_random(conn, now: datetime) -> list[dict]:
"""Find due random reminders."""
rows = conn.execute(
"""
SELECT r.id, r.text, sr.id AS schedule_id, sr.times_per_day, sr.window_start, sr.window_end,
sr.days_filter, sr.from_date, sr.until_date
FROM reminders r
JOIN schedule_random sr ON sr.reminder_id = r.id
WHERE r.enabled = 1 AND r.deleted_at IS NULL
"""
).fetchall()
due = []
for row in rows:
for row in store.enabled_random(conn):
cfg = random_cfg_from_row(row)
try:
fires = compute_fire_times(now.date(), row["text"], cfg)
@@ -136,15 +95,7 @@ def _due_random(conn, now: datetime) -> list[dict]:
for ft in fires:
if 0 <= (now - ft).total_seconds() < TOLERANCE_SECONDS:
fire_iso = ft.isoformat(timespec="seconds")
already = conn.execute(
"""
SELECT 1 FROM reminder_fires
WHERE reminder_id = ? AND schedule_id = ? AND schedule_type = 'random'
AND fire_time = ? AND status = 'delivered'
""",
(row["id"], row["schedule_id"], fire_iso),
).fetchone()
if not already:
if not store.is_fire_delivered(conn, row["id"], row["schedule_id"], "random", fire_iso):
due.append({
"id": row["id"],
"text": row["text"],
@@ -156,22 +107,12 @@ def _due_random(conn, now: datetime) -> list[dict]:
def _record_fire(conn, reminder_id: int, schedule_id: int, schedule_type: str, fire_time: str, status: str, error: str | None = None) -> None:
now = _now_prague().isoformat(timespec="seconds")
conn.execute(
"""
INSERT INTO reminder_fires (reminder_id, schedule_id, schedule_type, fire_time, delivered_at, status, error_message)
VALUES (?, ?, ?, ?, ?, ?, ?)
""",
(reminder_id, schedule_id, schedule_type, fire_time, now if status == "delivered" else None, status, error),
)
delivered_at = _now_prague().isoformat(timespec="seconds") if status == "delivered" else None
store.record_fire(conn, reminder_id, schedule_id, schedule_type, fire_time, status, delivered_at, error)
def main() -> None:
if not DB_PATH.exists():
init_db(DB_PATH)
conn = get_db(DB_PATH)
try:
with store.connection(DB_PATH) as conn:
now = _now_prague()
due = _due_at(conn, now) + _due_cron(conn, now) + _due_random(conn, now)
if not due:
@@ -194,8 +135,6 @@ def main() -> None:
_record_fire(conn, rid, sid, schedule_type, ft, "delivered")
log_operation("DELIVER", rid, f'text="{text}"')
finally:
conn.close()
if __name__ == "__main__":

View File

@@ -0,0 +1,327 @@
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.11"
# dependencies = []
# ///
"""Data-access layer for the /remind skill.
Pure SQL + lifecycle helpers. No printing, no argparse, no sys.exit.
"""
from __future__ import annotations
import sqlite3
from contextlib import contextmanager
from pathlib import Path
from typing import Iterator
from db import get_db, init_db
from random_times import parse_window
@contextmanager
def connection(db_path: Path) -> Iterator[sqlite3.Connection]:
"""Open a connection, initialising the DB if missing."""
if not db_path.exists():
init_db(db_path)
conn = get_db(db_path)
try:
yield conn
finally:
conn.close()
@contextmanager
def transaction(db_path: Path) -> Iterator[sqlite3.Connection]:
"""Open a connection wrapped in an explicit transaction."""
with connection(db_path) as conn:
conn.execute("BEGIN")
try:
yield conn
conn.execute("COMMIT")
except Exception:
conn.execute("ROLLBACK")
raise
# ---------------------------------------------------------------------------
# Write helpers
# ---------------------------------------------------------------------------
def insert_reminder(conn: sqlite3.Connection, text: str, now: str) -> int:
"""Insert a new reminder and return its id."""
cur = conn.execute(
"INSERT INTO reminders (text, enabled, timezone, created_at, updated_at) VALUES (?, 1, 'Europe/Prague', ?, ?)",
(text, now, now),
)
return cur.lastrowid
def insert_schedules(
conn: sqlite3.Connection,
reminder_id: int,
at_list: list[str] | None,
cron_list: list[str] | None,
random_cfg: dict | None,
) -> None:
"""Insert schedule rows for a reminder."""
if at_list:
for at_str in at_list:
conn.execute(
"INSERT INTO schedule_at (reminder_id, at_datetime) VALUES (?, ?)",
(reminder_id, at_str),
)
if cron_list:
for expr in cron_list:
conn.execute(
"INSERT INTO schedule_cron (reminder_id, cron_expr) VALUES (?, ?)",
(reminder_id, expr),
)
if random_cfg:
start, end = parse_window(random_cfg["window"])
conn.execute(
"""
INSERT INTO schedule_random
(reminder_id, times_per_day, window_start, window_end, days_filter, from_date, until_date, period)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""",
(
reminder_id,
random_cfg["times_per_day"],
start,
end,
random_cfg.get("days"),
random_cfg.get("from"),
random_cfg.get("until"),
random_cfg.get("period", "day"),
),
)
def soft_delete(conn: sqlite3.Connection, rid: int, now: str) -> None:
conn.execute("UPDATE reminders SET deleted_at = ?, updated_at = ? WHERE id = ?", (now, now, rid))
def update_text(conn: sqlite3.Connection, rid: int, text: str, now: str) -> None:
conn.execute("UPDATE reminders SET text = ?, updated_at = ? WHERE id = ?", (text, now, rid))
def delete_schedules(conn: sqlite3.Connection, rid: int) -> None:
"""Delete all schedule rows for a reminder across all three schedule tables."""
conn.execute("DELETE FROM schedule_at WHERE reminder_id = ?", (rid,))
conn.execute("DELETE FROM schedule_cron WHERE reminder_id = ?", (rid,))
conn.execute("DELETE FROM schedule_random WHERE reminder_id = ?", (rid,))
def touch(conn: sqlite3.Connection, rid: int, now: str) -> None:
conn.execute("UPDATE reminders SET updated_at = ? WHERE id = ?", (now, rid))
def set_enabled(conn: sqlite3.Connection, rid: int, enabled: bool, now: str) -> None:
conn.execute("UPDATE reminders SET enabled = ?, updated_at = ? WHERE id = ?", (int(enabled), now, rid))
# ---------------------------------------------------------------------------
# Read helpers
# ---------------------------------------------------------------------------
def fetch_reminder(conn: sqlite3.Connection, reminder_id: int) -> dict:
"""Fetch a reminder with nested at/cron/random schedule lists."""
row = conn.execute(
"SELECT id, text, enabled, timezone, created_at, updated_at, deleted_at FROM reminders WHERE id = ?",
(reminder_id,),
).fetchone()
if row is None:
raise ValueError(f"reminder {reminder_id} not found")
reminder = dict(row)
reminder["at"] = [
dict(r) for r in conn.execute(
"SELECT id, at_datetime FROM schedule_at WHERE reminder_id = ?", (reminder_id,)
).fetchall()
]
reminder["cron"] = [
dict(r) for r in conn.execute(
"SELECT id, cron_expr FROM schedule_cron WHERE reminder_id = ?", (reminder_id,)
).fetchall()
]
reminder["random"] = [
dict(r) for r in conn.execute(
"SELECT id, times_per_day, window_start, window_end, days_filter, from_date, until_date, period "
"FROM schedule_random WHERE reminder_id = ?",
(reminder_id,),
).fetchall()
]
return reminder
def schedules_for(conn: sqlite3.Connection, reminder_id: int) -> dict:
"""Return raw schedule rows grouped by type; formatting stays in the CLI."""
return {
"at": [
dict(r) for r in conn.execute(
"SELECT at_datetime FROM schedule_at WHERE reminder_id = ?", (reminder_id,)
).fetchall()
],
"cron": [
dict(r) for r in conn.execute(
"SELECT cron_expr FROM schedule_cron WHERE reminder_id = ?", (reminder_id,)
).fetchall()
],
"random": [
dict(r) for r in conn.execute(
"SELECT times_per_day, window_start, window_end, days_filter, from_date, until_date, period "
"FROM schedule_random WHERE reminder_id = ?",
(reminder_id,),
).fetchall()
],
}
def list_active(conn: sqlite3.Connection) -> list[dict]:
rows = conn.execute(
"SELECT id, text, enabled FROM reminders WHERE deleted_at IS NULL ORDER BY id"
).fetchall()
return [dict(r) for r in rows]
def find_active_by_id(conn: sqlite3.Connection, rid: int) -> dict | None:
"""Return the reminder row for an internal DB id, or None if not found/deleted."""
row = conn.execute(
"SELECT id, text, enabled, timezone, created_at, updated_at, deleted_at "
"FROM reminders WHERE id = ? AND deleted_at IS NULL",
(rid,),
).fetchone()
return dict(row) if row else None
def find_active_by_keyword(conn: sqlite3.Connection, keyword: str) -> list[dict]:
escaped = keyword.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
rows = conn.execute(
"SELECT id, text, enabled, timezone, created_at, updated_at, deleted_at "
"FROM reminders WHERE text LIKE ? ESCAPE '\\' AND deleted_at IS NULL",
(f"%{escaped}%",),
).fetchall()
return [dict(r) for r in rows]
def active_display_order(conn: sqlite3.Connection) -> list[int]:
"""Internal ids of active reminders in display order (ascending by id)."""
rows = conn.execute(
"SELECT id FROM reminders WHERE deleted_at IS NULL ORDER BY id"
).fetchall()
return [row["id"] for row in rows]
def delivered_since(conn: sqlite3.Connection, since: str) -> list[dict]:
rows = conn.execute(
"""
SELECT f.delivered_at, r.text
FROM reminder_fires f
JOIN reminders r ON r.id = f.reminder_id
WHERE f.status = 'delivered' AND f.fire_time >= ?
ORDER BY f.delivered_at
""",
(since,),
).fetchall()
return [dict(r) for r in rows]
def delivered_today(conn: sqlite3.Connection, today: str) -> list[dict]:
rows = conn.execute(
"""
SELECT f.delivered_at, r.text
FROM reminder_fires f
JOIN reminders r ON r.id = f.reminder_id
WHERE f.status = 'delivered' AND substr(f.fire_time, 1, 10) = ?
ORDER BY f.delivered_at
""",
(today,),
).fetchall()
return [dict(r) for r in rows]
# ---------------------------------------------------------------------------
# Sender helpers (reminder_fires)
# ---------------------------------------------------------------------------
def due_at(conn: sqlite3.Connection, since: str, until: str) -> list[dict]:
"""One-time reminders firing in (since, until] that were not yet delivered."""
rows = conn.execute(
"""
SELECT r.id, r.text, sa.id AS schedule_id, sa.at_datetime AS fire_time
FROM reminders r
JOIN schedule_at sa ON sa.reminder_id = r.id
WHERE r.enabled = 1 AND r.deleted_at IS NULL
AND sa.at_datetime > ?
AND sa.at_datetime <= ?
AND NOT EXISTS (
SELECT 1 FROM reminder_fires rf
WHERE rf.reminder_id = r.id AND rf.schedule_id = sa.id
AND rf.schedule_type = 'at' AND rf.fire_time = sa.at_datetime
AND rf.status = 'delivered'
)
""",
(since, until),
).fetchall()
return [dict(r) for r in rows]
def enabled_cron(conn: sqlite3.Connection) -> list[dict]:
"""All cron schedules on active, enabled reminders (due-check happens in the sender)."""
rows = conn.execute(
"""
SELECT r.id, r.text, sc.id AS schedule_id, sc.cron_expr
FROM reminders r
JOIN schedule_cron sc ON sc.reminder_id = r.id
WHERE r.enabled = 1 AND r.deleted_at IS NULL
"""
).fetchall()
return [dict(r) for r in rows]
def enabled_random(conn: sqlite3.Connection) -> list[dict]:
"""All random schedules on active, enabled reminders (fire times computed in the sender)."""
rows = conn.execute(
"""
SELECT r.id, r.text, sr.id AS schedule_id, sr.times_per_day, sr.window_start, sr.window_end,
sr.days_filter, sr.from_date, sr.until_date, sr.period
FROM reminders r
JOIN schedule_random sr ON sr.reminder_id = r.id
WHERE r.enabled = 1 AND r.deleted_at IS NULL
"""
).fetchall()
return [dict(r) for r in rows]
def is_fire_delivered(
conn: sqlite3.Connection, reminder_id: int, schedule_id: int, schedule_type: str, fire_time: str
) -> bool:
"""Whether this exact fire was already delivered (dedup guard)."""
row = conn.execute(
"""
SELECT 1 FROM reminder_fires
WHERE reminder_id = ? AND schedule_id = ? AND schedule_type = ?
AND fire_time = ? AND status = 'delivered'
""",
(reminder_id, schedule_id, schedule_type, fire_time),
).fetchone()
return row is not None
def record_fire(
conn: sqlite3.Connection,
reminder_id: int,
schedule_id: int,
schedule_type: str,
fire_time: str,
status: str,
delivered_at: str | None = None,
error: str | None = None,
) -> None:
conn.execute(
"""
INSERT INTO reminder_fires (reminder_id, schedule_id, schedule_type, fire_time, delivered_at, status, error_message)
VALUES (?, ?, ?, ?, ?, ?, ?)
""",
(reminder_id, schedule_id, schedule_type, fire_time, delivered_at, status, error),
)

View File

@@ -167,11 +167,13 @@ def test_fires_sorted_across_types(conn):
def test_format_empty_window():
assert format_upcoming([]) == ["(nothing scheduled in this window)"]
assert format_upcoming([], {}) == ["(nothing scheduled in this window)"]
def test_format_line_shape():
lines = format_upcoming([
{"fire_time": datetime(2026, 6, 10, 9, 0), "id": 1, "text": "call mom", "schedule_type": "cron"},
])
assert lines == ["2026-06-10 09:00 #1 call mom (cron)"]
def test_format_line_shape_uses_display_id():
# Internal id 5 maps to display ID 2 — the line shows the display ID.
lines = format_upcoming(
[{"fire_time": datetime(2026, 6, 10, 9, 0), "id": 5, "text": "call mom", "schedule_type": "cron"}],
{5: 2},
)
assert lines == ["2026-06-10 09:00 #2 call mom (cron)"]

View File

@@ -94,3 +94,69 @@ def test_config_validated_before_date_filter():
# Out-of-range date still surfaces a structural error rather than returning [].
with pytest.raises(ValueError):
compute_fire_times(date(2000, 1, 1), "x", cfg(times_per_day=50, until="1999-01-01"))
# --- Weekly period -----------------------------------------------------------
# Week of Mon 2026-03-23 .. Sun 2026-03-29.
WEEK = [date(2026, 3, d) for d in range(23, 30)]
def weekly_cfg(**overrides) -> dict:
base = {"times_per_day": 2, "window": WINDOW, "period": "week"}
base.update(overrides)
return base
def _week_fires(text: str, cfg_dict: dict) -> list[datetime]:
return [fire for day in WEEK for fire in compute_fire_times(day, text, cfg_dict)]
def test_weekly_count_across_week():
assert len(_week_fires("x", weekly_cfg(times_per_day=2))) == 2
def test_weekly_distinct_days():
fires = _week_fires("x", weekly_cfg(times_per_day=3))
assert len({f.date() for f in fires}) == 3
def test_weekly_deterministic_across_days():
# Every day of the week must agree on the same plan, so summing per-day calls
# over the week yields a stable set regardless of call order.
assert _week_fires("walk", weekly_cfg()) == _week_fires("walk", weekly_cfg())
def test_weekly_within_window():
for fire in _week_fires("x", weekly_cfg(times_per_day=4)):
assert WINDOW_START.time() <= fire.time() <= WINDOW_END.time()
def test_weekly_days_filter_limits_eligible():
fires = _week_fires("x", weekly_cfg(times_per_day=2, days="1-5"))
assert all(f.weekday() < 5 for f in fires)
def test_weekly_count_clamped_to_eligible_days():
# Capacity (7 days) admits 3, but until clips this week to Mon+Tue -> 2 fires, no error.
bounded = weekly_cfg(times_per_day=3, until="2026-03-24")
fires = _week_fires("x", bounded)
assert len(fires) == 2
assert {f.date() for f in fires} == {date(2026, 3, 23), date(2026, 3, 24)}
def test_weekly_from_until_clips_to_partial_week():
bounded = weekly_cfg(times_per_day=2, **{"from": "2026-03-25", "until": "2026-03-27"})
fires = _week_fires("x", bounded)
assert all(date(2026, 3, 25) <= f.date() <= date(2026, 3, 27) for f in fires)
assert len(fires) == 2
def test_weekly_count_exceeds_capacity_raises():
with pytest.raises(ValueError):
compute_fire_times(WEEK[0], "x", weekly_cfg(times_per_day=8))
def test_weekly_count_exceeds_filtered_capacity_raises():
with pytest.raises(ValueError):
compute_fire_times(WEEK[0], "x", weekly_cfg(times_per_day=3, days="1,2"))

View File

@@ -217,6 +217,61 @@ def test_remove_by_id_disambiguates_duplicates(tmp_path, capsys):
assert "drink water" in captured.out
def test_display_id_renumbers_after_remove(tmp_path, capsys):
db_path = tmp_path / "test.sqlite"
init_db(db_path)
for text in ("first", "second", "third"):
_run(db_path, ["add", "--text", text, "--cron", "0 9 * * *"])
capsys.readouterr()
# Display IDs follow insertion order: #1 first, #2 second, #3 third.
_run(db_path, ["remove", "--id", "1"]) # removes "first"
capsys.readouterr()
ret = _run(db_path, ["list"])
captured = capsys.readouterr()
assert ret == 0
assert "#1 second [enabled]" in captured.out
assert "#2 third [enabled]" in captured.out
assert "first" not in captured.out
# After renumbering, display #1 is now "second".
ret = _run(db_path, ["remove", "--id", "1"])
captured = capsys.readouterr()
assert ret == 0
assert json.loads(captured.out)["removed"]["text"] == "second"
def test_id_out_of_range_reports_display_id(tmp_path, capsys):
db_path = tmp_path / "test.sqlite"
init_db(db_path)
_run(db_path, ["add", "--text", "only one", "--cron", "0 9 * * *"])
capsys.readouterr()
ret = _run(db_path, ["remove", "--id", "5"])
captured = capsys.readouterr()
assert ret == 1
assert json.loads(captured.err) == {"error": "no match", "display_id": 5}
def test_ambiguous_keyword_returns_display_ids(tmp_path, capsys):
db_path = tmp_path / "test.sqlite"
init_db(db_path)
_run(db_path, ["add", "--text", "drink water", "--cron", "0 9 * * *"])
_run(db_path, ["add", "--text", "drink water", "--cron", "0 10 * * *"])
capsys.readouterr()
ret = _run(db_path, ["remove", "--keyword", "drink"])
captured = capsys.readouterr()
assert ret == 1
err = json.loads(captured.err)
assert err["error"] == "ambiguous"
assert sorted(m["display_id"] for m in err["matches"]) == [1, 2]
def test_resolve_requires_id_or_keyword(tmp_path, capsys):
db_path = tmp_path / "test.sqlite"
init_db(db_path)