provozni zaloha
This commit is contained in:
327
skills/remind/scripts/store.py
Normal file
327
skills/remind/scripts/store.py
Normal 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),
|
||||
)
|
||||
Reference in New Issue
Block a user