From b49efdcb1146dda0ed31105f2d372e975e6f8b2a Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sun, 19 Jul 2026 13:29:14 -0400 Subject: [PATCH] refactor(scribe): retire calendar/events + person/place/list entities (backend) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Narrow Scribe to a Claude-Code work system-of-record (milestone #194, decision note #1759). Wholesale removal per rule #22 — backend + schema half. Calendar/events + CalDAV: delete models/event, services/{events,caldav, caldav_sync}, routes/events, mcp/tools/events; strip event branches from backup (bump v3->v4), dashboard (upcoming_events), trash, recent, and the mcp server read-only allowlist + instructions. Typed entities (person/place/list): delete mcp/tools/entities; drop the notes.metadata (entity_meta) column from model/service/routes and the knowledge browse service. note_type STAYS — it also marks 'process' notes. Scheduler: event_scheduler -> recurrence_scheduler, keeping only the recurring-task spawn job (drops event reminders + CalDAV sync). Schema: migration 0069 drops the events table + notes.metadata column + orphan caldav settings rows (faithful downgrade recreates them). KEEP: recurrence.py (task recurrence), notifications task reminders, graph view, and every work surface. Frontend + plugin/docs true-up follow next. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01BPtbSzA4JLMAKgFZ8VTg7Q --- .../0069_drop_events_and_entity_metadata.py | 82 ++ src/scribe/app.py | 12 +- src/scribe/mcp/server.py | 10 +- src/scribe/mcp/tools/__init__.py | 4 +- src/scribe/mcp/tools/entities.py | 245 ------ src/scribe/mcp/tools/events.py | 171 ---- src/scribe/mcp/tools/recent.py | 24 +- src/scribe/models/__init__.py | 1 - src/scribe/models/event.py | 79 -- src/scribe/models/note.py | 14 +- src/scribe/routes/events.py | 142 ---- src/scribe/routes/knowledge.py | 6 +- src/scribe/routes/notes.py | 7 - src/scribe/routes/settings.py | 76 +- src/scribe/services/backup.py | 89 +- src/scribe/services/caldav.py | 770 ------------------ src/scribe/services/caldav_sync.py | 247 ------ src/scribe/services/dashboard.py | 17 +- src/scribe/services/event_scheduler.py | 198 ----- src/scribe/services/events.py | 477 ----------- src/scribe/services/knowledge.py | 81 +- src/scribe/services/notes.py | 2 - src/scribe/services/recurrence_scheduler.py | 64 ++ src/scribe/services/trash.py | 27 +- tests/test_events_model.py | 26 - tests/test_events_routes.py | 67 -- tests/test_events_service.py | 326 -------- tests/test_mcp_tool_entities.py | 191 ----- tests/test_mcp_tool_events.py | 158 ---- tests/test_services_backup.py | 12 +- tests/test_services_dashboard.py | 3 - tests/test_services_knowledge_counts.py | 4 +- tests/test_trash_filtering.py | 15 - 33 files changed, 194 insertions(+), 3453 deletions(-) create mode 100644 alembic/versions/0069_drop_events_and_entity_metadata.py delete mode 100644 src/scribe/mcp/tools/entities.py delete mode 100644 src/scribe/mcp/tools/events.py delete mode 100644 src/scribe/models/event.py delete mode 100644 src/scribe/routes/events.py delete mode 100644 src/scribe/services/caldav.py delete mode 100644 src/scribe/services/caldav_sync.py delete mode 100644 src/scribe/services/event_scheduler.py delete mode 100644 src/scribe/services/events.py create mode 100644 src/scribe/services/recurrence_scheduler.py delete mode 100644 tests/test_events_model.py delete mode 100644 tests/test_events_routes.py delete mode 100644 tests/test_events_service.py delete mode 100644 tests/test_mcp_tool_entities.py delete mode 100644 tests/test_mcp_tool_events.py diff --git a/alembic/versions/0069_drop_events_and_entity_metadata.py b/alembic/versions/0069_drop_events_and_entity_metadata.py new file mode 100644 index 0000000..38f9a71 --- /dev/null +++ b/alembic/versions/0069_drop_events_and_entity_metadata.py @@ -0,0 +1,82 @@ +"""drop events table + notes.metadata column (retire calendar + entity surfaces) + +Revision ID: 0069 +Revises: 0068 +Create Date: 2026-07-19 + +The personal-assistant surfaces (calendar/events + CalDAV, and the typed +person/place/list entities that stored structured fields in notes.metadata) +were removed when Scribe narrowed to a Claude-Code work system-of-record. +This migration drops their storage: + + - the `events` table (all calendar/CalDAV data) + - the `notes.metadata` (entity_meta) JSONB column — it only ever held + person/place/list structured fields. The `note_type` column STAYS: it + also distinguishes 'process' notes. + - orphan CalDAV settings rows (nothing reads them after the removal) + +Downgrade recreates the table + column structure at its pre-removal shape. +The dropped data itself is not recoverable. +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects.postgresql import JSONB + + +revision = "0069" +down_revision = "0068" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # Entity metadata column (person/place/list structured fields). The + # note_type column is intentionally kept — it also marks 'process' notes. + op.drop_column("notes", "metadata") + + # Calendar / CalDAV storage. Dropping the table drops its indexes + the + # duration CHECK constraint with it. + op.drop_table("events") + + # Orphan CalDAV integration settings — no code reads them post-removal. + op.execute("DELETE FROM settings WHERE key LIKE 'caldav%'") + + +def downgrade() -> None: + # Recreate the events table at its pre-removal schema (empty — the data is + # gone). Mirrors the model as of 0037 (reminders) + 0043 (duration_minutes) + # + 0057 (soft-delete columns/index). + op.create_table( + "events", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("user_id", sa.Integer(), + sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False), + sa.Column("project_id", sa.Integer(), + sa.ForeignKey("projects.id", ondelete="SET NULL"), nullable=True), + sa.Column("uid", sa.Text(), nullable=False), + sa.Column("title", sa.Text(), nullable=False, server_default=""), + sa.Column("start_dt", sa.DateTime(timezone=True), nullable=False), + sa.Column("duration_minutes", sa.Integer(), nullable=True), + sa.Column("all_day", sa.Boolean(), nullable=False, server_default=sa.false()), + sa.Column("description", sa.Text(), nullable=False, server_default=""), + sa.Column("location", sa.Text(), nullable=False, server_default=""), + sa.Column("caldav_uid", sa.Text(), nullable=False, server_default=""), + sa.Column("color", sa.Text(), nullable=False, server_default=""), + sa.Column("recurrence", sa.Text(), nullable=True), + sa.Column("reminder_minutes", sa.Integer(), nullable=True), + sa.Column("reminder_sent_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), + nullable=False, server_default=sa.func.now()), + sa.Column("updated_at", sa.DateTime(timezone=True), + nullable=False, server_default=sa.func.now()), + sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("deleted_batch_id", sa.Text(), nullable=True), + sa.CheckConstraint( + "duration_minutes IS NULL OR duration_minutes >= 0", + name="events_duration_minutes_non_negative", + ), + ) + op.create_index("ix_events_deleted_at", "events", ["deleted_at"]) + + # Re-add the entity metadata column. + op.add_column("notes", sa.Column("metadata", JSONB(), nullable=True)) diff --git a/src/scribe/app.py b/src/scribe/app.py index 84b1a49..7f45626 100644 --- a/src/scribe/app.py +++ b/src/scribe/app.py @@ -21,7 +21,6 @@ from scribe.routes.shares import shares_bp from scribe.routes.in_app_notifications import notifications_bp from scribe.routes.users import users_bp from scribe.routes.api_keys import api_keys_bp -from scribe.routes.events import events_bp from scribe.routes.search import search_bp from scribe.routes.profile import profile_bp from scribe.routes.knowledge import knowledge_bp @@ -84,7 +83,6 @@ def create_app() -> Quart: app.register_blueprint(notifications_bp) app.register_blueprint(users_bp) app.register_blueprint(api_keys_bp) - app.register_blueprint(events_bp) app.register_blueprint(search_bp) app.register_blueprint(profile_bp) app.register_blueprint(knowledge_bp) @@ -173,9 +171,9 @@ def create_app() -> Quart: asyncio.create_task(_delayed_backfill()) - # Event scheduler (reminders + CalDAV pull sync) - from scribe.services.event_scheduler import start_event_scheduler - start_event_scheduler(asyncio.get_running_loop()) + # Recurrence scheduler (recurring-task spawn every 15m) + from scribe.services.recurrence_scheduler import start_recurrence_scheduler + start_recurrence_scheduler(asyncio.get_running_loop()) # Version-pinning scheduler (daily auto-pin scan at 03:00 UTC) from scribe.services.version_pinning_scheduler import ( @@ -204,8 +202,8 @@ def create_app() -> Quart: @app.after_serving async def shutdown(): - from scribe.services.event_scheduler import stop_event_scheduler - stop_event_scheduler() + from scribe.services.recurrence_scheduler import stop_recurrence_scheduler + stop_recurrence_scheduler() from scribe.services.version_pinning_scheduler import ( stop_version_pinning_scheduler, ) diff --git a/src/scribe/mcp/server.py b/src/scribe/mcp/server.py index 0f9b884..44be3af 100644 --- a/src/scribe/mcp/server.py +++ b/src/scribe/mcp/server.py @@ -45,15 +45,10 @@ What each part is for, and when to reach for it: record (note, task, issue) with it via system_ids so research, build-work, and fixes for the same area line up, and recurring problem-spots surface. Manage with create_system / list_systems / get_system. -- Typed entities (person/place/list): structured records about people, places, - and checklists. Mechanics: - Notes and Tasks share a model; tasks are notes with is_task=True. - Use the *_note tools for notes, the *_task tools for tasks. Don't mix them. -- Typed entities (person, place, list) are notes with a non-default note_type - plus type-specific columns; use the dedicated *_person / *_place / *_list - tools rather than create_note. - Tags are plain strings (no `#` prefix). Empty list clears tags; omit to leave unchanged on updates. - For optional integer FKs (project_id, milestone_id, parent_id), use 0 to mean @@ -224,10 +219,9 @@ operator. "Works for one user" is not done. # write for read keys (default-deny), so a newly-added tool is locked down # until explicitly classified here. _READ_ONLY_TOOLS = frozenset({ - "get_event", "get_note", "get_project", "get_rule", "get_rulebook", + "get_note", "get_project", "get_rule", "get_rulebook", "get_task", "get_milestone", "get_recent", "enter_project", - "list_events", "list_lists", "list_milestones", "list_notes", - "list_persons", "list_places", "list_projects", "list_rulebooks", + "list_milestones", "list_notes", "list_projects", "list_rulebooks", "list_rules", "list_tags", "list_tasks", "list_topics", "list_trash", "list_always_on_rules", "search", "get_system", "list_systems", "list_system_records", diff --git a/src/scribe/mcp/tools/__init__.py b/src/scribe/mcp/tools/__init__.py index c03768b..5e9161c 100644 --- a/src/scribe/mcp/tools/__init__.py +++ b/src/scribe/mcp/tools/__init__.py @@ -5,7 +5,7 @@ to a FastMCP instance. `register_all(mcp)` is the single entry point called from `mcp.server.build_mcp_server`. """ from scribe.mcp.tools import ( - entities, events, milestones, notes, processes, projects, recent, repos, rulebooks, search, systems, tags, tasks, trash, + milestones, notes, processes, projects, recent, repos, rulebooks, search, systems, tags, tasks, trash, ) @@ -17,10 +17,8 @@ def register_all(mcp) -> None: projects.register(mcp) milestones.register(mcp) systems.register(mcp) - events.register(mcp) tags.register(mcp) recent.register(mcp) - entities.register(mcp) repos.register(mcp) processes.register(mcp) rulebooks.register(mcp) diff --git a/src/scribe/mcp/tools/entities.py b/src/scribe/mcp/tools/entities.py deleted file mode 100644 index a00fb25..0000000 --- a/src/scribe/mcp/tools/entities.py +++ /dev/null @@ -1,245 +0,0 @@ -"""Typed-entity MCP tools: person, place, list. - -These are notes with a non-'note' note_type and type-specific JSON metadata -stored in the entity_meta column. Three tools per type — list, create, update. -For get and delete, use get_note / delete_note (typed entities -share the Note model). - -The wrappers translate typed-field kwargs into the entity_meta dict shape that -KnowledgeView.vue and services/knowledge.py expect. - -Lists: a list entity stores its items in entity_meta["list_items"] as a list -of {text, checked} dicts. The create/update tools take a simpler `items` list -of plain strings for ergonomics; checked-state is reset to False on each call. -""" -from __future__ import annotations - -from scribe.mcp._context import current_user_id -from scribe.services import knowledge as knowledge_svc -from scribe.services import notes as notes_svc - - -async def _list_by_type(note_type: str, q: str, tag: str, limit: int) -> dict: - """Common list query for a typed entity.""" - uid = current_user_id() - items, total = await knowledge_svc.query_knowledge( - user_id=uid, - note_type=note_type, - tags=[tag] if tag else [], - sort="modified", - q=q or None, - limit=max(1, min(limit, 100)), - offset=0, - ) - # Map "items" key to a type-specific key for caller clarity. - plural = {"person": "persons", "place": "places", "list": "lists"}[note_type] - return {plural: items, "total": total} - - -async def _create_entity(note_type: str, name: str, entity_meta: dict, - tags: list[str] | None = None) -> dict: - """Create a typed note with the given metadata.""" - uid = current_user_id() - note = await notes_svc.create_note( - uid, - title=name, - note_type=note_type, - entity_meta=entity_meta or None, - tags=tags, - ) - return note.to_dict() - - -async def _update_entity(entity_id: int, note_type: str, name: str, - meta_updates: dict) -> dict: - """Merge updates into entity_meta and (optionally) update the title.""" - uid = current_user_id() - note = await notes_svc.get_note(uid, entity_id) - if note is None or note.note_type != note_type: - raise ValueError(f"{note_type} {entity_id} not found") - new_meta = dict(note.entity_meta or {}) - new_meta.update(meta_updates) - fields: dict = {"entity_meta": new_meta} - if name: - fields["title"] = name - updated = await notes_svc.update_note(uid, entity_id, **fields) - return updated.to_dict() - - -# ─── Person ────────────────────────────────────────────────────────────────── - -_PERSON_FIELDS = ("relationship", "email", "phone", "birthday", - "organization", "address") - - -async def list_persons(q: str = "", tag: str = "", limit: int = 25) -> dict: - """List people in the user's knowledge base. - - Args: - q: Free-text search across name + body (optional). - tag: Filter to a single tag (optional). - limit: Max results (1-100). - """ - return await _list_by_type("person", q, tag, limit) - - -async def create_person( - name: str, - relationship: str = "", - email: str = "", - phone: str = "", - birthday: str = "", - organization: str = "", - address: str = "", - tags: list[str] | None = None, -) -> dict: - """Create a person in the user's knowledge base. - - Args: - name: Person's name (required). - relationship: How the user knows them (e.g. "colleague", "friend"). - email / phone / birthday (YYYY-MM-DD) / organization / address: optional. - tags: Plain-string tags, no # prefix. - """ - meta = {f: v for f, v in ( - ("relationship", relationship), ("email", email), ("phone", phone), - ("birthday", birthday), ("organization", organization), - ("address", address), - ) if v} - return await _create_entity("person", name, meta, tags) - - -async def update_person( - person_id: int, - name: str = "", - relationship: str = "", - email: str = "", - phone: str = "", - birthday: str = "", - organization: str = "", - address: str = "", -) -> dict: - """Update a person. Only explicitly provided fields are changed. - - To clear a field, pass an explicit space character (this preserves the - fable-mcp empty-string-means-omit convention). - """ - meta_updates = {f: v for f, v in ( - ("relationship", relationship), ("email", email), ("phone", phone), - ("birthday", birthday), ("organization", organization), - ("address", address), - ) if v} - return await _update_entity(person_id, "person", name, meta_updates) - - -# ─── Place ─────────────────────────────────────────────────────────────────── - - -async def list_places(q: str = "", tag: str = "", limit: int = 25) -> dict: - """List places (cafes, offices, addresses) in the user's knowledge base.""" - return await _list_by_type("place", q, tag, limit) - - -async def create_place( - name: str, - address: str = "", - phone: str = "", - hours: str = "", - website: str = "", - category: str = "", - tags: list[str] | None = None, -) -> dict: - """Create a place in the user's knowledge base. - - Args: - name: Place name (required). - address / phone / hours / website / category: optional. - tags: Plain-string tags, no # prefix. - """ - meta = {f: v for f, v in ( - ("address", address), ("phone", phone), ("hours", hours), - ("website", website), ("category", category), - ) if v} - return await _create_entity("place", name, meta, tags) - - -async def update_place( - place_id: int, - name: str = "", - address: str = "", - phone: str = "", - hours: str = "", - website: str = "", - category: str = "", -) -> dict: - """Update a place. Only explicitly provided fields are changed.""" - meta_updates = {f: v for f, v in ( - ("address", address), ("phone", phone), ("hours", hours), - ("website", website), ("category", category), - ) if v} - return await _update_entity(place_id, "place", name, meta_updates) - - -# ─── List ──────────────────────────────────────────────────────────────────── - - -async def list_lists(q: str = "", tag: str = "", limit: int = 25) -> dict: - """List checklists in the user's knowledge base.""" - return await _list_by_type("list", q, tag, limit) - - -async def create_list( - name: str, - category: str = "", - items: list[str] | None = None, - tags: list[str] | None = None, -) -> dict: - """Create a checklist (a list-type entity). - - Args: - name: List name (required). - category: Optional category label (e.g. "shopping", "packing"). - items: Initial item texts. All items start unchecked. - tags: Plain-string tags, no # prefix. - """ - meta: dict = {} - if category: - meta["category"] = category - if items: - meta["list_items"] = [{"text": t, "checked": False} for t in items] - return await _create_entity("list", name, meta, tags) - - -async def update_list( - list_id: int, - name: str = "", - category: str = "", - items: list[str] | None = None, -) -> dict: - """Update a checklist. - - Args: - list_id: ID of the list to update. - name: New title (optional). - category: New category (optional). - items: REPLACES the entire item set with these texts (all reset to - unchecked). Omit (None) to leave items unchanged. Pass an empty - list to clear all items. - """ - meta_updates: dict = {} - if category: - meta_updates["category"] = category - if items is not None: - meta_updates["list_items"] = [ - {"text": t, "checked": False} for t in items - ] - return await _update_entity(list_id, "list", name, meta_updates) - - -def register(mcp) -> None: - for fn in ( - list_persons, create_person, update_person, - list_places, create_place, update_place, - list_lists, create_list, update_list, - ): - mcp.tool(name=fn.__name__)(fn) diff --git a/src/scribe/mcp/tools/events.py b/src/scribe/mcp/tools/events.py deleted file mode 100644 index 20d1a1d..0000000 --- a/src/scribe/mcp/tools/events.py +++ /dev/null @@ -1,171 +0,0 @@ -"""Calendar event MCP tools — new in Phase 3. - -Events were previously only an internal-LLM tool; the MCP surface didn't have -them. Wraps services/events.py. - -Date/time inputs are split: start_date (YYYY-MM-DD) + start_time (HH:MM) get -combined into a naive datetime; the service layer interprets it in the user's -local timezone. duration_minutes=0 ⇒ point event (NULL duration). The -LLM-era expected_weekday verification check is intentionally not replicated — -Claude doesn't need it. - -For update, sentinels: - - title="" / location="" / description="" → leave unchanged - - start_date="" / start_time="" → leave unchanged (both must be provided to - move the event) - - duration_minutes=-1 → leave unchanged; 0 means "set to point event" -""" -from __future__ import annotations - -from datetime import datetime, timedelta, timezone - -from scribe.mcp._context import current_user_id -from scribe.services import events as events_svc -from scribe.services import trash as trash_svc - - -def _combine(start_date: str, start_time: str) -> datetime: - """Combine YYYY-MM-DD + HH:MM into a naive datetime. - - The events service interprets naive datetimes for create/update against - the user's configured timezone, so we don't attach tzinfo here. - """ - t = start_time or "00:00" - return datetime.fromisoformat(f"{start_date}T{t}:00") - - -def _day_range_utc(date_from: str, date_to: str) -> tuple[datetime, datetime]: - """Return a UTC datetime range [start_of_date_from, end_of_date_to). - - Event.start_dt is stored timezone-aware in the DB; comparing it against a - naive datetime raises TypeError. We anchor the range in UTC, which is a - reasonable default — refining to the user's local timezone for the - range boundaries is a separate improvement. - """ - start = datetime.fromisoformat(date_from).replace(tzinfo=timezone.utc) - # `date_to` is inclusive at the day level — bump by 24h so events later - # on date_to are included. - end = ( - datetime.fromisoformat(date_to).replace(tzinfo=timezone.utc) - + timedelta(days=1) - ) - return start, end - - -def _event_dict(event) -> dict: - """Render an Event model to a dict, handling list_events (already dicts).""" - return event if isinstance(event, dict) else event.to_dict() - - -async def list_events(date_from: str, date_to: str) -> dict: - """List events between date_from and date_to (YYYY-MM-DD, both inclusive at - the day level — `date_to` is interpreted as end-of-that-day). - - Recurring events are expanded into individual occurrences within the range. - """ - uid = current_user_id() - start, end = _day_range_utc(date_from, date_to) - rows = await events_svc.list_events(uid, start, end) - return {"events": [_event_dict(e) for e in rows], "total": len(rows)} - - -async def create_event( - title: str, - start_date: str, - start_time: str = "00:00", - duration_minutes: int = 0, - all_day: bool = False, - location: str = "", - description: str = "", -) -> dict: - """Create a calendar event. - - Args: - title: Event title (required). - start_date: YYYY-MM-DD. - start_time: HH:MM (24-hour). Ignored when all_day=True. - duration_minutes: 0 for a point event (no duration); otherwise minutes. - all_day: True to make this an all-day event. - location: Optional location string. - description: Optional longer description. - """ - uid = current_user_id() - event = await events_svc.create_event( - uid, - title=title, - start_dt=_combine(start_date, start_time), - duration_minutes=duration_minutes or None, - all_day=all_day, - location=location, - description=description, - ) - return event.to_dict() - - -async def get_event(event_id: int) -> dict: - """Fetch a single event by ID.""" - uid = current_user_id() - event = await events_svc.get_event(uid, event_id) - if event is None: - raise ValueError(f"event {event_id} not found") - return event.to_dict() - - -async def update_event( - event_id: int, - title: str = "", - start_date: str = "", - start_time: str = "", - duration_minutes: int = -1, - location: str = "", - description: str = "", -) -> dict: - """Update an event. Only explicitly provided fields are changed. - - Args: - event_id: ID of the event to update. - title: New title; omit to leave unchanged. - start_date / start_time: BOTH must be set to move the event. Omit either - to leave the start_dt unchanged. - duration_minutes: -1 leaves unchanged; 0 sets to point event; any - positive value sets the duration. - location / description: omit to leave unchanged. - """ - uid = current_user_id() - fields: dict = {} - if title: - fields["title"] = title - if location: - fields["location"] = location - if description: - fields["description"] = description - if start_date and start_time: - fields["start_dt"] = _combine(start_date, start_time) - if duration_minutes >= 0: - # 0 means point event (NULL); positive sets a real duration. - fields["duration_minutes"] = duration_minutes or None - event = await events_svc.update_event(uid, event_id, **fields) - if event is None: - raise ValueError(f"event {event_id} not found") - return event.to_dict() - - -async def delete_event(event_id: int) -> dict: - """Move a calendar event to the trash (recoverable). Restore via restore(batch_id).""" - uid = current_user_id() - batch = await trash_svc.delete(uid, "event", event_id) - if batch is None: - raise ValueError(f"event {event_id} not found") - return {"deleted_batch_id": batch, - "message": f"Event {event_id} moved to trash. Restore with restore('{batch}')."} - - -def register(mcp) -> None: - for fn in ( - list_events, - create_event, - get_event, - update_event, - delete_event, - ): - mcp.tool(name=fn.__name__)(fn) diff --git a/src/scribe/mcp/tools/recent.py b/src/scribe/mcp/tools/recent.py index bc49fe2..b19b5be 100644 --- a/src/scribe/mcp/tools/recent.py +++ b/src/scribe/mcp/tools/recent.py @@ -1,10 +1,10 @@ """get_recent — cross-type recent-activity tool. -Returns the most-recently-touched notes, tasks, projects, and events for the -user, ordered by updated_at descending. Useful for Claude to bootstrap context -at the start of a conversation ("what was I working on?"). +Returns the most-recently-touched notes, tasks, and projects for the user, +ordered by updated_at descending. Useful for Claude to bootstrap context at +the start of a conversation ("what was I working on?"). -Aggregation is Python-side after three small per-table queries — simpler than +Aggregation is Python-side after two small per-table queries — simpler than a UNION ALL with type-discriminating columns, and fine for personal-scale data. """ from __future__ import annotations @@ -15,13 +15,12 @@ from sqlalchemy import select from scribe.mcp._context import current_user_id from scribe.models import async_session -from scribe.models.event import Event from scribe.models.note import Note from scribe.models.project import Project async def get_recent(days: int = 7, limit: int = 25) -> dict: - """Return recently-touched items across notes, tasks, projects, events. + """Return recently-touched items across notes, tasks, and projects. Args: days: Look-back window in days (1-90). @@ -66,19 +65,6 @@ async def get_recent(days: int = 7, limit: int = 25) -> dict: "title": p.title, "updated_at": p.updated_at.isoformat(), }) - events = (await session.execute( - select(Event).where(Event.user_id == uid, - Event.updated_at >= since, - Event.deleted_at.is_(None)) - .order_by(Event.updated_at.desc()).limit(limit) - )).scalars().all() - for e in events: - items.append({ - "id": e.id, - "type": "event", - "title": e.title, - "updated_at": e.updated_at.isoformat(), - }) items.sort(key=lambda r: r["updated_at"], reverse=True) items = items[:limit] return {"items": items, "total": len(items)} diff --git a/src/scribe/models/__init__.py b/src/scribe/models/__init__.py index 982ccee..2d15043 100644 --- a/src/scribe/models/__init__.py +++ b/src/scribe/models/__init__.py @@ -28,7 +28,6 @@ from scribe.models.invitation import InvitationToken # noqa: E402, F401 from scribe.models.embedding import NoteEmbedding # noqa: E402, F401 from scribe.models.retrieval_log import RetrievalLog # noqa: E402, F401 from scribe.models.project import Project # noqa: E402, F401 -from scribe.models.event import Event # noqa: E402, F401 from scribe.models.milestone import Milestone # noqa: E402, F401 from scribe.models.task_log import TaskLog # noqa: E402, F401 from scribe.models.note_draft import NoteDraft # noqa: E402, F401 diff --git a/src/scribe/models/event.py b/src/scribe/models/event.py deleted file mode 100644 index e179b13..0000000 --- a/src/scribe/models/event.py +++ /dev/null @@ -1,79 +0,0 @@ -from datetime import datetime, timedelta, timezone - -from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, Text -from sqlalchemy.orm import Mapped, mapped_column - -from scribe.models import Base -from scribe.models.base import SoftDeleteMixin - - -class Event(Base, SoftDeleteMixin): - __tablename__ = "events" - - id: Mapped[int] = mapped_column(primary_key=True) - user_id: Mapped[int] = mapped_column( - Integer, ForeignKey("users.id", ondelete="CASCADE") - ) - project_id: Mapped[int | None] = mapped_column( - Integer, ForeignKey("projects.id", ondelete="SET NULL"), nullable=True - ) - # iCal UID for Radicale linkage (unique per user) - uid: Mapped[str] = mapped_column(Text) - title: Mapped[str] = mapped_column(Text, default="") - start_dt: Mapped[datetime] = mapped_column(DateTime(timezone=True)) - # Duration in minutes; NULL = point event with no end specified. - # Replaces the prior `end_dt` column (Fable #160 / migration 0043). - # The DB has a CHECK constraint that this is NULL or >= 0, so an - # event whose end is before its start is structurally inexpressible. - duration_minutes: Mapped[int | None] = mapped_column(Integer, nullable=True) - all_day: Mapped[bool] = mapped_column(Boolean, default=False) - description: Mapped[str] = mapped_column(Text, default="") - location: Mapped[str] = mapped_column(Text, default="") - caldav_uid: Mapped[str] = mapped_column(Text, default="") - color: Mapped[str] = mapped_column(Text, default="") - recurrence: Mapped[str | None] = mapped_column(Text, nullable=True) - reminder_minutes: Mapped[int | None] = mapped_column(Integer, nullable=True) - reminder_sent_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) - created_at: Mapped[datetime] = mapped_column( - DateTime(timezone=True), default=lambda: datetime.now(timezone.utc) - ) - updated_at: Mapped[datetime] = mapped_column( - DateTime(timezone=True), - default=lambda: datetime.now(timezone.utc), - onupdate=lambda: datetime.now(timezone.utc), - ) - - @property - def end_dt(self) -> datetime | None: - """Derived end datetime: ``start_dt + duration_minutes``. - - Returns ``None`` for point events (``duration_minutes is None``). - Computed at access time rather than stored — a stored end was - the source of the "end before start" corruption that motivated - this redesign. - """ - if self.duration_minutes is None: - return None - return self.start_dt + timedelta(minutes=self.duration_minutes) - - def to_dict(self) -> dict: - end_dt = self.end_dt - return { - "id": self.id, - "user_id": self.user_id, - "uid": self.uid, - "caldav_uid": self.caldav_uid, - "project_id": self.project_id, - "title": self.title, - "start_dt": self.start_dt.isoformat() if self.start_dt else None, - "end_dt": end_dt.isoformat() if end_dt else None, - "duration_minutes": self.duration_minutes, - "all_day": self.all_day, - "description": self.description, - "location": self.location, - "color": self.color, - "recurrence": self.recurrence, - "reminder_minutes": self.reminder_minutes, - "created_at": self.created_at.isoformat() if self.created_at else None, - "updated_at": self.updated_at.isoformat() if self.updated_at else None, - } diff --git a/src/scribe/models/note.py b/src/scribe/models/note.py index e17b3d7..6682c6d 100644 --- a/src/scribe/models/note.py +++ b/src/scribe/models/note.py @@ -61,11 +61,9 @@ class Note(Base, TimestampMixin, SoftDeleteMixin): recurrence_next_spawn_at: Mapped[datetime | None] = mapped_column( DateTime(timezone=True), nullable=True ) - # Entity type — 'note' (default), 'person', 'place', 'list' + # Note type — 'note' (default) or 'process' (a stored process). Task-ness is + # tracked by `status`, not here. (person/place/list entity types removed 2026-07.) note_type: Mapped[str] = mapped_column(Text, default="note", server_default="note") - # Structured metadata for entity types (person/place/list) - # Named 'entity_meta' to avoid collision with SQLAlchemy's reserved 'metadata' attribute - entity_meta: Mapped[dict | None] = mapped_column("metadata", JSONB, nullable=True) # Task sub-kind — 'work' (default), 'plan', or 'issue' (corrective work). # Only meaningful when the note is a task (status is not None); ordinary # notes keep the 'work' default and ignore it. Orthogonal to note_type @@ -87,11 +85,6 @@ class Note(Base, TimestampMixin, SoftDeleteMixin): def is_task(self) -> bool: return self.status is not None - @property - def entity_type(self) -> str: - """Normalised type: 'note', 'person', 'place', or 'list'.""" - return self.note_type or "note" - def to_dict(self) -> dict: return { "id": self.id, @@ -118,9 +111,8 @@ class Note(Base, TimestampMixin, SoftDeleteMixin): else None ), "is_task": self.is_task, - "note_type": self.entity_type, + "note_type": self.note_type or "note", "task_kind": self.task_kind, - "metadata": self.entity_meta or {}, "created_at": self.created_at.isoformat(), "updated_at": self.updated_at.isoformat(), } diff --git a/src/scribe/routes/events.py b/src/scribe/routes/events.py deleted file mode 100644 index d3b8c97..0000000 --- a/src/scribe/routes/events.py +++ /dev/null @@ -1,142 +0,0 @@ -"""Calendar events REST API.""" -from __future__ import annotations - -from datetime import datetime, timezone - -from quart import Blueprint, g, jsonify, request - -from scribe.auth import login_required -import scribe.services.events as events_svc - -events_bp = Blueprint("events", __name__, url_prefix="/api/events") - - -def _parse_dt(value: str) -> datetime: - """Parse ISO 8601 datetime string, ensuring UTC-awareness.""" - dt = datetime.fromisoformat(value.replace("Z", "+00:00")) - if dt.tzinfo is None: - dt = dt.replace(tzinfo=timezone.utc) - return dt - - -def _get_current_user_id() -> int: - return g.user.id - - -@events_bp.get("") -@login_required -async def list_events(): - date_from_str = request.args.get("from") - date_to_str = request.args.get("to") - if not date_from_str or not date_to_str: - return jsonify({"error": "from and to query params are required"}), 400 - try: - date_from = _parse_dt(date_from_str) - date_to = _parse_dt(date_to_str) - except ValueError: - return jsonify({"error": "Invalid datetime format"}), 400 - events = await events_svc.list_events( - user_id=_get_current_user_id(), - date_from=date_from, - date_to=date_to, - ) - return jsonify(events) - - -@events_bp.post("") -@login_required -async def create_event(): - data = await request.get_json() or {} - if not data.get("title") or not data.get("start_dt"): - return jsonify({"error": "title and start_dt are required"}), 400 - try: - start_dt = _parse_dt(data["start_dt"]) - end_dt = _parse_dt(data["end_dt"]) if data.get("end_dt") else None - except ValueError: - return jsonify({"error": "Invalid datetime format"}), 400 - try: - event = await events_svc.create_event( - user_id=_get_current_user_id(), - title=data["title"], - start_dt=start_dt, - end_dt=end_dt, - duration_minutes=data.get("duration_minutes"), - all_day=data.get("all_day", False), - description=data.get("description", ""), - location=data.get("location", ""), - color=data.get("color", ""), - recurrence=data.get("recurrence"), - project_id=data.get("project_id"), - reminder_minutes=data.get("reminder_minutes"), - ) - except ValueError as exc: - return jsonify({"error": str(exc)}), 400 - return jsonify(event.to_dict()), 201 - - -@events_bp.get("/") -@login_required -async def get_event(event_id: int): - event = await events_svc.get_event( - user_id=_get_current_user_id(), - event_id=event_id, - ) - if event is None: - return jsonify({"error": "Event not found"}), 404 - return jsonify(event.to_dict()) - - -@events_bp.patch("/") -@login_required -async def update_event(event_id: int): - data = await request.get_json() or {} - fields: dict = {} - for str_field in ("title", "description", "location", "color", "recurrence"): - if str_field in data: - fields[str_field] = data[str_field] - for bool_field in ("all_day",): - if bool_field in data: - fields[bool_field] = data[bool_field] - for int_field in ("project_id", "reminder_minutes", "duration_minutes"): - if int_field in data: - fields[int_field] = data[int_field] - for dt_field in ("start_dt", "end_dt"): - if dt_field in data: - if data[dt_field] is None: - # Explicit null clears the field (e.g. removing end_dt) - fields[dt_field] = None - elif data[dt_field]: - try: - fields[dt_field] = _parse_dt(data[dt_field]) - except ValueError: - return jsonify({"error": f"Invalid datetime for {dt_field}"}), 400 - try: - event = await events_svc.update_event( - user_id=_get_current_user_id(), - event_id=event_id, - **fields, - ) - except ValueError as exc: - return jsonify({"error": str(exc)}), 400 - if event is None: - return jsonify({"error": "Event not found"}), 404 - return jsonify(event.to_dict()) - - -@events_bp.delete("/") -@login_required -async def delete_event(event_id: int): - from scribe.services.trash import delete as trash_delete - batch = await trash_delete(_get_current_user_id(), "event", event_id) - if batch is None: - return jsonify({"error": "Event not found"}), 404 - return "", 204 - - -@events_bp.post("/sync") -@login_required -async def sync_caldav(): - """Trigger a CalDAV pull sync for the current user.""" - from scribe.services.caldav_sync import sync_user_events - result = await sync_user_events(user_id=_get_current_user_id()) - return jsonify(result) diff --git a/src/scribe/routes/knowledge.py b/src/scribe/routes/knowledge.py index ab31732..901693f 100644 --- a/src/scribe/routes/knowledge.py +++ b/src/scribe/routes/knowledge.py @@ -1,4 +1,4 @@ -"""Unified Knowledge endpoint — notes, people, places, lists in one queryable feed.""" +"""Unified Knowledge endpoint — notes, tasks, plans, and processes in one queryable feed.""" import logging from quart import Blueprint, jsonify, request @@ -10,7 +10,7 @@ logger = logging.getLogger(__name__) knowledge_bp = Blueprint("knowledge", __name__, url_prefix="/api/knowledge") -_VALID_TYPES = {"note", "person", "place", "list", "task", "plan", "process"} +_VALID_TYPES = {"note", "task", "plan", "process"} _VALID_SORTS = {"modified", "created", "alpha", "type"} @@ -20,7 +20,7 @@ async def list_knowledge(): """Return paginated knowledge objects with optional filtering. Query params: - type — one of note|person|place|list (omit for all, excludes tasks) + type — one of note|task|plan|process (omit for all) tags — comma-separated tag filter (AND logic) sort — modified|created|alpha|type (default: modified) q — search query (semantic when provided, keyword fallback) diff --git a/src/scribe/routes/notes.py b/src/scribe/routes/notes.py index 6864281..b70cc44 100644 --- a/src/scribe/routes/notes.py +++ b/src/scribe/routes/notes.py @@ -95,7 +95,6 @@ async def create_note_route(): project_id = proj.id note_type = data.get("note_type", "note") - entity_meta = data.get("metadata") or None try: note = await create_note( @@ -111,7 +110,6 @@ async def create_note_route(): priority=priority, due_date=due_date, note_type=note_type, - entity_meta=entity_meta, ) except ValueError as e: return jsonify({"error": str(e)}), 400 @@ -206,9 +204,6 @@ async def update_note_route(note_id: int): for key in ("title", "body", "description", "parent_id", "project_id", "milestone_id", "status", "priority", "note_type"): if key in data: fields[key] = data[key] - if "metadata" in data: - fields["entity_meta"] = data["metadata"] or None - if "due_date" in data: if data["due_date"]: result = parse_iso_date(data["due_date"], "due_date") @@ -248,8 +243,6 @@ async def patch_note_route(note_id: int): for key in ("title", "body", "description", "parent_id", "project_id", "milestone_id", "status", "priority", "note_type"): if key in data: fields[key] = data[key] - if "metadata" in data: - fields["entity_meta"] = data["metadata"] or None if "due_date" in data: if data["due_date"]: result = parse_iso_date(data["due_date"], "due_date") diff --git a/src/scribe/routes/settings.py b/src/scribe/routes/settings.py index bb1ea1c..cbf9b28 100644 --- a/src/scribe/routes/settings.py +++ b/src/scribe/routes/settings.py @@ -1,47 +1,19 @@ -"""User settings + integrations (CalDAV, SearXNG status). +"""User settings + integrations (SearXNG status). Chat-model picker endpoints (/models), KV-cache priming, and journal-schedule hooks were removed in Phase 8 alongside the chat/journal subsystems. """ -import ipaddress import logging -import socket -from urllib.parse import urlparse from quart import Blueprint, jsonify, request from scribe.auth import login_required, get_current_user_id from scribe.config import Config -from scribe.services.caldav import CALDAV_SETTING_KEYS, get_caldav_config, test_connection from scribe.services.settings import delete_setting, get_all_settings, get_setting, set_settings_batch logger = logging.getLogger(__name__) -def _is_private_url(url: str) -> bool: - """SSRF-blocking helper: returns True for URLs that resolve to private, - loopback, or link-local addresses. Inlined here after services/llm.py - (the original home) was removed in Phase 8.""" - try: - host = urlparse(url).hostname - if not host: - return True - # Resolve to all addresses; reject if any is private/loopback/link-local. - infos = socket.getaddrinfo(host, None) - for family, *_rest, sockaddr in infos: - ip_str = sockaddr[0] - try: - ip = ipaddress.ip_address(ip_str) - except ValueError: - continue - if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved: - return True - except Exception: - # Conservative: if we can't resolve, treat as private (reject). - return True - return False - - settings_bp = Blueprint("settings", __name__, url_prefix="/api/settings") @@ -76,52 +48,6 @@ async def update_settings_route(): return jsonify(settings) -@settings_bp.route("/caldav", methods=["GET"]) -@login_required -async def get_caldav(): - uid = get_current_user_id() - config = await get_caldav_config(uid) - if config.get("caldav_password"): - config["caldav_password"] = "********" - return jsonify(config) - - -@settings_bp.route("/caldav", methods=["PUT"]) -@login_required -async def update_caldav(): - uid = get_current_user_id() - data = await request.get_json() - - # Validate CalDAV URL before saving — block internal/private addresses - if "caldav_url" in data: - url = str(data.get("caldav_url") or "").strip() - if url: - parsed_scheme = url.split("://")[0].lower() if "://" in url else "" - if parsed_scheme not in ("http", "https"): - return jsonify({"error": "CalDAV URL must use http or https"}), 400 - if _is_private_url(url): - return jsonify({"error": "CalDAV URL must not point to an internal or private address"}), 400 - - settings_to_save = {} - for key in CALDAV_SETTING_KEYS: - if key in data: - if key == "caldav_password" and data[key] == "********": - continue - settings_to_save[key] = str(data[key]) - - if settings_to_save: - await set_settings_batch(uid, settings_to_save) - return jsonify({"status": "ok"}) - - -@settings_bp.route("/caldav/test", methods=["POST"]) -@login_required -async def test_caldav(): - uid = get_current_user_id() - result = await test_connection(uid) - return jsonify(result) - - @settings_bp.route("/search", methods=["GET"]) @login_required async def test_search(): diff --git a/src/scribe/services/backup.py b/src/scribe/services/backup.py index 9184c12..32d5cf6 100644 --- a/src/scribe/services/backup.py +++ b/src/scribe/services/backup.py @@ -4,7 +4,6 @@ from datetime import date, datetime, timezone from sqlalchemy import or_, select from scribe.models import async_session -from scribe.models.event import Event from scribe.models.milestone import Milestone from scribe.models.note import Note from scribe.models.note_draft import NoteDraft @@ -25,9 +24,10 @@ from scribe.models.user import User logger = logging.getLogger(__name__) # Backup format version. v3 (2026-06) added rulebooks/topics/rules + their -# project subscription/suppression join tables, and events — all silently -# dropped by v2. Bump when the serialized schema changes. -BACKUP_VERSION = 3 +# project subscription/suppression join tables. v4 (2026-07) dropped events +# when the calendar surface was retired — old v3 events are skipped on restore. +# Bump when the serialized schema changes. +BACKUP_VERSION = 4 # Tables intentionally NOT in the backup, surfaced in the payload so the gap is # explicit rather than silent. ACL (groups/shares) is a coherent follow-up; @@ -80,7 +80,6 @@ async def export_full_backup() -> dict: rulebooks = (await session.execute(select(Rulebook))).scalars().all() topics = (await session.execute(select(RulebookTopic))).scalars().all() rules = (await session.execute(select(Rule))).scalars().all() - events = (await session.execute(select(Event))).scalars().all() subscriptions = (await session.execute( select(project_rulebook_subscriptions) )).all() @@ -245,27 +244,6 @@ async def export_full_backup() -> dict: "rulebook_subscriptions": _subscription_rows(subscriptions), "rule_suppressions": _rule_suppression_rows(rule_suppressions), "topic_suppressions": _topic_suppression_rows(topic_suppressions), - "events": [ - { - "id": e.id, - "user_id": e.user_id, - "project_id": e.project_id, - "uid": e.uid, - "caldav_uid": e.caldav_uid, - "title": e.title, - "start_dt": e.start_dt.isoformat() if e.start_dt else None, - "duration_minutes": e.duration_minutes, - "all_day": e.all_day, - "description": e.description, - "location": e.location, - "color": e.color, - "recurrence": e.recurrence, - "reminder_minutes": e.reminder_minutes, - "created_at": e.created_at.isoformat(), - "updated_at": e.updated_at.isoformat(), - } - for e in events - ], } @@ -314,9 +292,6 @@ async def export_user_backup(user_id: int) -> dict: rules = (await session.execute( select(Rule).where(or_(*rule_filters)) )).scalars().all() if rule_filters else [] - events = (await session.execute( - select(Event).where(Event.user_id == user_id) - )).scalars().all() if project_ids: subscriptions = (await session.execute( select(project_rulebook_subscriptions).where( @@ -480,27 +455,6 @@ async def export_user_backup(user_id: int) -> dict: "rulebook_subscriptions": _subscription_rows(subscriptions), "rule_suppressions": _rule_suppression_rows(rule_suppressions), "topic_suppressions": _topic_suppression_rows(topic_suppressions), - "events": [ - { - "id": e.id, - "user_id": e.user_id, - "project_id": e.project_id, - "uid": e.uid, - "caldav_uid": e.caldav_uid, - "title": e.title, - "start_dt": e.start_dt.isoformat() if e.start_dt else None, - "duration_minutes": e.duration_minutes, - "all_day": e.all_day, - "description": e.description, - "location": e.location, - "color": e.color, - "recurrence": e.recurrence, - "reminder_minutes": e.reminder_minutes, - "created_at": e.created_at.isoformat(), - "updated_at": e.updated_at.isoformat(), - } - for e in events - ], } @@ -591,17 +545,17 @@ async def _restore_v1(data: dict) -> dict: async def _restore_v2(data: dict) -> dict: """Restore v2/v3 backup with full FK re-mapping. - Conversations + push subscriptions in pre-pivot backups are silently - skipped — those subsystems were removed in the MCP-first pivot. v3-only - sections (rulebooks/topics/rules/join-tables/events) are guarded by - data.get so a v2 payload restores without them. + Conversations, push subscriptions, and (as of v4) events in older backups + are silently skipped — those subsystems were removed. v3+ sections + (rulebooks/topics/rules/join-tables) are guarded by data.get so a v2 + payload restores without them. """ stats: dict[str, int] = { "users": 0, "projects": 0, "milestones": 0, "notes": 0, "task_logs": 0, "note_drafts": 0, "note_versions": 0, "settings": 0, "rulebooks": 0, "rulebook_topics": 0, "rules": 0, "rulebook_subscriptions": 0, "rule_suppressions": 0, - "topic_suppressions": 0, "events": 0, + "topic_suppressions": 0, } async with async_session() as session: @@ -860,31 +814,6 @@ async def _restore_v2(data: dict) -> dict: )) stats["topic_suppressions"] += 1 - # 15. Events (v3) - for e_data in data.get("events", []): - mapped_uid = user_id_map.get(e_data.get("user_id", 0)) - if mapped_uid is None: - continue - ev = Event( - user_id=mapped_uid, - project_id=project_id_map.get(e_data["project_id"]) if e_data.get("project_id") else None, - uid=e_data.get("uid", ""), - caldav_uid=e_data.get("caldav_uid", ""), - title=e_data.get("title", ""), - start_dt=_dt(e_data.get("start_dt")), - duration_minutes=e_data.get("duration_minutes"), - all_day=e_data.get("all_day", False), - description=e_data.get("description", ""), - location=e_data.get("location", ""), - color=e_data.get("color", ""), - recurrence=e_data.get("recurrence"), - reminder_minutes=e_data.get("reminder_minutes"), - created_at=_dt(e_data.get("created_at")), - updated_at=_dt(e_data.get("updated_at")), - ) - session.add(ev) - stats["events"] += 1 - await session.commit() logger.info("Restored v2/v3 backup: %s", stats) diff --git a/src/scribe/services/caldav.py b/src/scribe/services/caldav.py deleted file mode 100644 index 8d097e1..0000000 --- a/src/scribe/services/caldav.py +++ /dev/null @@ -1,770 +0,0 @@ -"""CalDAV calendar integration service.""" - -import asyncio -import logging -from datetime import date as date_type, datetime, timedelta -from zoneinfo import ZoneInfo - -import caldav -import icalendar - -from scribe.services.settings import get_all_settings - -logger = logging.getLogger(__name__) - -CALDAV_SETTING_KEYS = ["caldav_url", "caldav_username", "caldav_password", "caldav_calendar_name", "caldav_timezone"] - -# Sentinel: distinguishes "leave the RRULE untouched" from "clear it" (None/"") -# in update_event, since None is a meaningful value for recurrence. -_RECURRENCE_UNSET = object() - - -async def get_caldav_config(user_id: int) -> dict[str, str]: - """Return the user's CalDAV config from their settings.""" - all_settings = await get_all_settings(user_id) - return {k: all_settings.get(k, "") for k in CALDAV_SETTING_KEYS} - - -async def is_caldav_configured(user_id: int) -> bool: - """Check if the user has configured an external CalDAV server.""" - config = await get_caldav_config(user_id) - return bool(config.get("caldav_url") and config.get("caldav_username") and config.get("caldav_password")) - - -def _get_calendar(client: caldav.DAVClient, calendar_name: str) -> caldav.Calendar: - """Get a named calendar or the first available one (synchronous).""" - principal = client.principal() - calendars = principal.calendars() - if not calendars: - raise ValueError("No calendars found on the CalDAV server.") - if calendar_name: - for cal in calendars: - if cal.name == calendar_name: - return cal - names = [c.name for c in calendars] - raise ValueError(f"Calendar '{calendar_name}' not found. Available: {', '.join(names)}") - return calendars[0] - - -def _get_all_calendars(client: caldav.DAVClient) -> list[caldav.Calendar]: - """Get all calendars for the user (synchronous).""" - principal = client.principal() - calendars = principal.calendars() - if not calendars: - raise ValueError("No calendars found on the CalDAV server.") - return calendars - - -def _make_client(config: dict[str, str]) -> caldav.DAVClient: - """Create a CalDAV client from config dict.""" - return caldav.DAVClient( - url=config["caldav_url"], - username=config.get("caldav_username") or None, - password=config.get("caldav_password") or None, - ) - - -def _parse_vevent(component) -> dict | None: - """Extract event data from a VEVENT component.""" - if component.name != "VEVENT": - return None - title = str(component.get("SUMMARY", "")) - dtstart = component.get("DTSTART") - dtend = component.get("DTEND") - location = str(component.get("LOCATION", "")) - description = str(component.get("DESCRIPTION", "")) - uid = str(component.get("UID", "")) - start_str = dtstart.dt.isoformat() if dtstart else "" - end_str = dtend.dt.isoformat() if dtend else "" - - result = { - "uid": uid, - "title": title, - "start": start_str, - "end": end_str, - "location": location, - "description": description, - } - - # Extract recurrence rule - rrule = component.get("RRULE") - if rrule: - result["recurrence"] = rrule.to_ical().decode("utf-8") - - # Extract alarms - alarms = [] - for sub in component.subcomponents: - if sub.name == "VALARM": - trigger = sub.get("TRIGGER") - if trigger and trigger.dt: - minutes = abs(int(trigger.dt.total_seconds() // 60)) - alarms.append({"minutes_before": minutes}) - if alarms: - result["alarms"] = alarms - - # Extract attendees - attendees = component.get("ATTENDEE") - if attendees: - if not isinstance(attendees, list): - attendees = [attendees] - result["attendees"] = [str(a).replace("mailto:", "") for a in attendees] - - return result - - -def _parse_vtodo(component) -> dict | None: - """Extract todo data from a VTODO component.""" - if component.name != "VTODO": - return None - uid = str(component.get("UID", "")) - summary = str(component.get("SUMMARY", "")) - description = str(component.get("DESCRIPTION", "")) - status = str(component.get("STATUS", "")) - due = component.get("DUE") - due_str = due.dt.isoformat() if due else "" - priority = component.get("PRIORITY") - priority_val = int(priority) if priority else None - - return { - "uid": uid, - "summary": summary, - "description": description, - "due": due_str, - "status": status, - "priority": priority_val, - } - - -def _apply_timezone(dt: datetime, timezone: str | None) -> datetime: - """Apply a timezone to a naive datetime. Returns dt unchanged if already aware.""" - if dt.tzinfo is not None: - return dt - if timezone: - return dt.replace(tzinfo=ZoneInfo(timezone)) - return dt - - -def _build_valarm(minutes_before: int) -> icalendar.Alarm: - """Create a DISPLAY alarm component triggered N minutes before the event.""" - alarm = icalendar.Alarm() - alarm.add("action", "DISPLAY") - alarm.add("description", "Reminder") - alarm.add("trigger", timedelta(minutes=-minutes_before)) - return alarm - - -def _add_attendees(event: icalendar.Event, attendees: list[str]) -> None: - """Add mailto: attendees to an iCalendar event.""" - for email in attendees: - attendee = icalendar.vCalAddress(f"mailto:{email}") - event.add("attendee", attendee) - - -def _check_config(config: dict[str, str]) -> None: - """Raise if CalDAV is not configured.""" - if not config.get("caldav_url"): - raise ValueError("CalDAV is not configured. Go to Settings → Calendar to enter your server URL.") - - -async def create_event( - user_id: int, - title: str, - start: str, - end: str | None = None, - duration: int | None = None, - description: str | None = None, - location: str | None = None, - all_day: bool = False, - recurrence: str | None = None, - timezone: str | None = None, - reminder_minutes: int | None = None, - attendees: list[str] | None = None, - calendar_name: str | None = None, - uid: str | None = None, -) -> dict: - """Create a calendar event. - - start/end are ISO date (YYYY-MM-DD) or datetime strings. - If all_day is True, DTSTART/DTEND use DATE values. - recurrence is an iCalendar RRULE string (e.g. "FREQ=YEARLY"). - """ - config = await get_caldav_config(user_id) - _check_config(config) - - tz = timezone or config.get("caldav_timezone") or None - - cal = icalendar.Calendar() - cal.add("prodid", "-//Scribe//EN") - cal.add("version", "2.0") - - event = icalendar.Event() - if uid: - # Remove auto-generated UID if the library added one, then inject ours - if "UID" in event: - del event["UID"] - event.add("uid", uid) - event.add("summary", title) - - if all_day: - # All-day events use DATE values (no time component) - d_start = datetime.fromisoformat(start).date() if "T" in start else date_type.fromisoformat(start) - if end: - d_end = datetime.fromisoformat(end).date() if "T" in end else date_type.fromisoformat(end) - else: - d_end = d_start + timedelta(days=1) - event.add("dtstart", d_start) - event.add("dtend", d_end) - result_start = d_start.isoformat() - result_end = d_end.isoformat() - else: - dt_start = _apply_timezone(datetime.fromisoformat(start), tz) - event.add("dtstart", dt_start) - result_start = dt_start.isoformat() - if end: - dt_end = _apply_timezone(datetime.fromisoformat(end), tz) - elif duration: - dt_end = dt_start + timedelta(minutes=duration) - else: - dt_end = None - if dt_end is not None: - event.add("dtend", dt_end) - result_end = dt_end.isoformat() - else: - # Point event (no end, no duration): emit DTSTART only. Fabricating - # a 60-min DTEND here would round-trip back on the next pull as - # duration_minutes=60, silently lengthening a point event. - result_end = None - - if description: - event.add("description", description) - if location: - event.add("location", location) - if recurrence: - # Parse RRULE string like "FREQ=YEARLY" into a vRecur dict - rrule_parts = {} - for part in recurrence.split(";"): - if "=" in part: - key, value = part.split("=", 1) - rrule_parts[key.strip().lower()] = value.strip() - event.add("rrule", rrule_parts) - if reminder_minutes is not None: - event.add_component(_build_valarm(reminder_minutes)) - if attendees: - _add_attendees(event, attendees) - - cal.add_component(event) - - ical_str = cal.to_ical().decode("utf-8") - - def _save(): - client = _make_client(config) - cal_name = calendar_name or config.get("caldav_calendar_name", "") - calendar = _get_calendar(client, cal_name) - calendar.save_event(ical_str) - - await asyncio.to_thread(_save) - - result = { - "title": title, - "start": result_start, - "end": result_end, - "all_day": all_day, - } - if recurrence: - result["recurrence"] = recurrence - return result - - -async def list_events(user_id: int, date_from: str, date_to: str) -> list[dict]: - """List calendar events in a date range. Dates are ISO datetime strings. - - Searches all calendars unless caldav_calendar_name is configured. - """ - config = await get_caldav_config(user_id) - _check_config(config) - - dt_from = datetime.fromisoformat(date_from) - dt_to = datetime.fromisoformat(date_to) - - def _search(): - client = _make_client(config) - cal_name = config.get("caldav_calendar_name", "") - if cal_name: - calendars = [_get_calendar(client, cal_name)] - else: - calendars = _get_all_calendars(client) - all_results = [] - for calendar in calendars: - try: - all_results.extend(calendar.date_search(dt_from, dt_to)) - except Exception: - logger.warning("Failed to search calendar '%s'", getattr(calendar, 'name', '?')) - return all_results - - results = await asyncio.to_thread(_search) - - events = [] - for result in results: - cal = icalendar.Calendar.from_ical(result.data) - for component in cal.walk(): - parsed = _parse_vevent(component) - if parsed: - events.append(parsed) - return events - - -async def search_events(user_id: int, query: str, days_ahead: int = 90) -> list[dict]: - """Search events by keyword in the next N days.""" - now = datetime.now() - date_from = now.isoformat() - date_to = (now + timedelta(days=days_ahead)).isoformat() - - all_events = await list_events(user_id, date_from, date_to) - q = query.lower() - return [ - e for e in all_events - if q in e["title"].lower() or q in e.get("location", "").lower() or q in e.get("description", "").lower() - ] - - -async def update_event( - user_id: int, - query: str, - title: str | None = None, - start: str | None = None, - end: str | None = None, - description: str | None = None, - location: str | None = None, - timezone: str | None = None, - calendar_name: str | None = None, - recurrence: str | None | object = _RECURRENCE_UNSET, -) -> dict: - """Update a calendar event matching the query. - - ``recurrence``: leave at the sentinel to keep the existing RRULE; pass an - RRULE string to set it, or None/"" to remove it. The push path passes the - local event's recurrence so RRULE edits propagate to the server. - """ - config = await get_caldav_config(user_id) - _check_config(config) - tz = timezone or config.get("caldav_timezone") or None - - def _do_update(): - client = _make_client(config) - cal_name = calendar_name or config.get("caldav_calendar_name", "") - now = datetime.now() - if cal_name: - calendars = [_get_calendar(client, cal_name)] - else: - calendars = _get_all_calendars(client) - results = [] - for cal in calendars: - try: - results.extend(cal.date_search(now - timedelta(days=30), now + timedelta(days=365))) - except Exception: - logger.warning("Failed to search calendar '%s'", getattr(cal, 'name', '?')) - - q = query.lower() - matches = [] - for r in results: - cal_obj = icalendar.Calendar.from_ical(r.data) - for component in cal_obj.walk(): - if component.name == "VEVENT": - event_title = str(component.get("SUMMARY", "")) - if q in event_title.lower(): - matches.append((r, component)) - - if not matches: - raise ValueError(f"No event found matching '{query}'.") - if len(matches) > 3: - titles = [str(m[1].get("SUMMARY", "")) for m in matches] - raise ValueError(f"Too many matches ({len(matches)}) for '{query}'. Be more specific. Found: {', '.join(titles[:10])}") - - event_obj, component = matches[0] - if title: - component["SUMMARY"] = title - if start: - dt_start = _apply_timezone(datetime.fromisoformat(start), tz) - del component["DTSTART"] - component.add("dtstart", dt_start) - if end: - dt_end = _apply_timezone(datetime.fromisoformat(end), tz) - if "DTEND" in component: - del component["DTEND"] - component.add("dtend", dt_end) - if description is not None: - if "DESCRIPTION" in component: - del component["DESCRIPTION"] - component.add("description", description) - if location is not None: - if "LOCATION" in component: - del component["LOCATION"] - component.add("location", location) - if recurrence is not _RECURRENCE_UNSET: - # Authoritatively sync the RRULE to the local event: drop the old - # rule, then re-add if a non-empty rule was provided (else clear it). - if "RRULE" in component: - del component["RRULE"] - if recurrence: - rrule_parts = {} - for part in str(recurrence).split(";"): - if "=" in part: - key, value = part.split("=", 1) - rrule_parts[key.strip().lower()] = value.strip() - component.add("rrule", rrule_parts) - - # Rebuild ical data and save - cal_data = icalendar.Calendar() - cal_data.add("prodid", "-//Scribe//EN") - cal_data.add("version", "2.0") - cal_data.add_component(component) - event_obj.data = cal_data.to_ical().decode("utf-8") - event_obj.save() - - return _parse_vevent(component) - - return await asyncio.to_thread(_do_update) - - -async def delete_event( - user_id: int, - query: str, - calendar_name: str | None = None, -) -> dict: - """Delete a calendar event matching the query.""" - config = await get_caldav_config(user_id) - _check_config(config) - - def _do_delete(): - client = _make_client(config) - cal_name = calendar_name or config.get("caldav_calendar_name", "") - now = datetime.now() - if cal_name: - calendars = [_get_calendar(client, cal_name)] - else: - calendars = _get_all_calendars(client) - results = [] - for cal in calendars: - try: - results.extend(cal.date_search(now - timedelta(days=30), now + timedelta(days=365))) - except Exception: - logger.warning("Failed to search calendar '%s'", getattr(cal, 'name', '?')) - - q = query.lower() - matches = [] - for r in results: - cal_obj = icalendar.Calendar.from_ical(r.data) - for component in cal_obj.walk(): - if component.name == "VEVENT": - event_title = str(component.get("SUMMARY", "")) - if q in event_title.lower(): - matches.append((r, component)) - - if not matches: - raise ValueError(f"No event found matching '{query}'.") - if len(matches) > 3: - titles = [str(m[1].get("SUMMARY", "")) for m in matches] - raise ValueError(f"Too many matches ({len(matches)}) for '{query}'. Be more specific. Found: {', '.join(titles[:10])}") - - event_obj, component = matches[0] - parsed = _parse_vevent(component) - event_obj.delete() - return parsed - - return await asyncio.to_thread(_do_delete) - - -async def list_calendars(user_id: int) -> list[dict]: - """List all calendars for the user.""" - config = await get_caldav_config(user_id) - _check_config(config) - - def _list(): - client = _make_client(config) - principal = client.principal() - calendars = principal.calendars() - return [{"name": c.name, "url": str(c.url)} for c in calendars] - - return await asyncio.to_thread(_list) - - -async def create_todo( - user_id: int, - summary: str, - due: str | None = None, - description: str | None = None, - priority: int | None = None, - reminder_minutes: int | None = None, - timezone: str | None = None, - calendar_name: str | None = None, -) -> dict: - """Create a CalDAV todo (VTODO).""" - config = await get_caldav_config(user_id) - _check_config(config) - tz = timezone or config.get("caldav_timezone") or None - - def _create(): - client = _make_client(config) - cal_name = calendar_name or config.get("caldav_calendar_name", "") - calendar = _get_calendar(client, cal_name) - - kwargs = {"summary": summary} - if due: - dt_due = datetime.fromisoformat(due) - dt_due = _apply_timezone(dt_due, tz) - kwargs["due"] = dt_due - - todo = calendar.save_todo(**kwargs) - - # Modify component for extra fields - cal_obj = icalendar.Calendar.from_ical(todo.data) - modified = False - for component in cal_obj.walk(): - if component.name == "VTODO": - if description: - component.add("description", description) - modified = True - if priority is not None: - component.add("priority", priority) - modified = True - if reminder_minutes is not None: - component.add_component(_build_valarm(reminder_minutes)) - modified = True - if modified: - todo.data = cal_obj.to_ical().decode("utf-8") - todo.save() - return _parse_vtodo(component) - - return {"summary": summary} - - return await asyncio.to_thread(_create) - - -async def list_todos( - user_id: int, - include_completed: bool = False, - calendar_name: str | None = None, -) -> list[dict]: - """List CalDAV todos.""" - config = await get_caldav_config(user_id) - _check_config(config) - - def _list(): - client = _make_client(config) - cal_name = calendar_name or config.get("caldav_calendar_name", "") - calendar = _get_calendar(client, cal_name) - todos = calendar.todos(include_completed=include_completed) - results = [] - for t in todos: - cal_obj = icalendar.Calendar.from_ical(t.data) - for component in cal_obj.walk(): - parsed = _parse_vtodo(component) - if parsed: - results.append(parsed) - return results - - return await asyncio.to_thread(_list) - - -async def search_todos( - user_id: int, - query: str, - include_completed: bool = False, - calendar_name: str | None = None, -) -> list[dict]: - """Search CalDAV todos by keyword in summary or description.""" - todos = await list_todos(user_id, include_completed=include_completed, calendar_name=calendar_name) - q = query.lower() - return [ - t for t in todos - if q in t.get("summary", "").lower() or q in (t.get("description") or "").lower() - ] - - -async def complete_todo( - user_id: int, - query: str, - calendar_name: str | None = None, -) -> dict: - """Complete a CalDAV todo matching the query.""" - config = await get_caldav_config(user_id) - _check_config(config) - - def _complete(): - client = _make_client(config) - cal_name = calendar_name or config.get("caldav_calendar_name", "") - calendar = _get_calendar(client, cal_name) - todos = calendar.todos(include_completed=False) - - q = query.lower() - matches = [] - for t in todos: - cal_obj = icalendar.Calendar.from_ical(t.data) - for component in cal_obj.walk(): - if component.name == "VTODO": - s = str(component.get("SUMMARY", "")) - if q in s.lower(): - matches.append((t, component)) - - if not matches: - raise ValueError(f"No todo found matching '{query}'.") - if len(matches) > 3: - titles = [str(m[1].get("SUMMARY", "")) for m in matches] - raise ValueError(f"Too many matches ({len(matches)}) for '{query}'. Be more specific. Found: {', '.join(titles[:10])}") - - todo_obj, component = matches[0] - todo_obj.complete() - - # Re-parse after completing - cal_obj = icalendar.Calendar.from_ical(todo_obj.data) - for comp in cal_obj.walk(): - parsed = _parse_vtodo(comp) - if parsed: - return parsed - return {"summary": str(component.get("SUMMARY", "")), "status": "COMPLETED"} - - return await asyncio.to_thread(_complete) - - -async def update_todo( - user_id: int, - query: str, - summary: str | None = None, - due: str | None = None, - description: str | None = None, - priority: int | None = None, - timezone: str | None = None, - calendar_name: str | None = None, -) -> dict: - """Update a CalDAV todo matching the query.""" - config = await get_caldav_config(user_id) - _check_config(config) - tz = timezone or config.get("caldav_timezone") or None - - def _do_update(): - client = _make_client(config) - cal_name = calendar_name or config.get("caldav_calendar_name", "") - calendar = _get_calendar(client, cal_name) - todos = calendar.todos(include_completed=True) - - q = query.lower() - matches = [] - for t in todos: - cal_obj = icalendar.Calendar.from_ical(t.data) - for component in cal_obj.walk(): - if component.name == "VTODO": - s = str(component.get("SUMMARY", "")) - if q in s.lower(): - matches.append((t, component)) - - if not matches: - raise ValueError(f"No todo found matching '{query}'.") - if len(matches) > 3: - titles = [str(m[1].get("SUMMARY", "")) for m in matches] - raise ValueError( - f"Too many matches ({len(matches)}) for '{query}'. " - f"Be more specific. Found: {', '.join(titles[:10])}" - ) - - todo_obj, component = matches[0] - - if summary: - component["SUMMARY"] = summary - if description is not None: - if "DESCRIPTION" in component: - del component["DESCRIPTION"] - component.add("description", description) - if priority is not None: - if "PRIORITY" in component: - del component["PRIORITY"] - component.add("priority", priority) - if due: - if "DUE" in component: - del component["DUE"] - try: - dt = datetime.fromisoformat(due) - dt = _apply_timezone(dt, tz) - component.add("due", dt) - except ValueError: - component.add("due", date_type.fromisoformat(due)) - - # Rebuild ical data and save - cal_data = icalendar.Calendar() - cal_data.add("prodid", "-//Scribe//EN") - cal_data.add("version", "2.0") - cal_data.add_component(component) - todo_obj.data = cal_data.to_ical().decode("utf-8") - todo_obj.save() - - return _parse_vtodo(component) - - return await asyncio.to_thread(_do_update) - - -async def delete_todo( - user_id: int, - query: str, - calendar_name: str | None = None, -) -> dict: - """Delete a CalDAV todo matching the query.""" - config = await get_caldav_config(user_id) - _check_config(config) - - def _delete(): - client = _make_client(config) - cal_name = calendar_name or config.get("caldav_calendar_name", "") - calendar = _get_calendar(client, cal_name) - todos = calendar.todos(include_completed=True) - - q = query.lower() - matches = [] - for t in todos: - cal_obj = icalendar.Calendar.from_ical(t.data) - for component in cal_obj.walk(): - if component.name == "VTODO": - s = str(component.get("SUMMARY", "")) - if q in s.lower(): - matches.append((t, component)) - - if not matches: - raise ValueError(f"No todo found matching '{query}'.") - if len(matches) > 3: - titles = [str(m[1].get("SUMMARY", "")) for m in matches] - raise ValueError(f"Too many matches ({len(matches)}) for '{query}'. Be more specific. Found: {', '.join(titles[:10])}") - - todo_obj, component = matches[0] - parsed = _parse_vtodo(component) - todo_obj.delete() - return parsed - - return await asyncio.to_thread(_delete) - - -async def test_connection(user_id: int) -> dict: - """Test the CalDAV connection and return status.""" - config = await get_caldav_config(user_id) - if not config.get("caldav_url"): - return {"success": False, "error": "CalDAV is not configured."} - - def _test(): - client = _make_client(config) - principal = client.principal() - calendars = principal.calendars() - return [c.name for c in calendars] - - try: - calendar_names = await asyncio.to_thread(_test) - return { - "success": True, - "calendars": calendar_names, - "message": f"Connected successfully. Found {len(calendar_names)} calendar(s).", - } - except Exception as e: - error_msg = str(e) - if "401" in error_msg or "403" in error_msg or "Unauthorized" in error_msg: - error_msg = "Authentication failed. Check your username and password." - elif "404" in error_msg or "Not Found" in error_msg: - error_msg = "CalDAV endpoint not found. Check your URL." - elif "Connection" in error_msg or "resolve" in error_msg: - error_msg = f"Connection failed: {error_msg}" - return {"success": False, "error": error_msg} diff --git a/src/scribe/services/caldav_sync.py b/src/scribe/services/caldav_sync.py deleted file mode 100644 index faa0072..0000000 --- a/src/scribe/services/caldav_sync.py +++ /dev/null @@ -1,247 +0,0 @@ -"""CalDAV pull sync — imports remote events into the internal event store. - -Runs as a scheduled job (hourly) and is also callable via the API. -Only syncs events in a rolling 30-day-past / 180-day-future window. -""" -from __future__ import annotations - -import asyncio -import logging -import uuid -from datetime import datetime, timedelta, timezone -from typing import Any - -from sqlalchemy import select, update - -from scribe.models import async_session -from scribe.models.event import Event - -logger = logging.getLogger(__name__) - -_SYNC_PAST_DAYS = 30 -_SYNC_FUTURE_DAYS = 180 -# Wall-clock cap on the blocking CalDAV fetch so a hung/slow server can't -# wedge the hourly sweep indefinitely. -_SYNC_TIMEOUT_SECONDS = 120 - - -def _parse_dt(val: Any) -> datetime | None: - """Convert a date or datetime from an iCal component to a UTC-aware datetime.""" - if val is None: - return None - import datetime as _dt_mod - if isinstance(val, _dt_mod.datetime): - if val.tzinfo is None: - return val.replace(tzinfo=timezone.utc) - return val.astimezone(timezone.utc) - if isinstance(val, _dt_mod.date): - # All-day date: treat as midnight UTC - return datetime(val.year, val.month, val.day, tzinfo=timezone.utc) - return None - - -def _sync_one_user(config: dict[str, str], user_id: int) -> list[dict]: - """Synchronous CalDAV fetch — runs in a thread executor.""" - import caldav # noqa: PLC0415 - - now = datetime.now(timezone.utc) - range_start = now - timedelta(days=_SYNC_PAST_DAYS) - range_end = now + timedelta(days=_SYNC_FUTURE_DAYS) - - client = caldav.DAVClient( - url=config["caldav_url"], - username=config.get("caldav_username") or None, - password=config.get("caldav_password") or None, - ) - principal = client.principal() - calendars = principal.calendars() - if not calendars: - return [] - - cal_name = config.get("caldav_calendar_name", "") - if cal_name: - calendars = [c for c in calendars if c.name == cal_name] or calendars - - events: list[dict] = [] - for calendar in calendars: - try: - results = calendar.date_search(start=range_start, end=range_end, expand=False) - except Exception: - logger.warning("CalDAV date_search failed for calendar %s", getattr(calendar, "name", "?"), exc_info=True) - continue - for vevent_obj in results: - try: - ical = vevent_obj.icalendar_instance - for component in ical.walk(): - if component.name != "VEVENT": - continue - dtstart = component.get("DTSTART") - dtend = component.get("DTEND") - uid = str(component.get("UID", "")) - if not uid: - continue - start_dt = _parse_dt(dtstart.dt if dtstart else None) - end_dt = _parse_dt(dtend.dt if dtend else None) - if start_dt is None: - continue - - import datetime as _dt_mod - all_day = dtstart and isinstance(dtstart.dt, _dt_mod.date) and not isinstance(dtstart.dt, _dt_mod.datetime) - - rrule = component.get("RRULE") - recurrence = rrule.to_ical().decode("utf-8") if rrule else None - - events.append({ - "caldav_uid": uid, - "title": str(component.get("SUMMARY", "")), - "start_dt": start_dt, - "end_dt": end_dt, - "all_day": bool(all_day), - "description": str(component.get("DESCRIPTION", "")), - "location": str(component.get("LOCATION", "")), - "recurrence": recurrence, - }) - except Exception: - logger.debug("Failed to parse CalDAV event", exc_info=True) - - return events - - -async def sync_user_events(user_id: int) -> dict: - """Pull CalDAV events for one user and upsert into the DB. - - Returns a summary dict: {created, updated, unchanged}. - """ - from scribe.services.caldav import get_caldav_config, is_caldav_configured # noqa: PLC0415 - - if not await is_caldav_configured(user_id): - return {"skipped": True, "reason": "CalDAV not configured"} - - config = await get_caldav_config(user_id) - - started = datetime.now(timezone.utc) - range_start = started - timedelta(days=_SYNC_PAST_DAYS) - range_end = started + timedelta(days=_SYNC_FUTURE_DAYS) - - loop = asyncio.get_running_loop() - try: - remote_events: list[dict] = await asyncio.wait_for( - loop.run_in_executor(None, _sync_one_user, config, user_id), - timeout=_SYNC_TIMEOUT_SECONDS, - ) - except asyncio.TimeoutError: - logger.warning("CalDAV pull sync timed out for user %d after %ds", user_id, _SYNC_TIMEOUT_SECONDS) - return {"error": "CalDAV fetch timed out"} - except Exception: - logger.warning("CalDAV pull sync failed for user %d", user_id, exc_info=True) - return {"error": "CalDAV fetch failed"} - - created = updated = unchanged = skipped = deleted = 0 - - async with async_session() as session: - for ev in remote_events: - caldav_uid = ev["caldav_uid"] - # Storage uses duration, not end_dt. Convert here so the - # rest of this function can compare/upsert in one shape. - ev_start = ev["start_dt"] - ev_end = ev["end_dt"] - ev_duration = ( - int((ev_end - ev_start).total_seconds() // 60) - if ev_end is not None and ev_start is not None and ev_end > ev_start - else None - ) - ev["duration_minutes"] = ev_duration - - result = await session.execute( - select(Event).where( - Event.user_id == user_id, - Event.caldav_uid == caldav_uid, - ) - ) - existing = result.scalar_one_or_none() - - if existing is not None and existing.deleted_at is not None: - # The user trashed this event locally. Don't resurrect it by - # updating, and don't create a duplicate live copy — leave it - # in the trash. (Propagating the delete to the remote server is - # tracked separately.) - skipped += 1 - continue - - if existing is None: - # Create new event - new_ev = Event( - user_id=user_id, - uid=str(uuid.uuid4()), - caldav_uid=caldav_uid, - title=ev["title"], - start_dt=ev_start, - duration_minutes=ev_duration, - all_day=ev["all_day"], - description=ev["description"], - location=ev["location"], - recurrence=ev["recurrence"], - ) - session.add(new_ev) - created += 1 - else: - # Update if anything changed - changed = False - for field in ("title", "start_dt", "duration_minutes", "all_day", "description", "location", "recurrence"): - if getattr(existing, field) != ev[field]: - setattr(existing, field, ev[field]) - changed = True - if changed: - updated += 1 - else: - unchanged += 1 - - # Reconcile deletions: a previously-synced event (has a caldav_uid) - # that no longer appears remotely within the synced window is - # soft-deleted, so a delete on the remote propagates locally instead - # of orphaning forever. Guarded on a non-empty fetch so a spurious - # empty result can't wipe every local copy. - if remote_events: - remote_uids = {e["caldav_uid"] for e in remote_events} - orphan_batch = str(uuid.uuid4()) - orphan_res = await session.execute( - update(Event) - .where( - Event.user_id == user_id, - Event.caldav_uid.isnot(None), - Event.caldav_uid.notin_(remote_uids), - Event.deleted_at.is_(None), - Event.start_dt >= range_start, - Event.start_dt <= range_end, - ) - .values(deleted_at=datetime.now(timezone.utc), deleted_batch_id=orphan_batch) - ) - deleted = orphan_res.rowcount or 0 - - await session.commit() - - elapsed = (datetime.now(timezone.utc) - started).total_seconds() - logger.info( - "CalDAV sync user %d: %d created, %d updated, %d unchanged, %d skipped (trashed), " - "%d deleted (orphaned) in %.1fs", - user_id, created, updated, unchanged, skipped, deleted, elapsed, - ) - return {"created": created, "updated": updated, "unchanged": unchanged, - "skipped": skipped, "deleted": deleted} - - -async def sync_all_users() -> None: - """Pull CalDAV events for all users with CalDAV configured.""" - from sqlalchemy import select as sa_select # noqa: PLC0415 - - from scribe.models.user import User # noqa: PLC0415 - - async with async_session() as session: - result = await session.execute(sa_select(User.id)) - user_ids = [row[0] for row in result.all()] - - for user_id in user_ids: - try: - await sync_user_events(user_id) - except Exception: - logger.warning("CalDAV sync failed for user %d", user_id, exc_info=True) diff --git a/src/scribe/services/dashboard.py b/src/scribe/services/dashboard.py index 99d16e5..77c2d6f 100644 --- a/src/scribe/services/dashboard.py +++ b/src/scribe/services/dashboard.py @@ -1,7 +1,7 @@ """Dashboard aggregation — assembles the /dashboard landing payload. One call: most-recently-active projects (each -> active milestones -> open -tasks), recently-completed tasks, upcoming events, week stats. Owner-scoped, +tasks), recently-completed tasks, week stats. Owner-scoped, trashed rows excluded. Each section is independent — a failure returns its empty value rather than blanking the page. """ @@ -23,7 +23,7 @@ N_PROJECTS = 3 # most-recently-active projects shown TASKS_PER_GROUP = 5 # open-task cap per milestone / no-milestone group RECENT_DONE_LIMIT = 8 # recently-completed tasks shown OPEN_ISSUES_LIMIT = 10 # open issues shown on the dashboard -WINDOW_DAYS = 7 # look-back (done) / look-ahead (events) window +WINDOW_DAYS = 7 # look-back window (done items, week stats) _OPEN = ["todo", "in_progress"] @@ -84,7 +84,6 @@ async def build_dashboard(user_id: int) -> dict: return { "active_projects": await _safe(_active_projects(user_id), []), "recently_completed": await _safe(_recently_completed(user_id), []), - "upcoming_events": await _safe(_upcoming_events(user_id), []), "open_issues": await _safe(_open_issues(user_id), []), "week_stats": await _safe(_week_stats(user_id), {}), } @@ -177,18 +176,6 @@ async def _recently_completed(user_id: int) -> list[dict]: "completed_at": n.completed_at.isoformat()} for n, ptitle in rows] -async def _upcoming_events(user_id: int) -> list[dict]: - from scribe.services import events as events_svc - now = datetime.now(timezone.utc) - rows = await events_svc.list_events(user_id, now, now + timedelta(days=WINDOW_DAYS)) - out = [] - for e in rows: - d = e if isinstance(e, dict) else e.to_dict() - out.append({"id": d["id"], "title": d["title"], - "start_dt": d.get("start_dt"), "all_day": d.get("all_day", False)}) - return out - - async def _week_stats(user_id: int) -> dict: cutoff = datetime.now(timezone.utc) - timedelta(days=WINDOW_DAYS) async with async_session() as session: diff --git a/src/scribe/services/event_scheduler.py b/src/scribe/services/event_scheduler.py deleted file mode 100644 index 33c6864..0000000 --- a/src/scribe/services/event_scheduler.py +++ /dev/null @@ -1,198 +0,0 @@ -"""Scheduler jobs for background maintenance tasks. - -- Reminder notifications: checks every 5 minutes for due event reminders and - delivers them to the in-app notification feed. -- CalDAV pull sync: runs every hour for all users with CalDAV configured. -- Recurring-task spawn: every 15 minutes, creates the next occurrence of any - recurring task whose spawn time has arrived. - -Uses the BackgroundScheduler pattern shared with the other *_scheduler modules. -""" -from __future__ import annotations - -import asyncio -import logging -from datetime import datetime, timedelta, timezone - -from apscheduler.schedulers.background import BackgroundScheduler -from apscheduler.triggers.interval import IntervalTrigger -from dateutil.rrule import rrulestr -from sqlalchemy import and_, or_, select - -from scribe.models import async_session -from scribe.models.event import Event - -logger = logging.getLogger(__name__) - -_scheduler: BackgroundScheduler | None = None -_loop: asyncio.AbstractEventLoop | None = None - - -# --------------------------------------------------------------------------- -# Reminder job -# --------------------------------------------------------------------------- - -async def _fire_reminders() -> None: - """Fire in-app reminders for events whose reminder time has arrived. - - One-shot events fire once (gated on reminder_sent_at IS NULL). Recurring - events fire once PER OCCURRENCE: reminder_sent_at stores the start of the - occurrence we last reminded about, so each new occurrence re-arms the - reminder instead of the whole series firing only once. - """ - now = datetime.now(timezone.utc) - window_end = now + timedelta(minutes=5) - - async with async_session() as session: - result = await session.execute( - select(Event).where( - Event.reminder_minutes.isnot(None), - Event.deleted_at.is_(None), - or_( - # Recurring events are evaluated every sweep against their - # next occurrence (the base start_dt is long past). - Event.recurrence.isnot(None), - # One-shot events: classic gate. - and_(Event.reminder_sent_at.is_(None), Event.start_dt > now), - ), - ) - ) - candidates = list(result.scalars().all()) - - # (event_id, occurrence_start) — occurrence_start is also the dedup marker - # written to reminder_sent_at, so a given occurrence reminds exactly once. - to_notify: list[tuple[int, datetime]] = [] - for event in candidates: - if event.recurrence: - try: - rule = rrulestr(event.recurrence, dtstart=event.start_dt, ignoretz=False) - occ = rule.after(now, inc=True) - except Exception: - logger.warning("Failed to expand RRULE for event %d reminder", event.id, exc_info=True) - continue - if occ is None: - continue - reminder_dt = occ - timedelta(minutes=event.reminder_minutes) - if reminder_dt <= window_end and event.reminder_sent_at != occ: - to_notify.append((event.id, occ)) - else: - reminder_dt = event.start_dt - timedelta(minutes=event.reminder_minutes) - if reminder_dt <= window_end: - to_notify.append((event.id, event.start_dt)) - - if not to_notify: - return - - # Deliver via the in-app notification feed (push was removed in Phase 8). - from scribe.services.notifications import create_in_app_notification - - async with async_session() as session: - for event_id, occurrence_start in to_notify: - ev = (await session.execute( - select(Event).where(Event.id == event_id) - )).scalar_one_or_none() - # Skip if this exact occurrence was already reminded (covers a - # concurrent sweep and the one-shot already-sent case). - if ev is None or ev.reminder_sent_at == occurrence_start: - continue - await create_in_app_notification(ev.user_id, "event_reminder", { - "event_id": ev.id, - "title": ev.title, - "start_dt": occurrence_start.isoformat(), - "url": "/calendar", - }) - # Stamp the occurrence marker only after the notification is - # created, so a delivery failure leaves it eligible to retry. - ev.reminder_sent_at = occurrence_start - await session.commit() - - -def _run_reminders(loop: asyncio.AbstractEventLoop) -> None: - asyncio.run_coroutine_threadsafe(_fire_reminders(), loop) - - -# --------------------------------------------------------------------------- -# CalDAV pull sync job -# --------------------------------------------------------------------------- - -async def _run_caldav_sync() -> None: - from scribe.services.caldav_sync import sync_all_users # noqa: PLC0415 - try: - await sync_all_users() - except Exception: - logger.warning("CalDAV pull sync job failed", exc_info=True) - - -def _run_caldav_sync_threadsafe(loop: asyncio.AbstractEventLoop) -> None: - asyncio.run_coroutine_threadsafe(_run_caldav_sync(), loop) - - -# --------------------------------------------------------------------------- -# Recurring-task spawn job -# --------------------------------------------------------------------------- - -async def _run_recurrence_spawn() -> None: - from scribe.services.recurrence import spawn_recurring_tasks # noqa: PLC0415 - try: - await spawn_recurring_tasks() - except Exception: - logger.warning("Recurring-task spawn job failed", exc_info=True) - - -def _run_recurrence_spawn_threadsafe(loop: asyncio.AbstractEventLoop) -> None: - asyncio.run_coroutine_threadsafe(_run_recurrence_spawn(), loop) - - -# --------------------------------------------------------------------------- -# Lifecycle -# --------------------------------------------------------------------------- - -def start_event_scheduler(loop: asyncio.AbstractEventLoop) -> None: - global _scheduler, _loop - if _scheduler is not None: - return - _loop = loop - _scheduler = BackgroundScheduler() - - # Check reminders every 5 minutes - _scheduler.add_job( - _run_reminders, - trigger=IntervalTrigger(minutes=5), - args=[loop], - id="event_reminders", - replace_existing=True, - ) - - # CalDAV pull sync every hour - _scheduler.add_job( - _run_caldav_sync_threadsafe, - trigger=IntervalTrigger(hours=1), - args=[loop], - id="caldav_pull_sync", - replace_existing=True, - ) - - # Spawn the next occurrence of due recurring tasks every 15 minutes. - # Without this job, recurrence_next_spawn_at is armed on completion but - # never drained, so recurring tasks never recur. - _scheduler.add_job( - _run_recurrence_spawn_threadsafe, - trigger=IntervalTrigger(minutes=15), - args=[loop], - id="recurrence_spawn", - replace_existing=True, - ) - - _scheduler.start() - logger.info( - "Event scheduler started (reminders every 5m, CalDAV sync every 1h, " - "recurring-task spawn every 15m)" - ) - - -def stop_event_scheduler() -> None: - global _scheduler - if _scheduler is not None: - _scheduler.shutdown(wait=False) - _scheduler = None - logger.info("Event scheduler stopped") diff --git a/src/scribe/services/events.py b/src/scribe/services/events.py deleted file mode 100644 index ad2203d..0000000 --- a/src/scribe/services/events.py +++ /dev/null @@ -1,477 +0,0 @@ -"""Internal event store service with CalDAV push sync. - -Storage model: an event is anchored at ``start_dt`` and has an optional -``duration_minutes``. The end of the event is *derived* via -``Event.end_dt`` (a Python property), never stored. Callers may still -pass ``end_dt`` on writes for ergonomic compatibility — the service -converts to ``duration_minutes`` internally. This rules out the entire -"end before start" bug class structurally (Fable #160 / migration -0043). Open-ended events use ``duration_minutes = None``. -""" -from __future__ import annotations - -import asyncio -import logging -import uuid -from datetime import datetime, timedelta, timezone - -from dateutil.rrule import rrulestr -from sqlalchemy import or_, select - -from scribe.models import async_session -from scribe.models.event import Event - -logger = logging.getLogger(__name__) - - -def _normalize_duration( - *, - start_dt: datetime, - end_dt: datetime | None, - duration_minutes: int | None, -) -> int | None: - """Reduce (end_dt, duration_minutes) inputs to a single canonical - ``duration_minutes`` value. - - Resolution order: - 1. If ``duration_minutes`` is explicit, use it (validate >= 0). - If ``end_dt`` is also given, validate the two agree. - 2. Otherwise, derive from ``end_dt - start_dt``. - 3. Otherwise None (point event with no end). - - Raises ``ValueError`` for any invalid combination — duration < 0, - end_dt < start_dt, or end_dt and duration_minutes inconsistent. - """ - if duration_minutes is not None: - if duration_minutes < 0: - raise ValueError( - f"duration_minutes must be >= 0, got {duration_minutes}" - ) - if end_dt is not None: - expected = int((end_dt - start_dt).total_seconds() // 60) - if expected != duration_minutes: - raise ValueError( - f"end_dt ({end_dt.isoformat()}) implies " - f"{expected} minutes but duration_minutes={duration_minutes} " - f"was passed; pass only one or make them agree." - ) - return duration_minutes - if end_dt is not None: - delta_seconds = (end_dt - start_dt).total_seconds() - if delta_seconds < 0: - raise ValueError( - f"end_dt ({end_dt.isoformat()}) must be at or after " - f"start_dt ({start_dt.isoformat()}); pass end_dt=None " - f"or omit it for point events." - ) - return int(delta_seconds // 60) - return None - - -async def _localize_naive(user_id: int, dt: datetime | None) -> datetime | None: - """Anchor a naive datetime in the user's timezone; pass tz-aware through. - - Naive datetimes are the user's local wall-clock time (the MCP create/update - tools combine date+time without a zone). Attaching the user's tzinfo lets - asyncpg store the correct UTC instant, matching the REST/UI path. - """ - if dt is not None and dt.tzinfo is None: - from scribe.services.tz import get_user_tz # noqa: PLC0415 - return dt.replace(tzinfo=await get_user_tz(user_id)) - return dt - - -async def create_event( - user_id: int, - title: str, - start_dt: datetime, - end_dt: datetime | None = None, - duration_minutes: int | None = None, - all_day: bool = False, - description: str = "", - location: str = "", - color: str = "", - recurrence: str | None = None, - project_id: int | None = None, - reminder_minutes: int | None = None, - # ``duration`` is a legacy alias kept for the calendar tool layer - # and CalDAV pass-through callers; promotes to duration_minutes - # when duration_minutes isn't otherwise specified. - duration: int | None = None, - attendees: list[str] | None = None, - calendar_name: str | None = None, -) -> Event: - """Create an event in the DB, then fire a CalDAV push task. - - Either ``end_dt`` or ``duration_minutes`` may be supplied; the - service converts to ``duration_minutes`` internally. Raises - ``ValueError`` on invalid combinations (negative duration, end - before start, end/duration disagreement). - """ - if duration is not None and duration_minutes is None: - duration_minutes = duration - # Canonical localization point: a naive datetime (e.g. from the MCP tool's - # date+time split) is the user's wall-clock time, so anchor it in their - # timezone before storage. tz-aware inputs (REST, CalDAV pass-through) are - # left untouched. Without this, MCP-created events landed at the same - # wall-clock numerals in UTC and drifted from UI-created ones by the offset. - start_dt = await _localize_naive(user_id, start_dt) - end_dt = await _localize_naive(user_id, end_dt) - duration_minutes = _normalize_duration( - start_dt=start_dt, end_dt=end_dt, duration_minutes=duration_minutes, - ) - uid = str(uuid.uuid4()) - async with async_session() as session: - event = Event( - user_id=user_id, - uid=uid, - title=title, - start_dt=start_dt, - duration_minutes=duration_minutes, - all_day=all_day, - description=description, - location=location, - color=color, - recurrence=recurrence, - project_id=project_id, - reminder_minutes=reminder_minutes, - ) - session.add(event) - await session.commit() - await session.refresh(event) - - extra_fields = { - "duration": duration_minutes, - "reminder_minutes": reminder_minutes, - "attendees": attendees, - "calendar_name": calendar_name, - } - asyncio.create_task(_push_create(event, user_id, extra_fields)) - return event - - -async def get_event(user_id: int, event_id: int) -> Event | None: - """Return event owned by user_id, or None.""" - async with async_session() as session: - result = await session.execute( - select(Event).where( - Event.id == event_id, Event.user_id == user_id, Event.deleted_at.is_(None) - ) - ) - return result.scalar_one_or_none() - - -async def list_events( - user_id: int, - date_from: datetime, - date_to: datetime, -) -> list[dict]: - """List events for user_id that overlap [date_from, date_to]. - - Recurring events (with an RRULE recurrence string) are expanded into - individual occurrences within the range. Non-recurring events are - returned as-is. All results are sorted by start time and returned as - dicts (same shape as ``Event.to_dict()``). - - Filtering strategy: a coarse SQL prefilter (events that start on or - before ``date_to``), then refine in Python using the event's derived - end (``start_dt + duration_minutes``). Doing the end-of-event math - in SQL would require Postgres-specific interval arithmetic; the - Python-side refinement is a few row-loops over a small per-user - result set, which is fine for personal-scale data and avoids - coupling the query to a specific dialect. - """ - async with async_session() as session: - result = await session.execute( - select(Event) - .where( - Event.user_id == user_id, - Event.deleted_at.is_(None), - or_( - Event.recurrence.isnot(None), - Event.start_dt <= date_to, - ), - ) - .order_by(Event.start_dt) - ) - events = list(result.scalars().all()) - - items: list[dict] = [] - for event in events: - if event.recurrence: - duration = ( - timedelta(minutes=event.duration_minutes) - if event.duration_minutes is not None - else None - ) - try: - rule = rrulestr(event.recurrence, dtstart=event.start_dt, ignoretz=False) - occurrences = rule.between(date_from, date_to, inc=True) - except Exception: - logger.warning("Failed to expand RRULE for event %d: %r", event.id, event.recurrence) - # Fall back to canonical event row; still apply the - # window check so a far-future canonical row doesn't - # leak into today's list. - if date_from <= event.start_dt <= date_to: - items.append(event.to_dict()) - continue - - base = event.to_dict() - for occ in occurrences: - if occ.tzinfo is None: - occ = occ.replace(tzinfo=timezone.utc) - occurrence_dict = dict(base) - occurrence_dict["start_dt"] = occ.isoformat() - if duration is not None: - occurrence_dict["end_dt"] = (occ + duration).isoformat() - items.append(occurrence_dict) - continue - - # Non-recurring: refine the coarse prefilter in Python using the - # derived end_dt. A point event (duration None) is included when - # its start is at or after date_from. A timed event is included - # when its end is at or after date_from. - derived_end = event.end_dt - if derived_end is None: - if event.start_dt >= date_from: - items.append(event.to_dict()) - else: - if derived_end >= date_from: - items.append(event.to_dict()) - - items.sort(key=lambda x: x["start_dt"]) - return items - - -async def search_events( - user_id: int, - query: str, - days_ahead: int = 90, - include_past: bool = False, -) -> list[Event]: - """Search events by keyword in title, description, or location.""" - now = datetime.now(timezone.utc) - q = f"%{query}%" - async with async_session() as session: - where = [ - Event.user_id == user_id, - Event.deleted_at.is_(None), - or_( - Event.title.ilike(q), - Event.description.ilike(q), - Event.location.ilike(q), - ), - ] - if not include_past: - date_to = now + timedelta(days=days_ahead) - where.extend([Event.start_dt >= now, Event.start_dt <= date_to]) - result = await session.execute( - select(Event).where(*where).order_by(Event.start_dt) - ) - return result.scalars().all() - - -async def update_event(user_id: int, event_id: int, **fields) -> Event | None: - """Partial update. Returns updated event or None if not found. - - Accepts ``end_dt`` or ``duration_minutes`` (or both, validated for - agreement). The service converts to ``duration_minutes`` before - persisting; ``end_dt`` is never stored. Raises ``ValueError`` for - invalid combinations against the post-update state. - """ - async with async_session() as session: - result = await session.execute( - select(Event).where( - Event.id == event_id, Event.user_id == user_id, - Event.deleted_at.is_(None), - ) - ) - event = result.scalar_one_or_none() - if event is None: - return None - old_title = event.title # capture before mutation for CalDAV lookup - - # Localize a naive start_dt patch to the user's timezone (same canonical - # rule as create_event) before it's used or persisted. - if fields.get("start_dt") is not None: - fields["start_dt"] = await _localize_naive(user_id, fields["start_dt"]) - - # Resolve any end_dt/duration_minutes inputs against the - # post-update start_dt. If neither is in the patch, leave the - # existing duration_minutes alone. - post_update_start = ( - fields["start_dt"] - if fields.get("start_dt") is not None - else event.start_dt - ) - if "end_dt" in fields or "duration_minutes" in fields: - new_end = fields.pop("end_dt", None) - new_duration = fields.pop("duration_minutes", None) - # If end_dt is in the patch but explicitly None, that's a - # clear → duration_minutes = None. Same shape duration_minutes=None. - if new_end is None and new_duration is None: - fields["duration_minutes"] = None - else: - fields["duration_minutes"] = _normalize_duration( - start_dt=post_update_start, - end_dt=new_end, - duration_minutes=new_duration, - ) - - allowed = { - "title", "start_dt", "duration_minutes", "all_day", - "description", "location", "color", "recurrence", - "project_id", "reminder_minutes", - } - # Nullable fields callers can explicitly clear by passing None - nullable = { - "duration_minutes", "recurrence", "project_id", - "reminder_minutes", - } - for key, value in fields.items(): - if key in allowed and (value is not None or key in nullable): - setattr(event, key, value) - # Re-arm the reminder when the timing changes, so an event moved to a - # new (future) time — or given a new lead time — fires again instead of - # being permanently suppressed by a stale reminder_sent_at. - if "start_dt" in fields or "reminder_minutes" in fields: - event.reminder_sent_at = None - await session.commit() - await session.refresh(event) - - asyncio.create_task(_push_update(event, user_id, old_title=old_title)) - return event - - -async def delete_event(user_id: int, event_id: int) -> None: - """Delete event. Fires CalDAV delete push if caldav_uid is set.""" - async with async_session() as session: - result = await session.execute( - select(Event).where(Event.id == event_id, Event.user_id == user_id) - ) - event = result.scalar_one_or_none() - if event is None: - return - caldav_uid = event.caldav_uid - event_title = event.title # needed to find the event on CalDAV by title - await session.delete(event) - await session.commit() - - if caldav_uid: - asyncio.create_task(_push_delete(caldav_uid, event_title, user_id)) - - -async def find_events_by_query(user_id: int, query: str) -> list[Event]: - """ILIKE search on title — used by AI update/delete tools. - - Returns upcoming events first (start_dt >= now), falling back to - past events so the AI operates on the most relevant match. - """ - q = f"%{query}%" - now = datetime.now(timezone.utc) - async with async_session() as session: - # Prefer events at or after now; fall back to past events - upcoming = (await session.execute( - select(Event).where( - Event.user_id == user_id, - Event.deleted_at.is_(None), - Event.title.ilike(q), - Event.start_dt >= now, - ).order_by(Event.start_dt) - )).scalars().all() - if upcoming: - return list(upcoming) - past = (await session.execute( - select(Event).where( - Event.user_id == user_id, - Event.deleted_at.is_(None), - Event.title.ilike(q), - Event.start_dt < now, - ).order_by(Event.start_dt.desc()) - )).scalars().all() - return list(past) - - -# --------------------------------------------------------------------------- -# CalDAV push helpers (fire-and-forget) -# --------------------------------------------------------------------------- - -async def _push_create(event: Event, user_id: int, extra: dict) -> None: - try: - from scribe.services.caldav import ( - create_event as caldav_create, - is_caldav_configured, - ) - if not await is_caldav_configured(user_id): - return - derived_end = event.end_dt # property: start + duration_minutes - await caldav_create( - user_id=user_id, - title=event.title, - start=event.start_dt.isoformat(), - end=derived_end.isoformat() if derived_end else None, - description=event.description or None, - location=event.location or None, - all_day=event.all_day, - recurrence=event.recurrence, - uid=event.uid, - duration=extra.get("duration"), - reminder_minutes=extra.get("reminder_minutes"), - attendees=extra.get("attendees"), - calendar_name=extra.get("calendar_name"), - ) - # Mark as synced - async with async_session() as session: - result = await session.execute( - select(Event).where(Event.id == event.id) - ) - ev = result.scalar_one_or_none() - if ev: - ev.caldav_uid = event.uid - await session.commit() - except Exception: - logger.warning("CalDAV push (create) failed for event %d", event.id, exc_info=True) - - -async def _push_update(event: Event, user_id: int, old_title: str = "") -> None: - """Push an update to CalDAV. Uses old_title to locate the event by its pre-rename SUMMARY.""" - if not event.caldav_uid: - return - try: - from scribe.services.caldav import ( - update_event as caldav_update, - is_caldav_configured, - ) - if not await is_caldav_configured(user_id): - return - # Use old_title so CalDAV can find the event even if the title was changed - query_title = old_title or event.title - derived_end = event.end_dt - await caldav_update( - user_id=user_id, - query=query_title, - title=event.title, - start=event.start_dt.isoformat(), - end=derived_end.isoformat() if derived_end else None, - description=event.description or None, - location=event.location or None, - # Propagate the (possibly cleared) RRULE so a local recurrence edit - # isn't overwritten by the stale remote rule on the next pull. - recurrence=event.recurrence, - ) - except Exception: - logger.warning("CalDAV push (update) failed for event %d", event.id, exc_info=True) - - -async def _push_delete(caldav_uid: str, event_title: str, user_id: int) -> None: - """Push a delete to CalDAV. Uses event_title to locate the event by SUMMARY.""" - try: - from scribe.services.caldav import ( - delete_event as caldav_delete, - is_caldav_configured, - ) - if not await is_caldav_configured(user_id): - return - await caldav_delete(user_id=user_id, query=event_title) - except Exception: - logger.warning("CalDAV push (delete) failed for uid %s", caldav_uid, exc_info=True) diff --git a/src/scribe/services/knowledge.py b/src/scribe/services/knowledge.py index 8c4c9d7..3775855 100644 --- a/src/scribe/services/knowledge.py +++ b/src/scribe/services/knowledge.py @@ -1,4 +1,4 @@ -"""Knowledge service — unified query across notes, people, places, and lists.""" +"""Knowledge service — unified query across notes, tasks, plans, and processes.""" import logging from sqlalchemy import func, select @@ -12,46 +12,16 @@ _SNIPPET_LEN = 200 def _note_to_item(note: Note) -> dict: - meta = note.entity_meta or {} item: dict = { "id": note.id, - "note_type": note.entity_type, + "note_type": note.note_type or "note", "title": note.title, "snippet": (note.body or "")[:_SNIPPET_LEN], "tags": note.tags or [], "project_id": note.project_id, - "metadata": meta, "created_at": note.created_at.isoformat(), "updated_at": note.updated_at.isoformat(), } - # Type-specific convenience fields - if note.entity_type == "person": - item["relationship"] = meta.get("relationship", "") - item["email"] = meta.get("email", "") - item["phone"] = meta.get("phone", "") - item["birthday"] = meta.get("birthday", "") - item["organization"] = meta.get("organization", "") - item["address"] = meta.get("address", "") - elif note.entity_type == "place": - item["address"] = meta.get("address", "") - item["phone"] = meta.get("phone", "") - item["hours"] = meta.get("hours", "") - item["website"] = meta.get("website", "") - item["category"] = meta.get("category", "") - elif note.entity_type == "list": - # Parse markdown task list syntax into structured items - body = note.body or "" - list_items = [] - for line in body.split("\n"): - stripped = line.strip() - if stripped.startswith("- [ ] ") or stripped.startswith("- [x] ") or stripped.startswith("- [X] "): - checked_item = not stripped.startswith("- [ ] ") - list_items.append({"text": stripped[6:], "checked": checked_item}) - item["list_items"] = list_items - item["item_count"] = len(list_items) - item["checked_count"] = sum(1 for i in list_items if i["checked"]) - item["body"] = body - # Task fields — override note_type and add status/priority/due_date if note.is_task: item["note_type"] = "task" @@ -190,7 +160,7 @@ async def _semantic_knowledge_search( continue elif note_type == "plan" and (not note.is_task or note.task_kind != "plan"): continue - elif note_type and note_type not in ("task", "plan") and note.entity_type != note_type: + elif note_type and note_type not in ("task", "plan") and note.note_type != note_type: continue if tags and not all(t in (note.tags or []) for t in tags): continue @@ -237,7 +207,7 @@ async def get_knowledge_counts(user_id: int, tags: list[str] | None = None) -> d .where(Note.user_id == user_id) .where(Note.status.is_(None)) .where(Note.deleted_at.is_(None)) - .where(Note.note_type.in_(["note", "person", "place", "list", "process"])) + .where(Note.note_type.in_(["note", "process"])) .group_by(Note.note_type) ) if tags: @@ -273,9 +243,9 @@ async def get_knowledge_counts(user_id: int, tags: list[str] | None = None) -> d plan_stmt = plan_stmt.where(Note.tags.contains([tag])) counts["plan"] = (await session.execute(plan_stmt)).scalar_one() - for t in ("note", "person", "place", "list", "task", "plan", "process"): + for t in ("note", "task", "plan", "process"): counts.setdefault(t, 0) - counts["total"] = sum(counts[t] for t in ("note", "person", "place", "list", "task", "process")) + counts["total"] = sum(counts[t] for t in ("note", "task", "process")) return counts @@ -335,42 +305,3 @@ async def get_knowledge_by_ids(user_id: int, ids: list[int]) -> list[dict]: rows = list((await session.execute(stmt)).scalars().all()) by_id = {n.id: n for n in rows} return [_note_to_item(by_id[i]) for i in ids if i in by_id] - - -async def get_people_and_places_context(user_id: int) -> str: - """Return a compact summary of known people and places for LLM system prompt injection.""" - async with async_session() as session: - stmt = ( - select(Note) - .where(Note.user_id == user_id) - .where(Note.note_type.in_(["person", "place"])) - .where(Note.status.is_(None)) - .where(Note.deleted_at.is_(None)) - .order_by(Note.title.asc()) - .limit(50) - ) - rows = list((await session.execute(stmt)).scalars().all()) - - if not rows: - return "" - - people = [n for n in rows if n.entity_type == "person"] - places = [n for n in rows if n.entity_type == "place"] - - lines = [] - if people: - parts = [] - for p in people: - meta = p.entity_meta or {} - rel = meta.get("relationship", "") - parts.append(f"{p.title}" + (f" ({rel})" if rel else "")) - lines.append("Known people: " + ", ".join(parts)) - if places: - parts = [] - for p in places: - meta = p.entity_meta or {} - addr = meta.get("address", "") - parts.append(f"{p.title}" + (f" – {addr}" if addr else "")) - lines.append("Known places: " + "; ".join(parts)) - - return "\n".join(lines) diff --git a/src/scribe/services/notes.py b/src/scribe/services/notes.py index ba652c9..be91c90 100644 --- a/src/scribe/services/notes.py +++ b/src/scribe/services/notes.py @@ -63,7 +63,6 @@ async def create_note( due_date: date | None = None, recurrence_rule: dict | None = None, note_type: str = "note", - entity_meta: dict | None = None, task_kind: str = "work", arose_from_id: int | None = None, ) -> Note: @@ -107,7 +106,6 @@ async def create_note( due_date=due_date, recurrence_rule=recurrence_rule, note_type=note_type, - entity_meta=entity_meta, task_kind=task_kind, arose_from_id=arose_from_id, ) diff --git a/src/scribe/services/recurrence_scheduler.py b/src/scribe/services/recurrence_scheduler.py new file mode 100644 index 0000000..082751a --- /dev/null +++ b/src/scribe/services/recurrence_scheduler.py @@ -0,0 +1,64 @@ +"""Scheduler for recurring-task spawning. + +Every 15 minutes, creates the next occurrence of any recurring task whose spawn +time has arrived — draining `recurrence_next_spawn_at`, which is armed on task +completion. Without this job, recurring tasks would never recur. + +Uses the BackgroundScheduler pattern shared with the other *_scheduler modules. +(Formerly event_scheduler.py, which also ran event reminders + CalDAV sync; +those were removed when the calendar surface was retired.) +""" +from __future__ import annotations + +import asyncio +import logging + +from apscheduler.schedulers.background import BackgroundScheduler +from apscheduler.triggers.interval import IntervalTrigger + +logger = logging.getLogger(__name__) + +_scheduler: BackgroundScheduler | None = None +_loop: asyncio.AbstractEventLoop | None = None + + +async def _run_recurrence_spawn() -> None: + from scribe.services.recurrence import spawn_recurring_tasks # noqa: PLC0415 + try: + await spawn_recurring_tasks() + except Exception: + logger.warning("Recurring-task spawn job failed", exc_info=True) + + +def _run_recurrence_spawn_threadsafe(loop: asyncio.AbstractEventLoop) -> None: + asyncio.run_coroutine_threadsafe(_run_recurrence_spawn(), loop) + + +def start_recurrence_scheduler(loop: asyncio.AbstractEventLoop) -> None: + global _scheduler, _loop + if _scheduler is not None: + return + _loop = loop + _scheduler = BackgroundScheduler() + + # Spawn the next occurrence of due recurring tasks every 15 minutes. + # Without this job, recurrence_next_spawn_at is armed on completion but + # never drained, so recurring tasks never recur. + _scheduler.add_job( + _run_recurrence_spawn_threadsafe, + trigger=IntervalTrigger(minutes=15), + args=[loop], + id="recurrence_spawn", + replace_existing=True, + ) + + _scheduler.start() + logger.info("Recurrence scheduler started (recurring-task spawn every 15m)") + + +def stop_recurrence_scheduler() -> None: + global _scheduler + if _scheduler is not None: + _scheduler.shutdown(wait=False) + _scheduler = None + logger.info("Recurrence scheduler stopped") diff --git a/src/scribe/services/trash.py b/src/scribe/services/trash.py index 0ad61fd..970e4f1 100644 --- a/src/scribe/services/trash.py +++ b/src/scribe/services/trash.py @@ -14,7 +14,6 @@ from sqlalchemy import or_, select, update from scribe.models import async_session from scribe.models.note import Note -from scribe.models.event import Event from scribe.models.project import Project from scribe.models.milestone import Milestone from scribe.models.rulebook import Rulebook, RulebookTopic, Rule @@ -23,7 +22,6 @@ from scribe.models.rulebook import Rulebook, RulebookTopic, Rule _MODEL_FOR = { "note": Note, "task": Note, - "event": Event, "project": Project, "milestone": Milestone, "rulebook": Rulebook, @@ -59,7 +57,7 @@ def _owner_clause(model, user_id: int): select(Project.id).where(Project.user_id == user_id) ), ) - # Note, Event, Project, Milestone all carry user_id directly. + # Note, Project, Milestone all carry user_id directly. return model.user_id == user_id @@ -121,8 +119,6 @@ async def _cascade(session, user_id: int, etype: str, eid: int, batch: str, now) frontier = [c for c in children if c not in ids] ids.extend(frontier) await _set(session, Note, [Note.user_id == user_id, Note.id.in_(ids)], batch, now) - elif etype == "event": - await _set(session, Event, [Event.user_id == user_id, Event.id == eid], batch, now) elif etype == "rulebook": topic_ids = (await session.execute( select(RulebookTopic.id) @@ -149,35 +145,18 @@ async def delete(user_id: int, entity_type: str, entity_id: int) -> str | None: """ batch = str(uuid.uuid4()) now = datetime.now(timezone.utc) - caldav_event: tuple[str, str] | None = None async with async_session() as session: if not await _exists_alive(session, user_id, entity_type, entity_id): return None - # Capture CalDAV linkage before soft-deleting so we can propagate the - # deletion to the external server (the row stays present locally). - if entity_type == "event": - row = (await session.execute( - select(Event.caldav_uid, Event.title).where( - Event.id == entity_id, Event.user_id == user_id - ) - )).first() - if row and row[0]: - caldav_event = (row[0], row[1]) await _cascade(session, user_id, entity_type, entity_id, batch, now) await session.commit() - # Without this the soft-delete only hides the event locally and the remote - # copy lingers forever (and re-appears on any client syncing that server). - if caldav_event: - import asyncio - from scribe.services.events import _push_delete - asyncio.create_task(_push_delete(caldav_event[0], caldav_event[1], user_id)) return batch # All soft-deletable models, and their trash-listing type label. -_ALL = [Note, Event, Project, Milestone, Rulebook, RulebookTopic, Rule] +_ALL = [Note, Project, Milestone, Rulebook, RulebookTopic, Rule] _TYPE = { - Note: "note", Event: "event", Project: "project", Milestone: "milestone", + Note: "note", Project: "project", Milestone: "milestone", Rulebook: "rulebook", RulebookTopic: "topic", Rule: "rule", } diff --git a/tests/test_events_model.py b/tests/test_events_model.py deleted file mode 100644 index a01da39..0000000 --- a/tests/test_events_model.py +++ /dev/null @@ -1,26 +0,0 @@ -def test_event_model_has_new_columns(): - from scribe.models.event import Event - cols = {c.key for c in Event.__table__.columns} - assert "caldav_uid" in cols - assert "color" in cols - - -def test_event_to_dict_includes_new_fields(): - from scribe.models.event import Event - from datetime import datetime, timezone - e = Event( - user_id=1, uid="test-uid", title="Test", - start_dt=datetime(2026, 3, 25, 10, 0, tzinfo=timezone.utc), - caldav_uid="sync-uid", color="#6366f1", - ) - d = e.to_dict() - assert d["caldav_uid"] == "sync-uid" - assert d["color"] == "#6366f1" - - -def test_caldav_create_event_accepts_uid_param(): - """caldav.create_event signature must accept an optional uid param.""" - import inspect - from scribe.services.caldav import create_event - sig = inspect.signature(create_event) - assert "uid" in sig.parameters diff --git a/tests/test_events_routes.py b/tests/test_events_routes.py deleted file mode 100644 index cdaccf1..0000000 --- a/tests/test_events_routes.py +++ /dev/null @@ -1,67 +0,0 @@ -"""Route-level tests for the events blueprint. - -Full HTTP integration tests require a live DB (not available in unit test -environment). These tests cover structural correctness and the route -module's public interface; ownership enforcement is covered by the service -tests in test_events_service.py. -""" - - -def test_events_blueprint_registered(): - """events_bp must be importable and have the correct name.""" - from scribe.routes.events import events_bp - assert events_bp.name == "events" - assert events_bp.url_prefix == "/api/events" - - -def test_events_blueprint_has_five_routes(): - """Blueprint must declare routes for GET/POST '' and GET/PATCH/DELETE '/'.""" - from scribe.routes.events import events_bp - methods_by_rule: dict[str, set[str]] = {} - for rule in events_bp.deferred_functions: - pass # deferred; inspect via url_map after binding - # Import routes module to confirm all 5 view functions exist - from scribe.routes import events as events_module - assert callable(events_module.list_events) - assert callable(events_module.create_event) - assert callable(events_module.get_event) - assert callable(events_module.update_event) - assert callable(events_module.delete_event) - - -def test_events_blueprint_registered_in_app(): - """events_bp must be registered in the app factory.""" - from scribe.app import create_app - app = create_app() - # Check the blueprint is present in the app's blueprints dict - assert "events" in app.blueprints - - -def test_events_service_ownership_enforced_on_get(): - """get_event returns None for a different user — route will 404.""" - # Ownership is enforced by the service filtering by user_id. - # The service returns None when the event belongs to a different user, - # and the route converts that to a 404 response. - import inspect - from scribe.services import events as events_svc - sig = inspect.signature(events_svc.get_event) - assert "user_id" in sig.parameters - assert "event_id" in sig.parameters - - -def test_events_service_ownership_enforced_on_update(): - """update_event takes user_id — route passes current user's id.""" - import inspect - from scribe.services import events as events_svc - sig = inspect.signature(events_svc.update_event) - assert "user_id" in sig.parameters - assert "event_id" in sig.parameters - - -def test_events_service_ownership_enforced_on_delete(): - """delete_event takes user_id — route verifies ownership before deleting.""" - import inspect - from scribe.services import events as events_svc - sig = inspect.signature(events_svc.delete_event) - assert "user_id" in sig.parameters - assert "event_id" in sig.parameters diff --git a/tests/test_events_service.py b/tests/test_events_service.py deleted file mode 100644 index 93e08b8..0000000 --- a/tests/test_events_service.py +++ /dev/null @@ -1,326 +0,0 @@ -import pytest -from unittest.mock import AsyncMock, MagicMock, patch -from datetime import datetime, timezone - - -def _make_mock_session(): - mock_session = AsyncMock() - mock_session.__aenter__ = AsyncMock(return_value=mock_session) - mock_session.__aexit__ = AsyncMock(return_value=False) - mock_session.add = MagicMock() - mock_session.commit = AsyncMock() - mock_session.refresh = AsyncMock() - return mock_session - - -def _make_mock_event(id=1, user_id=1, uid="uid-abc", title="Meeting", - caldav_uid="", color="", duration_minutes=60): - e = MagicMock() - e.id = id - e.user_id = user_id - e.uid = uid - e.title = title - e.caldav_uid = caldav_uid - e.color = color - e.start_dt = datetime(2026, 3, 25, 10, 0, tzinfo=timezone.utc) - e.duration_minutes = duration_minutes - # end_dt is derived; mirror the property's behavior on the mock so - # service code that reads `event.end_dt` gets a sensible value. - if duration_minutes is None: - e.end_dt = None - else: - from datetime import timedelta - e.end_dt = e.start_dt + timedelta(minutes=duration_minutes) - e.all_day = False - e.description = "" - e.location = "" - e.recurrence = None - e.project_id = None - e.to_dict.return_value = { - "id": id, "uid": uid, "title": title, - "caldav_uid": caldav_uid, "color": color, - "start_dt": e.start_dt.isoformat(), - "end_dt": e.end_dt.isoformat() if e.end_dt else None, - "duration_minutes": duration_minutes, - } - return e - - -@pytest.mark.asyncio -async def test_create_event_stores_to_db(): - mock_session = _make_mock_session() - with patch("scribe.services.events.async_session") as mock_cls, \ - patch("scribe.services.events.asyncio.create_task") as mock_task: - mock_cls.return_value = mock_session - from scribe.services.events import create_event - result = await create_event( - user_id=1, - title="Dentist", - start_dt=datetime(2026, 3, 25, 10, 0, tzinfo=timezone.utc), - ) - assert mock_session.add.called - assert mock_session.commit.called - # CalDAV push background task should be scheduled - assert mock_task.called - - -@pytest.mark.asyncio -async def test_find_events_by_query_returns_ilike_results(): - mock_event = _make_mock_event(title="Team Meeting") - mock_session = _make_mock_session() - mock_result = MagicMock() - mock_result.scalars.return_value.all.return_value = [mock_event] - mock_session.execute = AsyncMock(return_value=mock_result) - - with patch("scribe.services.events.async_session") as mock_cls: - mock_cls.return_value = mock_session - from scribe.services.events import find_events_by_query - results = await find_events_by_query(user_id=1, query="meeting") - assert len(results) == 1 - assert results[0].title == "Team Meeting" - - -@pytest.mark.asyncio -async def test_list_events_returns_events_in_range(): - mock_event = _make_mock_event() - mock_session = _make_mock_session() - mock_result = MagicMock() - mock_result.scalars.return_value.all.return_value = [mock_event] - mock_session.execute = AsyncMock(return_value=mock_result) - - with patch("scribe.services.events.async_session") as mock_cls: - mock_cls.return_value = mock_session - from scribe.services.events import list_events - results = await list_events( - user_id=1, - date_from=datetime(2026, 3, 1, tzinfo=timezone.utc), - date_to=datetime(2026, 3, 31, tzinfo=timezone.utc), - ) - assert len(results) == 1 - - -@pytest.mark.asyncio -async def test_delete_event_fires_caldav_push_when_uid_set(): - mock_event = _make_mock_event(caldav_uid="sync-uid") - mock_session = _make_mock_session() - mock_result = MagicMock() - mock_result.scalar_one_or_none.return_value = mock_event - mock_session.execute = AsyncMock(return_value=mock_result) - - with patch("scribe.services.events.async_session") as mock_cls, \ - patch("scribe.services.events.asyncio.create_task") as mock_task: - mock_cls.return_value = mock_session - from scribe.services.events import delete_event - await delete_event(user_id=1, event_id=1) - # Push task fired because caldav_uid is set - assert mock_task.called - - -@pytest.mark.asyncio -async def test_update_event_fires_caldav_push(): - mock_event = _make_mock_event(caldav_uid="sync-uid") - mock_session = _make_mock_session() - mock_result = MagicMock() - mock_result.scalar_one_or_none.return_value = mock_event - mock_session.execute = AsyncMock(return_value=mock_result) - - with patch("scribe.services.events.async_session") as mock_cls, \ - patch("scribe.services.events.asyncio.create_task") as mock_task: - mock_cls.return_value = mock_session - from scribe.services.events import update_event - await update_event(user_id=1, event_id=1, title="Updated Title") - assert mock_task.called - - -# ── Duration-model write-side guarantees (Fable #160) ───────────────────────── - - -def test_normalize_duration_from_end_dt(): - """end_dt sugar converts to a positive minute count anchored on start.""" - from scribe.services.events import _normalize_duration - start = datetime(2026, 5, 1, 8, 0, tzinfo=timezone.utc) - end = datetime(2026, 5, 1, 9, 30, tzinfo=timezone.utc) - assert _normalize_duration(start_dt=start, end_dt=end, duration_minutes=None) == 90 - - -def test_normalize_duration_zero_is_valid_point_event(): - """end_dt == start_dt → duration 0. The point-with-zero-duration case - is rare but legal (e.g. an instant marker); the duration model treats - it the same as duration None for display purposes.""" - from scribe.services.events import _normalize_duration - same = datetime(2026, 5, 1, 8, 0, tzinfo=timezone.utc) - assert _normalize_duration(start_dt=same, end_dt=same, duration_minutes=None) == 0 - - -def test_normalize_duration_rejects_end_before_start(): - """The exact 2026-04-29 prod failure: end 32 days before start. - The duration model makes this inexpressible at the schema level - via a CHECK constraint, but write-path callers still get a - helpful ValueError if they construct an inconsistent (start, end) - pair via the end_dt sugar.""" - from scribe.services.events import _normalize_duration - start = datetime(2026, 5, 1, 12, 0, tzinfo=timezone.utc) - end_before = datetime(2026, 3, 30, 12, 0, tzinfo=timezone.utc) - with pytest.raises(ValueError, match="at or after start_dt"): - _normalize_duration( - start_dt=start, end_dt=end_before, duration_minutes=None, - ) - - -def test_normalize_duration_rejects_negative_duration(): - """Direct duration_minutes < 0 is rejected. Mirrors the DB CHECK - constraint at the service boundary so callers get a clean error - rather than a constraint violation from psycopg.""" - from scribe.services.events import _normalize_duration - start = datetime(2026, 5, 1, 12, 0, tzinfo=timezone.utc) - with pytest.raises(ValueError, match="must be >= 0"): - _normalize_duration(start_dt=start, end_dt=None, duration_minutes=-15) - - -def test_normalize_duration_rejects_inconsistent_end_and_duration(): - """If a caller passes both end_dt AND duration_minutes that disagree, - the inconsistency is surfaced rather than silently picking one.""" - from scribe.services.events import _normalize_duration - start = datetime(2026, 5, 1, 12, 0, tzinfo=timezone.utc) - end = datetime(2026, 5, 1, 13, 0, tzinfo=timezone.utc) # implies 60 min - with pytest.raises(ValueError, match="implies 60 minutes"): - _normalize_duration( - start_dt=start, end_dt=end, duration_minutes=30, - ) - - -def test_normalize_duration_none_for_open_ended(): - """Both inputs None → None duration (open-ended event).""" - from scribe.services.events import _normalize_duration - start = datetime(2026, 5, 1, 12, 0, tzinfo=timezone.utc) - assert _normalize_duration( - start_dt=start, end_dt=None, duration_minutes=None, - ) is None - - -@pytest.mark.asyncio -async def test_create_event_rejects_end_before_start(): - """Service-level rejection — same scenario as the prod bug, surfaced - cleanly for tool / route callers via ValueError.""" - from scribe.services.events import create_event - start = datetime(2026, 5, 1, 12, 0, tzinfo=timezone.utc) - end_before = datetime(2026, 3, 30, 12, 0, tzinfo=timezone.utc) - with pytest.raises(ValueError, match="at or after start_dt"): - await create_event( - user_id=1, title="Bad", - start_dt=start, end_dt=end_before, - ) - - -@pytest.mark.asyncio -async def test_update_event_preserves_duration_when_only_start_changes(): - """Sliding semantics: when the user moves an event by changing only - start_dt, the existing duration_minutes is preserved as-is. The new - effective end_dt slides forward with the start. This is a behavioral - upgrade vs. the old end_dt model, where moving start past the - stored end made the event 'go backward in time'.""" - mock_event = _make_mock_event(duration_minutes=60) # start 10:00, end 11:00 - mock_session = _make_mock_session() - mock_result = MagicMock() - mock_result.scalar_one_or_none.return_value = mock_event - mock_session.execute = AsyncMock(return_value=mock_result) - - with patch("scribe.services.events.async_session") as mock_cls, \ - patch("scribe.services.events.asyncio.create_task"): - mock_cls.return_value = mock_session - from scribe.services.events import update_event - # Move start to 12:00; effective end becomes 13:00 automatically. - result = await update_event( - user_id=1, event_id=1, - start_dt=datetime(2026, 3, 25, 12, 0, tzinfo=timezone.utc), - ) - assert result is not None - # duration_minutes was NOT touched; mock_event still has 60. - assert mock_event.duration_minutes == 60 - - -@pytest.mark.asyncio -async def test_update_event_clearing_end_dt_clears_duration(): - """Passing end_dt=None on update is the documented way to clear the - end (turn a timed event into a point event). The service must - translate that into duration_minutes=None, not leave the prior - value in place.""" - mock_event = _make_mock_event(duration_minutes=60) - mock_session = _make_mock_session() - mock_result = MagicMock() - mock_result.scalar_one_or_none.return_value = mock_event - mock_session.execute = AsyncMock(return_value=mock_result) - - with patch("scribe.services.events.async_session") as mock_cls, \ - patch("scribe.services.events.asyncio.create_task"): - mock_cls.return_value = mock_session - from scribe.services.events import update_event - await update_event(user_id=1, event_id=1, end_dt=None) - assert mock_event.duration_minutes is None - - -@pytest.mark.asyncio -async def test_list_events_includes_point_event_in_window(): - """A point event (duration_minutes=None) surfaces when its start - is in the window. Replaces the prior 'corrupt end_dt' regression - test — the duration model can't represent that state, but the - same code path is exercised here for point events.""" - mock_event = _make_mock_event(duration_minutes=None) - # Point event in the upcoming window - mock_event.start_dt = datetime(2026, 5, 1, 12, 0, tzinfo=timezone.utc) - mock_event.end_dt = None - mock_event.to_dict.return_value = { - "id": 1, "title": "Point", - "start_dt": mock_event.start_dt.isoformat(), - "end_dt": None, - "duration_minutes": None, - } - mock_session = _make_mock_session() - mock_result = MagicMock() - mock_result.scalars.return_value.all.return_value = [mock_event] - mock_session.execute = AsyncMock(return_value=mock_result) - - with patch("scribe.services.events.async_session") as mock_cls: - mock_cls.return_value = mock_session - from scribe.services.events import list_events - results = await list_events( - user_id=1, - date_from=datetime(2026, 4, 29, tzinfo=timezone.utc), - date_to=datetime(2026, 5, 27, tzinfo=timezone.utc), - ) - assert len(results) == 1 - assert results[0]["id"] == 1 - - -@pytest.mark.asyncio -async def test_list_events_excludes_timed_event_that_already_ended(): - """A timed event whose start + duration is before the window must - NOT surface. Verifies the Python-side refinement actually works - against the coarse SQL prefilter.""" - mock_event = _make_mock_event(duration_minutes=60) - # Start 4/20 12:00, end 4/20 13:00; window is 4/29 → 5/27 — fully past. - mock_event.start_dt = datetime(2026, 4, 20, 12, 0, tzinfo=timezone.utc) - mock_event.end_dt = datetime(2026, 4, 20, 13, 0, tzinfo=timezone.utc) - mock_event.to_dict.return_value = { - "id": 1, "start_dt": mock_event.start_dt.isoformat(), - "end_dt": mock_event.end_dt.isoformat(), "duration_minutes": 60, - } - mock_session = _make_mock_session() - mock_result = MagicMock() - mock_result.scalars.return_value.all.return_value = [mock_event] - mock_session.execute = AsyncMock(return_value=mock_result) - - with patch("scribe.services.events.async_session") as mock_cls: - mock_cls.return_value = mock_session - from scribe.services.events import list_events - results = await list_events( - user_id=1, - date_from=datetime(2026, 4, 29, tzinfo=timezone.utc), - date_to=datetime(2026, 5, 27, tzinfo=timezone.utc), - ) - assert results == [] - - -# test_tools_calendar_always_available removed in Phase 8 along with the -# services/tools/ LLM-tool layer. Event CRUD is now exposed via MCP tools -# (see tests/test_mcp_tool_events.py for coverage). diff --git a/tests/test_mcp_tool_entities.py b/tests/test_mcp_tool_entities.py deleted file mode 100644 index bd75ad1..0000000 --- a/tests/test_mcp_tool_entities.py +++ /dev/null @@ -1,191 +0,0 @@ -"""Tests for typed-entity tools (person/place/list). - -Focuses on the metadata-shape translations and the entity_meta merge logic, -since that's where bugs would hide.""" -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest - -from scribe.mcp._context import _user_id_ctx -from scribe.mcp.tools.entities import ( - list_persons, create_person, update_person, - create_place, update_place, - create_list, update_list, -) - - -@pytest.fixture(autouse=True) -def _bind_user(): - token = _user_id_ctx.set(7) - yield - _user_id_ctx.reset(token) - - -def _fake_note(*, note_type="note", entity_meta=None, **overrides) -> MagicMock: - n = MagicMock() - n.note_type = note_type - n.entity_meta = entity_meta - base = {"id": 1, "title": "t", "note_type": note_type, - "metadata": entity_meta or {}} - base.update(overrides) - n.to_dict.return_value = base - return n - - -# ─── list ──────────────────────────────────────────────────────────────────── - - -@pytest.mark.asyncio -async def test_list_persons_calls_knowledge_with_person_type(): - mock = AsyncMock(return_value=([{"id": 1, "title": "alice"}], 1)) - with patch("scribe.mcp.tools.entities.knowledge_svc.query_knowledge", mock): - out = await list_persons(tag="work") - kwargs = mock.call_args.kwargs - assert kwargs["note_type"] == "person" - assert kwargs["tags"] == ["work"] - assert out["total"] == 1 - assert "persons" in out # type-specific plural key - - -# ─── create: metadata building ─────────────────────────────────────────────── - - -@pytest.mark.asyncio -async def test_create_person_only_includes_provided_fields_in_meta(): - """Empty-string fields must NOT pollute entity_meta.""" - fake = _fake_note(note_type="person") - mock = AsyncMock(return_value=fake) - with patch("scribe.mcp.tools.entities.notes_svc.create_note", mock): - await create_person(name="Alice", email="a@x.com") - kwargs = mock.call_args.kwargs - assert kwargs["title"] == "Alice" - assert kwargs["note_type"] == "person" - assert kwargs["entity_meta"] == {"email": "a@x.com"} - - -@pytest.mark.asyncio -async def test_create_person_all_empty_meta_is_none(): - """Service is called with entity_meta=None when no typed fields were given.""" - fake = _fake_note(note_type="person") - mock = AsyncMock(return_value=fake) - with patch("scribe.mcp.tools.entities.notes_svc.create_note", mock): - await create_person(name="Bob") - assert mock.call_args.kwargs["entity_meta"] is None - - -@pytest.mark.asyncio -async def test_create_place_categories_into_meta(): - fake = _fake_note(note_type="place") - mock = AsyncMock(return_value=fake) - with patch("scribe.mcp.tools.entities.notes_svc.create_note", mock): - await create_place(name="Cafe X", address="123 Main", category="coffee") - meta = mock.call_args.kwargs["entity_meta"] - assert meta == {"address": "123 Main", "category": "coffee"} - - -@pytest.mark.asyncio -async def test_create_list_translates_strings_to_unchecked_items(): - fake = _fake_note(note_type="list") - mock = AsyncMock(return_value=fake) - with patch("scribe.mcp.tools.entities.notes_svc.create_note", mock): - await create_list(name="shopping", items=["milk", "bread"]) - meta = mock.call_args.kwargs["entity_meta"] - assert meta["list_items"] == [ - {"text": "milk", "checked": False}, - {"text": "bread", "checked": False}, - ] - - -# ─── update: meta merge ────────────────────────────────────────────────────── - - -@pytest.mark.asyncio -async def test_update_person_merges_meta_preserving_other_fields(): - """Updating one field must NOT clobber the others stored in entity_meta.""" - existing = _fake_note( - id=5, note_type="person", - entity_meta={"email": "old@x.com", "phone": "555-1234"}, - ) - get_mock = AsyncMock(return_value=existing) - updated = _fake_note(note_type="person") - update_mock = AsyncMock(return_value=updated) - with patch( - "scribe.mcp.tools.entities.notes_svc.get_note", get_mock, - ), patch( - "scribe.mcp.tools.entities.notes_svc.update_note", update_mock, - ): - await update_person(person_id=5, email="new@x.com") - new_meta = update_mock.call_args.kwargs["entity_meta"] - assert new_meta == {"email": "new@x.com", "phone": "555-1234"} - - -@pytest.mark.asyncio -async def test_update_person_no_typed_fields_keeps_meta_unchanged(): - existing = _fake_note( - id=5, note_type="person", - entity_meta={"email": "a@x.com"}, - ) - updated = _fake_note(note_type="person") - with patch( - "scribe.mcp.tools.entities.notes_svc.get_note", - AsyncMock(return_value=existing), - ), patch( - "scribe.mcp.tools.entities.notes_svc.update_note", - AsyncMock(return_value=updated), - ) as update_mock: - await update_person(person_id=5, name="New Name") - # entity_meta unchanged, but still passed (service gets the full new dict) - assert update_mock.call_args.kwargs["entity_meta"] == {"email": "a@x.com"} - assert update_mock.call_args.kwargs["title"] == "New Name" - - -@pytest.mark.asyncio -async def test_update_person_rejects_wrong_type(): - """Trying to update a 'place' as a 'person' must fail.""" - wrong_type = _fake_note(id=5, note_type="place") - with patch( - "scribe.mcp.tools.entities.notes_svc.get_note", - AsyncMock(return_value=wrong_type), - ): - with pytest.raises(ValueError, match="person 5 not found"): - await update_person(person_id=5, email="x@x.com") - - -@pytest.mark.asyncio -async def test_update_list_items_empty_list_clears_items(): - """items=[] clears all items; items=None leaves unchanged.""" - existing = _fake_note( - id=5, note_type="list", - entity_meta={"list_items": [{"text": "old", "checked": True}]}, - ) - updated = _fake_note(note_type="list") - with patch( - "scribe.mcp.tools.entities.notes_svc.get_note", - AsyncMock(return_value=existing), - ), patch( - "scribe.mcp.tools.entities.notes_svc.update_note", - AsyncMock(return_value=updated), - ) as update_mock: - await update_list(list_id=5, items=[]) - assert update_mock.call_args.kwargs["entity_meta"]["list_items"] == [] - - -@pytest.mark.asyncio -async def test_update_list_items_none_leaves_items_unchanged(): - existing = _fake_note( - id=5, note_type="list", - entity_meta={"list_items": [{"text": "keep", "checked": False}]}, - ) - updated = _fake_note(note_type="list") - with patch( - "scribe.mcp.tools.entities.notes_svc.get_note", - AsyncMock(return_value=existing), - ), patch( - "scribe.mcp.tools.entities.notes_svc.update_note", - AsyncMock(return_value=updated), - ) as update_mock: - await update_list(list_id=5, name="renamed") - # Existing items intact in the merged meta - assert update_mock.call_args.kwargs["entity_meta"]["list_items"] == [ - {"text": "keep", "checked": False}, - ] diff --git a/tests/test_mcp_tool_events.py b/tests/test_mcp_tool_events.py deleted file mode 100644 index 6a2c9d0..0000000 --- a/tests/test_mcp_tool_events.py +++ /dev/null @@ -1,158 +0,0 @@ -"""Tests for fable_*_event tools.""" -from datetime import datetime -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest - -from scribe.mcp._context import _user_id_ctx -from scribe.mcp.tools.events import ( - list_events, create_event, get_event, - update_event, delete_event, -) - - -@pytest.fixture(autouse=True) -def _bind_user(): - token = _user_id_ctx.set(7) - yield - _user_id_ctx.reset(token) - - -def _fake_event(**overrides) -> MagicMock: - e = MagicMock() - base = { - "id": 1, "title": "ev", "start_dt": "2026-06-01T10:00:00", - "duration_minutes": 30, "all_day": False, - "location": "", "description": "", - } - base.update(overrides) - e.to_dict.return_value = base - return e - - -@pytest.mark.asyncio -async def test_list_events_passes_timezone_aware_range(): - """Range must be tz-aware (UTC) and date_to inclusive at end-of-day — - Event.start_dt is tz-aware in the DB; naive comparisons raise TypeError.""" - from datetime import timezone - mock = AsyncMock(return_value=[ - {"id": 1, "title": "morning standup"}, - ]) - with patch("scribe.mcp.tools.events.events_svc.list_events", mock): - out = await list_events(date_from="2026-06-01", date_to="2026-06-08") - args, _ = mock.call_args - assert args[0] == 7 # user_id - assert args[1] == datetime(2026, 6, 1, tzinfo=timezone.utc) - # date_to is end-of-day inclusive → start of 2026-06-09 (24h past start of 2026-06-08) - assert args[2] == datetime(2026, 6, 9, tzinfo=timezone.utc) - assert out["total"] == 1 - - -@pytest.mark.asyncio -async def test_create_event_combines_date_and_time(): - e = _fake_event() - mock = AsyncMock(return_value=e) - with patch("scribe.mcp.tools.events.events_svc.create_event", mock): - await create_event( - title="standup", start_date="2026-06-01", start_time="09:30", - duration_minutes=15, - ) - kwargs = mock.call_args.kwargs - assert kwargs["start_dt"] == datetime(2026, 6, 1, 9, 30) - assert kwargs["duration_minutes"] == 15 - - -@pytest.mark.asyncio -async def test_create_event_zero_duration_means_point_event(): - """duration_minutes=0 must map to None at the service layer (NULL = point).""" - e = _fake_event() - mock = AsyncMock(return_value=e) - with patch("scribe.mcp.tools.events.events_svc.create_event", mock): - await create_event(title="x", start_date="2026-06-01") - assert mock.call_args.kwargs["duration_minutes"] is None - - -@pytest.mark.asyncio -async def test_get_event_raises_when_not_found(): - with patch( - "scribe.mcp.tools.events.events_svc.get_event", - AsyncMock(return_value=None), - ): - with pytest.raises(ValueError, match="event 999 not found"): - await get_event(event_id=999) - - -@pytest.mark.asyncio -async def test_update_event_only_sends_non_default_fields(): - e = _fake_event() - mock = AsyncMock(return_value=e) - with patch("scribe.mcp.tools.events.events_svc.update_event", mock): - await update_event(event_id=1, title="new title") - args, kwargs = mock.call_args - assert args == (7, 1) - assert kwargs == {"title": "new title"} - - -@pytest.mark.asyncio -async def test_update_event_duration_minus_one_means_unchanged(): - e = _fake_event() - mock = AsyncMock(return_value=e) - with patch("scribe.mcp.tools.events.events_svc.update_event", mock): - await update_event(event_id=1, duration_minutes=-1) - assert "duration_minutes" not in mock.call_args.kwargs - - -@pytest.mark.asyncio -async def test_update_event_duration_zero_clears_to_point(): - """duration_minutes=0 means "set to point event" (NULL).""" - e = _fake_event() - mock = AsyncMock(return_value=e) - with patch("scribe.mcp.tools.events.events_svc.update_event", mock): - await update_event(event_id=1, duration_minutes=0) - assert mock.call_args.kwargs["duration_minutes"] is None - - -@pytest.mark.asyncio -async def test_update_event_requires_both_date_and_time_to_move(): - e = _fake_event() - mock = AsyncMock(return_value=e) - with patch("scribe.mcp.tools.events.events_svc.update_event", mock): - await update_event(event_id=1, start_date="2026-06-02") - # Only start_date, no start_time → start_dt NOT in fields - assert "start_dt" not in mock.call_args.kwargs - - mock.reset_mock() - await update_event( - event_id=1, start_date="2026-06-02", start_time="11:00", - ) - assert mock.call_args.kwargs["start_dt"] == datetime(2026, 6, 2, 11) - - -@pytest.mark.asyncio -async def test_update_event_raises_when_not_found(): - with patch( - "scribe.mcp.tools.events.events_svc.update_event", - AsyncMock(return_value=None), - ): - with pytest.raises(ValueError, match="event 999 not found"): - await update_event(event_id=999, title="x") - - -@pytest.mark.asyncio -async def test_delete_event_soft_deletes_and_returns_batch(): - with patch( - "scribe.mcp.tools.events.trash_svc.delete", - AsyncMock(return_value="batch-1"), - ): - result = await delete_event(event_id=7) - assert result["deleted_batch_id"] == "batch-1" - - -@pytest.mark.asyncio -async def test_delete_event_raises_when_not_found(): - with patch( - "scribe.mcp.tools.events.trash_svc.delete", - AsyncMock(return_value=None), - ): - with pytest.raises(ValueError, match="event 999 not found"): - await delete_event(event_id=999) diff --git a/tests/test_services_backup.py b/tests/test_services_backup.py index babe2e0..67486be 100644 --- a/tests/test_services_backup.py +++ b/tests/test_services_backup.py @@ -1,9 +1,9 @@ -"""Unit tests for the v3 backup export contract. +"""Unit tests for the v4 backup export contract. CI runs pytest with no database, so these cover the parts that don't need one: the version/coverage constants, the pure join-table row helpers, and the export dict shape (via a mocked session). Full FK-remapping round-trip is exercised -manually against a real DB (export a backup, confirm rulebooks/events appear). +manually against a real DB (export a backup, confirm rulebooks appear). """ from types import SimpleNamespace from unittest.mock import patch @@ -13,8 +13,8 @@ import pytest from scribe.services import backup -def test_backup_version_is_v3(): - assert backup.BACKUP_VERSION == 3 +def test_backup_version_is_v4(): + assert backup.BACKUP_VERSION == 4 def test_not_included_lists_the_known_gaps(): @@ -61,12 +61,12 @@ async def test_export_full_backup_contains_v3_sections(): with patch("scribe.services.backup.async_session", lambda: _CM()): out = await backup.export_full_backup() - assert out["version"] == 3 + assert out["version"] == 4 assert out["scope"] == "full" assert "api_keys" in out["_not_included"] # The sections v2 silently dropped must now be present (empty here). for key in ("rulebooks", "rulebook_topics", "rules", "rulebook_subscriptions", "rule_suppressions", - "topic_suppressions", "events"): + "topic_suppressions"): assert key in out, f"missing v3 section: {key}" assert out[key] == [] diff --git a/tests/test_services_dashboard.py b/tests/test_services_dashboard.py index 36a1174..13b7f03 100644 --- a/tests/test_services_dashboard.py +++ b/tests/test_services_dashboard.py @@ -42,14 +42,12 @@ async def test_build_dashboard_composes_sections(): import scribe.services.dashboard as dash with patch.object(dash, "_active_projects", AsyncMock(return_value=["P"])), \ patch.object(dash, "_recently_completed", AsyncMock(return_value=["done"])), \ - patch.object(dash, "_upcoming_events", AsyncMock(return_value=["evt"])), \ patch.object(dash, "_open_issues", AsyncMock(return_value=["iss"])), \ patch.object(dash, "_week_stats", AsyncMock(return_value={"open_total": 4})): out = await dash.build_dashboard(user_id=1) assert out == { "active_projects": ["P"], "recently_completed": ["done"], - "upcoming_events": ["evt"], "open_issues": ["iss"], "week_stats": {"open_total": 4}, } @@ -60,7 +58,6 @@ async def test_build_dashboard_isolates_failing_section(): import scribe.services.dashboard as dash with patch.object(dash, "_active_projects", AsyncMock(side_effect=RuntimeError("db down"))), \ patch.object(dash, "_recently_completed", AsyncMock(return_value=["done"])), \ - patch.object(dash, "_upcoming_events", AsyncMock(return_value=[])), \ patch.object(dash, "_open_issues", AsyncMock(return_value=[])), \ patch.object(dash, "_week_stats", AsyncMock(return_value={})): out = await dash.build_dashboard(user_id=1) diff --git a/tests/test_services_knowledge_counts.py b/tests/test_services_knowledge_counts.py index eb673b2..89bfda3 100644 --- a/tests/test_services_knowledge_counts.py +++ b/tests/test_services_knowledge_counts.py @@ -39,7 +39,7 @@ async def test_counts_include_process_in_facet_and_total(): assert counts["process"] == 2 # facet keys all present (setdefault) - for key in ("note", "person", "place", "list", "task", "plan", "process"): + for key in ("note", "task", "plan", "process"): assert key in counts - # total = note(3) + person(0) + place(0) + list(0) + task(1) + process(2) + # total = note(3) + task(1) + process(2) assert counts["total"] == 6 diff --git a/tests/test_trash_filtering.py b/tests/test_trash_filtering.py index c011645..34861d2 100644 --- a/tests/test_trash_filtering.py +++ b/tests/test_trash_filtering.py @@ -5,7 +5,6 @@ compiled SQL of every statement passed to execute, then assert the 'deleted_at IS NULL' filter is present. No real DB. """ from unittest.mock import AsyncMock, MagicMock, patch -from datetime import datetime, timezone import pytest @@ -66,20 +65,6 @@ async def test_list_projects_excludes_trashed(): assert _has_filter(cap) -@pytest.mark.asyncio -async def test_list_events_excludes_trashed(): - cap: list[str] = [] - with patch("scribe.services.events.async_session") as cls: - cls.return_value = _capturing_session(cap) - from scribe.services.events import list_events - await list_events( - 1, - datetime(2026, 5, 1, tzinfo=timezone.utc), - datetime(2026, 5, 31, tzinfo=timezone.utc), - ) - assert _has_filter(cap) - - @pytest.mark.asyncio async def test_query_knowledge_excludes_trashed(): cap: list[str] = []