349 lines
12 KiB
Python
349 lines
12 KiB
Python
#!/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),
|
|
)
|