Update projektu

This commit is contained in:
lachtan
2026-07-22 12:32:02 +02:00
parent 19014ed3d9
commit 8e66d6b92a
22 changed files with 1995 additions and 503 deletions

View File

@@ -19,6 +19,8 @@ Reply to the user in their own language.
| "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` |
| "every day at 8:00 and 20:00" | `add --cron "0 8 * * *" --cron "0 20 * * *"` |
| "today at 18:00 and Tuesday at 7:00" | `add --at "2026-07-05T18:00:00" --at "2026-07-07T07:00: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` |
@@ -32,6 +34,13 @@ uv run skills/remind/scripts/remind_cli.py <command> --help
## Behavioral contract
**One reminder text = one record.** When the same message should fire at several
times or days, put them all on a **single** `add` with repeated `--at`/`--cron`
(both are repeatable) — never issue multiple `add`s with the same text. `add` and
`edit` reject a duplicate active text with `{"error": "duplicate text", ...}`. To
add a time to an existing reminder, `edit --id <n> --replace-schedules` with **all**
the times it should keep.
**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:

View File

@@ -94,6 +94,35 @@ def _schedule_lines(conn, reminder_id: int) -> list[str]:
return lines
def _reject_duplicate_text(conn, text: str, exclude_id: int | None = None) -> bool:
"""Print an error and return True if an active reminder already has this text.
A single reminder text never needs more than one record: multiple times/days
belong on one reminder via repeated --at/--cron. This guard turns an
accidental split (the agent issuing several `add`s) into a hard error.
"""
dupes = store.find_active_by_exact_text(conn, text, exclude_id=exclude_id)
if not dupes:
return False
display_id = store.active_display_order(conn).index(dupes[0]["id"]) + 1
print(
json.dumps(
{
"error": "duplicate text",
"display_id": display_id,
"hint": (
"a reminder with this text already exists; to fire one message at "
"several times use a single add with repeated --at/--cron, or run: "
f"edit --id {display_id} --replace-schedules --at <t1> --at <t2> ..."
),
},
ensure_ascii=False,
),
file=sys.stderr,
)
return True
def _resolve_one(conn, args: argparse.Namespace) -> dict | None:
"""Resolve exactly one active reminder by --id (display ID) or --keyword (substring).
@@ -198,6 +227,10 @@ def cmd_add(args: argparse.Namespace) -> int:
)
return 1
with store.connection(DB_PATH) as conn:
if _reject_duplicate_text(conn, text):
return 1
try:
with store.transaction(DB_PATH) as conn:
now = _now()
@@ -271,6 +304,8 @@ def cmd_edit(args: argparse.Namespace) -> int:
if target is None:
return 1
rid = target["id"]
if new_text is not None and _reject_duplicate_text(conn, new_text, exclude_id=rid):
return 1
with store.transaction(DB_PATH) as conn:
now = _now()

View File

@@ -204,6 +204,27 @@ def find_active_by_keyword(conn: sqlite3.Connection, keyword: str) -> list[dict]
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(

View File

@@ -1,6 +1,6 @@
import json
import sys
from datetime import datetime
from datetime import datetime, timezone
from pathlib import Path
SCRIPTS = Path(__file__).parent.parent / "scripts"
@@ -8,6 +8,7 @@ sys.path.insert(0, str(SCRIPTS))
from db import get_db, init_db
import remind_cli
import store
def _run(db_path, argv):
@@ -22,6 +23,19 @@ def _run(db_path, argv):
remind_cli.DB_PATH = original_db_path
def _seed_duplicate(db_path, text, cron):
"""Insert a reminder directly via store, bypassing the CLI duplicate guard.
Used to construct pre-existing duplicate texts that the disambiguation code
(`--id`, ambiguous keyword) must still handle even though `add` now blocks them.
"""
with store.transaction(db_path) as conn:
now = datetime.now(timezone.utc).isoformat(timespec="seconds")
rid = store.insert_reminder(conn, text, now)
store.insert_schedules(conn, rid, None, [cron], None)
return rid
def test_add_list(tmp_path, capsys):
db_path = tmp_path / "test.sqlite"
init_db(db_path)
@@ -193,12 +207,65 @@ def test_add_random_validation(tmp_path, capsys):
assert "window" in captured.err.lower() or "gap" in captured.err.lower()
def test_add_rejects_duplicate_text(tmp_path, capsys):
db_path = tmp_path / "test.sqlite"
init_db(db_path)
ret = _run(db_path, ["add", "--text", "call mom", "--at", "2026-12-01T08:00:00"])
capsys.readouterr()
assert ret == 0
ret = _run(db_path, ["add", "--text", "call mom", "--at", "2026-12-01T20:00:00"])
captured = capsys.readouterr()
assert ret == 1
err = json.loads(captured.err)
assert err["error"] == "duplicate text"
assert err["display_id"] == 1
# The first reminder is untouched — no second record was created.
ret = _run(db_path, ["list"])
captured = capsys.readouterr()
assert captured.out.count("call mom") == 1
def test_add_duplicate_case_and_diacritics_insensitive(tmp_path, capsys):
db_path = tmp_path / "test.sqlite"
init_db(db_path)
_run(db_path, ["add", "--text", "Čaj", "--cron", "0 9 * * *"])
capsys.readouterr()
ret = _run(db_path, ["add", "--text", " čaj ", "--cron", "0 10 * * *"])
captured = capsys.readouterr()
assert ret == 1
assert json.loads(captured.err)["error"] == "duplicate text"
def test_edit_text_collision_rejected(tmp_path, capsys):
db_path = tmp_path / "test.sqlite"
init_db(db_path)
_run(db_path, ["add", "--text", "buy milk", "--cron", "0 9 * * *"])
_run(db_path, ["add", "--text", "buy bread", "--cron", "0 10 * * *"])
capsys.readouterr()
ret = _run(db_path, ["edit", "--id", "2", "--text", "buy milk"])
captured = capsys.readouterr()
assert ret == 1
assert json.loads(captured.err)["error"] == "duplicate text"
# Editing a reminder's text to itself (no real change) must still work.
ret = _run(db_path, ["edit", "--id", "1", "--text", "buy milk"])
captured = capsys.readouterr()
assert ret == 0
def test_remove_by_id_disambiguates_duplicates(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 * * *"])
_seed_duplicate(db_path, "drink water", "0 10 * * *")
capsys.readouterr()
ret = _run(db_path, ["remove", "--keyword", "drink water"])
@@ -261,7 +328,7 @@ def test_ambiguous_keyword_returns_display_ids(tmp_path, capsys):
init_db(db_path)
_run(db_path, ["add", "--text", "drink water", "--cron", "0 9 * * *"])
_run(db_path, ["add", "--text", "drink water", "--cron", "0 10 * * *"])
_seed_duplicate(db_path, "drink water", "0 10 * * *")
capsys.readouterr()
ret = _run(db_path, ["remove", "--keyword", "drink"])