migrace /remind na sqlite

This commit is contained in:
lachtan
2026-06-10 07:10:11 +02:00
parent be01fe7a07
commit b2d367e6a4
8 changed files with 1315 additions and 231 deletions

View File

@@ -3,10 +3,10 @@
# requires-python = ">=3.11"
# dependencies = ["croniter", "pyyaml"]
# ///
"""Deterministic CRUD for reminder.yaml.
"""Deterministic CRUD for reminders backed by SQLite.
CLI tool for LLM skills to create, list, and remove reminders atomically.
Never edits reminder.yaml directly — always writes to a .tmp file and renames.
CLI tool for LLM skills to create, list, edit, enable, disable, and remove reminders.
All mutations are atomic SQLite transactions with audit logging.
"""
from __future__ import annotations
@@ -15,39 +15,159 @@ import argparse
import json
import os
import sys
from datetime import date, datetime
from datetime import datetime, timezone
from pathlib import Path
import yaml
from croniter import croniter
from db import get_db, init_db, log_operation
from random_times import compute_fire_times
WORKSPACE = Path(__file__).resolve().parent.parent.parent.parent # .../workspace
REMINDER_YAML = WORKSPACE / "reminder.yaml"
WORKSPACE = Path(__file__).resolve().parent.parent.parent.parent
DEFAULT_DB_PATH = WORKSPACE / "db" / "reminders.sqlite"
DB_PATH = Path(os.environ.get("REMIND_DB", str(DEFAULT_DB_PATH)))
def _load() -> dict:
if not REMINDER_YAML.exists():
return {"reminders": []}
data = yaml.safe_load(REMINDER_YAML.read_text(encoding="utf-8")) or {}
if "reminders" not in data:
data["reminders"] = []
return data
def _now() -> str:
return datetime.now(timezone.utc).isoformat(timespec="seconds")
def _save(data: dict) -> None:
tmp = REMINDER_YAML.with_suffix(".yaml.tmp")
tmp.write_text(
yaml.safe_dump(data, allow_unicode=True, sort_keys=False, default_flow_style=False),
encoding="utf-8",
)
os.replace(tmp, REMINDER_YAML)
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."""
fields = {
"times_per_day": args.random_times_per_day,
"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()):
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")
cfg = {key: value for key, value in fields.items() if value is not None}
compute_fire_times(__import__("datetime").date(2000, 1, 1), "validation", cfg)
return cfg
def _parse_window(window: str) -> tuple[int, int]:
start_str, end_str = window.split("-", 1)
start = _hhmm_to_minutes(start_str.strip())
end = _hhmm_to_minutes(end_str.strip())
return start, end
def _hhmm_to_minutes(value: str) -> int:
h, m = value.split(":")
return int(h) * 60 + int(m)
def _insert_schedules(conn, reminder_id: int, args: argparse.Namespace) -> 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),
)
random_cfg = _build_random(args)
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 _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]:
rows = conn.execute(
"SELECT id, text, enabled, timezone, created_at, updated_at, deleted_at FROM reminders WHERE text LIKE ? AND deleted_at IS NULL",
(f"%{keyword}%",),
).fetchall()
return [dict(r) for r in rows]
def cmd_list(_args: argparse.Namespace) -> int:
data = _load()
print(json.dumps({"reminders": data["reminders"]}, ensure_ascii=False))
return 0
_ensure_db()
conn = get_db(DB_PATH)
try:
rows = conn.execute(
"SELECT id, text, enabled, timezone, created_at, updated_at, deleted_at FROM reminders WHERE deleted_at IS NULL ORDER BY id"
).fetchall()
reminders = []
for row in rows:
reminder = dict(row)
rid = reminder["id"]
reminder["at"] = [
dict(r) for r in conn.execute(
"SELECT id, at_datetime FROM schedule_at WHERE reminder_id = ?", (rid,)
).fetchall()
]
reminder["cron"] = [
dict(r) for r in conn.execute(
"SELECT id, cron_expr FROM schedule_cron WHERE reminder_id = ?", (rid,)
).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 = ?",
(rid,),
).fetchall()
]
reminders.append(reminder)
print(json.dumps({"reminders": reminders}, ensure_ascii=False))
return 0
finally:
conn.close()
def cmd_add(args: argparse.Namespace) -> int:
@@ -66,8 +186,6 @@ def cmd_add(args: argparse.Namespace) -> int:
print(json.dumps({"error": "provide --cron, --at, or --random-* options"}), file=sys.stderr)
return 1
item: dict = {"text": text}
if args.at:
for at_str in args.at:
try:
@@ -75,45 +193,35 @@ def cmd_add(args: argparse.Namespace) -> int:
except ValueError as exc:
print(json.dumps({"error": f"invalid --at datetime: {exc}"}), file=sys.stderr)
return 1
if len(args.at) == 1:
item["at"] = args.at[0]
else:
item["at_times"] = args.at
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)
return 1
item["cron_exprs"] = args.cron
if random_cfg:
item["random"] = random_cfg
data = _load()
data["reminders"].append(item)
_save(data)
print(json.dumps({"added": item}, ensure_ascii=False))
return 0
def _build_random(args: argparse.Namespace) -> dict | None:
"""Assemble and validate the random schedule block, or None if no --random-* flag given."""
fields = {
"times_per_day": args.random_times_per_day,
"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()):
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")
cfg = {key: value for key, value in fields.items() if value is not None}
compute_fire_times(date(2000, 1, 1), "validation", cfg) # raises ValueError on a bad config
return cfg
_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)
conn.execute("COMMIT")
reminder = _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:
@@ -122,50 +230,202 @@ def cmd_remove(args: argparse.Namespace) -> int:
print(json.dumps({"error": "keyword must not be empty"}), file=sys.stderr)
return 1
data = _load()
matches = [r for r in data["reminders"] if keyword in (r.get("text") or "").lower()]
_ensure_db()
conn = get_db(DB_PATH)
try:
matches = _find_by_keyword(conn, keyword)
if len(matches) == 0:
print(json.dumps({"error": "no match", "keyword": args.keyword}), file=sys.stderr)
return 1
if len(matches) > 1:
print(
json.dumps({"error": "ambiguous", "matches": [{"text": m["text"]} for m in matches]}, ensure_ascii=False),
file=sys.stderr,
)
return 1
if len(matches) == 0:
print(json.dumps({"error": "no match", "keyword": args.keyword}), file=sys.stderr)
rid = matches[0]["id"]
conn.execute("BEGIN")
conn.execute("UPDATE reminders SET deleted_at = ?, updated_at = ? WHERE id = ?", (_now(), _now(), rid))
conn.execute("COMMIT")
log_operation("REMOVE", rid, f'text="{matches[0]["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:
keyword = (args.keyword or "").strip().lower()
if not keyword:
print(json.dumps({"error": "keyword must not be empty"}), file=sys.stderr)
return 1
if len(matches) > 1:
print(
json.dumps({"error": "ambiguous", "matches": [{"text": m["text"]} for m in matches]}, ensure_ascii=False),
file=sys.stderr,
)
_ensure_db()
conn = get_db(DB_PATH)
try:
matches = _find_by_keyword(conn, keyword)
if len(matches) == 0:
print(json.dumps({"error": "no match", "keyword": args.keyword}), file=sys.stderr)
return 1
if len(matches) > 1:
print(
json.dumps({"error": "ambiguous", "matches": [{"text": m["text"]} for m in matches]}, ensure_ascii=False),
file=sys.stderr,
)
return 1
rid = matches[0]["id"]
conn.execute("BEGIN")
now = _now()
if args.text:
new_text = args.text.strip()
if not new_text:
print(json.dumps({"error": "text must not be empty"}), file=sys.stderr)
return 1
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)
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)
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:
keyword = (args.keyword or "").strip().lower()
if not keyword:
print(json.dumps({"error": "keyword must not be empty"}), file=sys.stderr)
return 1
removed = matches[0]
data["reminders"] = [r for r in data["reminders"] if r is not removed]
_save(data)
print(json.dumps({"removed": removed}, ensure_ascii=False))
return 0
_ensure_db()
conn = get_db(DB_PATH)
try:
matches = _find_by_keyword(conn, keyword)
if len(matches) == 0:
print(json.dumps({"error": "no match", "keyword": args.keyword}), file=sys.stderr)
return 1
if len(matches) > 1:
print(
json.dumps({"error": "ambiguous", "matches": [{"text": m["text"]} for m in matches]}, ensure_ascii=False),
file=sys.stderr,
)
return 1
rid = matches[0]["id"]
conn.execute("UPDATE reminders SET enabled = 1, updated_at = ? WHERE id = ?", (_now(), rid))
log_operation("ENABLE", rid, None)
reminder = _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:
keyword = (args.keyword or "").strip().lower()
if not keyword:
print(json.dumps({"error": "keyword must not be empty"}), file=sys.stderr)
return 1
_ensure_db()
conn = get_db(DB_PATH)
try:
matches = _find_by_keyword(conn, keyword)
if len(matches) == 0:
print(json.dumps({"error": "no match", "keyword": args.keyword}), file=sys.stderr)
return 1
if len(matches) > 1:
print(
json.dumps({"error": "ambiguous", "matches": [{"text": m["text"]} for m in matches]}, ensure_ascii=False),
file=sys.stderr,
)
return 1
rid = matches[0]["id"]
conn.execute("UPDATE reminders SET enabled = 0, updated_at = ? WHERE id = ?", (_now(), rid))
log_operation("DISABLE", rid, None)
reminder = _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 main() -> None:
parser = argparse.ArgumentParser(description="CRUD for reminder.yaml")
parser = argparse.ArgumentParser(description="CRUD for reminders (SQLite backed)")
sub = parser.add_subparsers(dest="command", required=True)
sub.add_parser("list", help="List all reminders as JSON")
sub.add_parser("list", help="List all active reminders as JSON")
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, combinable with --cron)")
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, e.g. '1-5' (optional)")
add_p.add_argument("--random-from", dest="random_from", metavar="YYYY-MM-DD", help="Random schedule: start date, inclusive (optional)")
add_p.add_argument("--random-until", dest="random_until", metavar="YYYY-MM-DD", help="Random schedule: end date, inclusive (optional)")
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")
remove_p = sub.add_parser("remove", help="Remove a reminder by keyword (soft delete)")
remove_p.add_argument("--keyword", required=True, help="Substring to match against reminder text")
edit_p = sub.add_parser("edit", help="Edit a reminder by keyword")
edit_p.add_argument("--keyword", required=True, help="Substring to match against reminder text")
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("--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")
edit_p.add_argument("--random-until", dest="random_until", metavar="YYYY-MM-DD")
enable_p = sub.add_parser("enable", help="Enable a reminder by keyword")
enable_p.add_argument("--keyword", required=True)
disable_p = sub.add_parser("disable", help="Disable a reminder by keyword")
disable_p.add_argument("--keyword", required=True)
args = parser.parse_args()
dispatch = {"list": cmd_list, "add": cmd_add, "remove": cmd_remove}
sys.exit(dispatch[args.command](args))
dispatch = {
"list": cmd_list,
"add": cmd_add,
"remove": cmd_remove,
"edit": cmd_edit,
"enable": cmd_enable,
"disable": cmd_disable,
}
return dispatch[args.command](args)
if __name__ == "__main__":
main()
sys.exit(main())