#!/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 find_active_by_exact_text( conn: sqlite3.Connection, text: str, exclude_id: int | None = None ) -> list[dict]: """Active reminders whose text equals `text` (trimmed, case-insensitive). Comparison happens in Python via ``casefold`` — SQLite's ``lower()`` only folds ASCII, so Czech diacritics ("Čaj"/"čaj") would slip through. Pass ``exclude_id`` to ignore the reminder currently being edited. """ target = text.strip().casefold() rows = conn.execute( "SELECT id, text, enabled, timezone, created_at, updated_at, deleted_at " "FROM reminders WHERE deleted_at IS NULL" ).fetchall() return [ dict(r) for r in rows if r["id"] != exclude_id and r["text"].strip().casefold() == target ] 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), )