From 4d55d9d82a4aeea8b090480421741043a88f6e5c Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 9 Mar 2026 18:28:25 -0400 Subject: [PATCH 01/45] Fix scanner probes returning 200 via SPA catch-all The 404 handler was unconditionally serving index.html (200) for all non-API, non-static paths, including scanner probes for .php, .asp, .cgi etc. Added _SPA_EXTENSIONS set so paths with unknown extensions get a real 404 instead of a misleading 200. Co-Authored-By: Claude Sonnet 4.6 --- src/fabledassistant/app.py | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/src/fabledassistant/app.py b/src/fabledassistant/app.py index b823292..53d4585 100644 --- a/src/fabledassistant/app.py +++ b/src/fabledassistant/app.py @@ -238,17 +238,34 @@ def create_app() -> Quart: resp.headers["Cache-Control"] = "no-cache, no-store, must-revalidate" return resp + # File extensions that belong to the SPA or its assets. + # Anything else with an extension is not a valid app path and gets a hard 404. + _SPA_EXTENSIONS = { + "", ".html", ".js", ".css", ".ico", ".png", ".jpg", ".jpeg", + ".svg", ".webp", ".woff", ".woff2", ".ttf", ".otf", ".map", ".json", + } + @app.errorhandler(404) async def handle_404(error): # Return JSON 404 for API routes if request.path.startswith("/api/"): return jsonify({"error": "Not found"}), 404 - # Try to serve static file + + # Try to serve a real static file first path = request.path.lstrip("/") file_path = STATIC_DIR / path if path and file_path.is_file(): return await send_from_directory(STATIC_DIR, path) - # SPA fallback + + # Reject paths with file extensions the app doesn't serve. + # This turns scanner probes (.php, .asp, .cgi, etc.) into honest 404s + # instead of serving them the Vue SPA with a misleading 200. + from pathlib import PurePosixPath + suffix = PurePosixPath(request.path).suffix.lower() + if suffix not in _SPA_EXTENSIONS: + return "", 404 + + # SPA fallback for clean client-side routes (/notes/123, /chat, etc.) resp = await make_response( await send_from_directory(STATIC_DIR, "index.html") ) From 46672725a13dc59164f13beea5d03626a05f5050 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 9 Mar 2026 18:42:07 -0400 Subject: [PATCH 02/45] Skip semantic duplicate check for bare-title tasks/notes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The semantic similarity check was flagging unrelated short-title tasks as duplicates (e.g. "Lore: Shell 0" matching "Lore: Reinitialization 0" at 91%) because with no body, the embedding is purely title-based and co-domain tasks in the same project share a tight embedding neighborhood. Only run the semantic check when the body is ≥ 80 chars — enough content to make a meaningful comparison. The fuzzy title check already covers exact/near-exact title duplicates. Co-Authored-By: Claude Sonnet 4.6 --- src/fabledassistant/services/tools.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/fabledassistant/services/tools.py b/src/fabledassistant/services/tools.py index 6b8378a..e8bccba 100644 --- a/src/fabledassistant/services/tools.py +++ b/src/fabledassistant/services/tools.py @@ -825,7 +825,7 @@ async def execute_tool(user_id: int, tool_name: str, arguments: dict) -> dict: item_type = "task" if near.status is not None else "note" return {"success": False, "error": f"A {item_type} with a very similar title '{near.title}' already exists (id: {near.id}, similarity: {ratio:.0%}). Use update_note to modify it instead of creating a duplicate."} - if not arguments.get("confirmed"): + if not arguments.get("confirmed") and len(task_body.strip()) >= 80: from fabledassistant.services.embeddings import semantic_search_notes as _ssn sem_query = f"{task_title}\n{task_body}".strip() sem_hits = await _ssn(user_id, sem_query, limit=3, threshold=0.87) @@ -903,7 +903,7 @@ async def execute_tool(user_id: int, tool_name: str, arguments: dict) -> dict: item_type = "task" if near.status is not None else "note" return {"success": False, "error": f"A {item_type} with a very similar title '{near.title}' already exists (id: {near.id}, similarity: {ratio:.0%}). Use update_note to modify it instead of creating a duplicate."} - if not arguments.get("confirmed"): + if not arguments.get("confirmed") and len(note_body.strip()) >= 80: from fabledassistant.services.embeddings import semantic_search_notes as _ssn sem_query = f"{note_title}\n{note_body}".strip() sem_hits = await _ssn(user_id, sem_query, limit=3, threshold=0.87) From a2ba90160cbee9e0a7619313023e3a6b4c2d99fe Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 24 Mar 2026 00:42:01 -0400 Subject: [PATCH 03/45] feat: kanban status buttons, task back-nav, RSS UI, weather search, briefing fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Project view: - Add inline status advance buttons on kanban task cards (todo→in_progress, in_progress→done); buttons reveal on hover, stop link navigation Task viewer: - Back button navigates to task's project instead of /tasks when project_id set - Esc key navigates to project (or /tasks); blurs focused element first Quick capture: - Use user's configured model instead of hardcoded Config.OLLAMA_MODEL - Remove create_project from classifier prompt (tool not offered, caused task-shaped inputs to silently fall through to note fallback) Briefing scheduler: - Fix get_event_loop() → get_running_loop() so background thread uses the correct hypercorn event loop (jobs were scheduling but never executing) - Suppress bare greeting when both LLM synthesis lanes return empty RSS feed UI (SettingsView): - Show last-fetched age, category badge, and feed URL per row - Category input field when adding a feed - Refresh all button: fetches latest items, reloads list, toasts with count - Enter key submits add-feed form; better empty-state hint with example feeds Weather tool: - Accept any city/region name in addition to 'home'/'work'/'all' - Geocodes via Nominatim + fetches live from Open-Meteo for arbitrary queries Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/api/client.ts | 10 +- frontend/src/views/ProjectView.vue | 71 ++++++++++- frontend/src/views/SettingsView.vue | 116 ++++++++++++++++-- frontend/src/views/TaskViewerView.vue | 24 +++- src/fabledassistant/app.py | 2 +- src/fabledassistant/routes/quick_capture.py | 3 +- .../services/briefing_pipeline.py | 4 + src/fabledassistant/services/intent.py | 1 - src/fabledassistant/services/tools.py | 41 +++++-- 9 files changed, 238 insertions(+), 34 deletions(-) diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index abb5c41..03efb8a 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -384,11 +384,17 @@ export async function getBriefingFeeds(): Promise { return data; } -export async function createBriefingFeed(url: string): Promise { - const data = await apiPost<{ id: number; url: string; title: string; category: string | null }>('/api/briefing/feeds', { url }); +export async function createBriefingFeed(url: string, category?: string): Promise { + const body: Record = { url }; + if (category?.trim()) body.category = category.trim(); + const data = await apiPost<{ id: number; url: string; title: string; category: string | null }>('/api/briefing/feeds', body); return { ...data, last_fetched_at: null }; } +export async function refreshBriefingFeeds(): Promise<{ feeds_refreshed: number; new_items: number }> { + return apiPost('/api/briefing/feeds/refresh', {}); +} + export async function deleteBriefingFeed(id: number): Promise { await apiDelete(`/api/briefing/feeds/${id}`); } diff --git a/frontend/src/views/ProjectView.vue b/frontend/src/views/ProjectView.vue index c1c2de5..1fefc0a 100644 --- a/frontend/src/views/ProjectView.vue +++ b/frontend/src/views/ProjectView.vue @@ -209,6 +209,30 @@ async function loadTasks() { } } +const advancingTaskId = ref(null); + +const taskStatusNext: Record = { + todo: "in_progress", + in_progress: "done", +}; + +async function advanceTaskStatus(task: NoteItem, e: Event) { + e.preventDefault(); + e.stopPropagation(); + const next = taskStatusNext[task.status ?? ""]; + if (!next || advancingTaskId.value === task.id) return; + advancingTaskId.value = task.id; + try { + await apiPatch(`/api/notes/${task.id}`, { status: next }); + const idx = tasks.value.findIndex((t) => t.id === task.id); + if (idx !== -1) tasks.value[idx] = { ...tasks.value[idx], status: next }; + } catch { + toast.show("Failed to update task", "error"); + } finally { + advancingTaskId.value = null; + } +} + async function loadNotes() { notesLoading.value = true; try { @@ -475,9 +499,17 @@ async function confirmDelete() { :class="['task-card', `pri-${task.priority || 'none'}`]" > {{ task.title || "Untitled" }} -
- - {{ task.due_date }} +

No tasks

@@ -498,9 +530,17 @@ async function confirmDelete() { :class="['task-card', `pri-${task.priority || 'none'}`]" > {{ task.title || "Untitled" }} -
- - {{ task.due_date }} +

No tasks

@@ -1088,7 +1128,26 @@ async function confirmDelete() { .task-card-done .task-title { text-decoration: line-through; } .task-title { display: block; font-weight: 500; margin-bottom: 0.2rem; line-height: 1.3; word-break: break-word; } +.task-card-footer { display: flex; align-items: center; justify-content: space-between; gap: 0.35rem; min-height: 1.2rem; } .task-meta { display: flex; align-items: center; gap: 0.35rem; flex-wrap: wrap; } +.task-advance-btn { + flex-shrink: 0; + display: inline-flex; align-items: center; justify-content: center; + width: 1.4rem; height: 1.4rem; + border: 1px solid var(--color-border); + border-radius: 4px; + background: transparent; + color: var(--color-text-muted); + font-size: 0.75rem; + cursor: pointer; + opacity: 0; + transition: opacity 0.15s, background 0.15s, color 0.15s; + line-height: 1; +} +.task-card:hover .task-advance-btn { opacity: 1; } +.task-advance-btn:hover { background: var(--color-primary); border-color: var(--color-primary); color: #fff; } +.task-advance-btn--done:hover { background: var(--color-success, #22c55e); border-color: var(--color-success, #22c55e); color: #fff; } +.task-advance-btn:disabled { opacity: 0.4; cursor: default; } .priority-dot { width: 7px; diff --git a/frontend/src/views/SettingsView.vue b/frontend/src/views/SettingsView.vue index 4d58272..bad9dad 100644 --- a/frontend/src/views/SettingsView.vue +++ b/frontend/src/views/SettingsView.vue @@ -3,7 +3,7 @@ import { ref, watch, onMounted } from "vue"; import { useSettingsStore } from "@/stores/settings"; import { useAuthStore } from "@/stores/auth"; import { useToastStore } from "@/stores/toast"; -import { apiGet, apiPost, apiPut, apiDelete, listGroups, createGroup, deleteGroup, listGroupMembers, addGroupMember, removeGroupMember, searchUsers, getBriefingConfig, saveBriefingConfig, getBriefingFeeds, createBriefingFeed, deleteBriefingFeed, geocodeAddress, type GroupEntry, type GroupMember, type UserSearchResult, type BriefingConfig, type BriefingFeed } from "@/api/client"; +import { apiGet, apiPost, apiPut, apiDelete, listGroups, createGroup, deleteGroup, listGroupMembers, addGroupMember, removeGroupMember, searchUsers, getBriefingConfig, saveBriefingConfig, getBriefingFeeds, createBriefingFeed, deleteBriefingFeed, refreshBriefingFeeds, geocodeAddress, type GroupEntry, type GroupMember, type UserSearchResult, type BriefingConfig, type BriefingFeed } from "@/api/client"; import { usePushStore } from "@/stores/push"; import type { User } from "@/types/auth"; import PaginationBar from "@/components/PaginationBar.vue"; @@ -231,7 +231,9 @@ const briefingSaved = ref(false); const briefingGeocoding = ref>({}); const briefingGeoError = ref>({}); const newFeedUrl = ref(''); +const newFeedCategory = ref(''); const addingFeed = ref(false); +const refreshingFeeds = ref(false); async function loadBriefingTab() { briefingConfig.value = await getBriefingConfig(); @@ -294,11 +296,12 @@ async function addFeed() { if (!newFeedUrl.value.trim() || addingFeed.value) return; addingFeed.value = true; try { - const feed = await createBriefingFeed(newFeedUrl.value.trim()); + const feed = await createBriefingFeed(newFeedUrl.value.trim(), newFeedCategory.value.trim() || undefined); briefingFeeds.value.push(feed); newFeedUrl.value = ''; - } catch { - toastStore.show('Failed to add feed', 'error'); + newFeedCategory.value = ''; + } catch (err: any) { + toastStore.show(err?.message === 'Feed already added' ? 'That feed is already in your list' : 'Failed to add feed', 'error'); } finally { addingFeed.value = false; } @@ -309,6 +312,32 @@ async function removeFeed(id: number) { briefingFeeds.value = briefingFeeds.value.filter((f) => f.id !== id); } +async function refreshFeeds() { + if (refreshingFeeds.value) return; + refreshingFeeds.value = true; + try { + const result = await refreshBriefingFeeds(); + // Reload feed list so last_fetched_at updates + briefingFeeds.value = await getBriefingFeeds(); + toastStore.show(`Refreshed ${result.feeds_refreshed} feed${result.feeds_refreshed !== 1 ? 's' : ''} — ${result.new_items} new item${result.new_items !== 1 ? 's' : ''}`); + } catch { + toastStore.show('Failed to refresh feeds', 'error'); + } finally { + refreshingFeeds.value = false; + } +} + +function feedAge(isoStr: string | null): string { + if (!isoStr) return 'never fetched'; + const diff = Date.now() - new Date(isoStr).getTime(); + const m = Math.floor(diff / 60000); + if (m < 1) return 'just now'; + if (m < 60) return `${m}m ago`; + const h = Math.floor(m / 60); + if (h < 24) return `${h}h ago`; + return `${Math.floor(h / 24)}d ago`; +} + // Chat retention const chatRetentionDays = ref(90); const savingRetention = ref(false); @@ -1513,20 +1542,40 @@ function formatUserDate(iso: string): string {
-

RSS Feeds

-

Add RSS or Atom feeds to be summarised in your morning briefing.

+
+
+

RSS Feeds

+

Add RSS or Atom feeds to be summarised in your morning briefing.

+
+ +
+
- {{ feed.title || feed.url }} - {{ feed.url }} +
+ {{ feed.title || feed.url }} + {{ feed.category }} +
+ {{ feed.url }} + {{ feedAge(feed.last_fetched_at) }}
-

No feeds yet.

-
- +

No feeds yet. Try adding Reuters, BBC World, or Hacker News.

+ +
+
+ + +
@@ -3030,6 +3079,51 @@ function formatUserDate(iso: string): string { transition: color 0.15s; } .briefing-btn-remove:hover { color: var(--color-danger, #ef4444); } +.briefing-feeds-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; + margin-bottom: 0.75rem; +} +.briefing-feeds-header h2 { margin: 0; } +.briefing-feeds-header .section-desc { margin: 0.25rem 0 0; } +.briefing-refresh-btn { white-space: nowrap; flex-shrink: 0; } +.briefing-feed-title-row { + display: flex; + align-items: center; + gap: 0.4rem; + flex-wrap: wrap; +} +.briefing-feed-cat { + font-size: 0.7rem; + padding: 0.1rem 0.4rem; + border-radius: 3px; + background: color-mix(in srgb, var(--color-primary) 15%, transparent); + color: var(--color-primary); + font-weight: 500; +} +.briefing-feed-age { + font-size: 0.72rem; + color: var(--color-text-muted); + display: block; + margin-top: 0.1rem; +} +.briefing-add-feed-form { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; + align-items: flex-end; + margin-top: 0.75rem; +} +.briefing-add-feed-inputs { + display: flex; + gap: 0.5rem; + flex: 1; + flex-wrap: wrap; +} +.briefing-add-feed-inputs .input { flex: 1; min-width: 160px; } +.briefing-cat-input { max-width: 180px; } .briefing-add-feed { display: flex; gap: 0.5rem; diff --git a/frontend/src/views/TaskViewerView.vue b/frontend/src/views/TaskViewerView.vue index 3f7fc47..cbe391a 100644 --- a/frontend/src/views/TaskViewerView.vue +++ b/frontend/src/views/TaskViewerView.vue @@ -1,5 +1,5 @@ + + + + +``` + +- [ ] **Step 2: TypeScript check** + +```bash +make typecheck +``` + +Expected: no errors. + +- [ ] **Step 3: Commit** + +```bash +git add frontend/src/components/WeatherCard.vue +git commit -m "feat(briefing): add WeatherCard.vue component" +``` + +--- + +## Task 13: BriefingView.vue — Metadata Integration + +**Files:** +- Modify: `frontend/src/views/BriefingView.vue` + +- [ ] **Step 1: Read the current `BriefingView.vue` to understand its structure** + +```bash +docker compose exec app cat frontend/src/views/BriefingView.vue | head -100 +``` + +Or use the Read tool. Understand: how messages are loaded, how they are rendered, where to insert the WeatherCard. + +- [ ] **Step 2: Add WeatherCard import and type definitions** + +At the top of ` + + + + From 06cb7cc86de2ca87dc53da52e7f1b6348ce5c338 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 25 Mar 2026 10:45:16 -0400 Subject: [PATCH 25/45] feat(briefing): render WeatherCard and RSS reaction buttons from message metadata Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/api/client.ts | 1 + frontend/src/views/BriefingView.vue | 124 ++++++++++++++++++++++++++-- 2 files changed, 119 insertions(+), 6 deletions(-) diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 215e126..d53055e 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -353,6 +353,7 @@ export interface BriefingMessage { role: 'user' | 'assistant' | 'system'; content: string; created_at: string; + metadata?: Record | null; } const DEFAULT_BRIEFING_CONFIG: BriefingConfig = { diff --git a/frontend/src/views/BriefingView.vue b/frontend/src/views/BriefingView.vue index 4a5fa64..e8dee64 100644 --- a/frontend/src/views/BriefingView.vue +++ b/frontend/src/views/BriefingView.vue @@ -2,6 +2,7 @@ import { ref, computed, onMounted, watch } from 'vue' import { useChatStore } from '@/stores/chat' import ChatMessage from '@/components/ChatMessage.vue' +import WeatherCard from '@/components/WeatherCard.vue' import BriefingSetupWizard from '@/components/BriefingSetupWizard.vue' import { getBriefingConfig, @@ -9,11 +10,28 @@ import { getBriefingToday, getBriefingConvMessages, triggerBriefingSlot, + postRssReaction, + deleteRssReaction, type BriefingConversation, type BriefingMessage, } from '@/api/client' import type { Message } from '@/types/chat' +interface MessageMetadata { + weather?: { + location: string + fetched_at: string + current_temp: number + condition: string + today_high: number | null + today_low: number | null + yesterday_high: number | null + yesterday_low: number | null + forecast: { day: string; condition: string; high: number; low: number }[] + } | null + rss_item_ids?: number[] +} + const chatStore = useChatStore() // Setup wizard @@ -114,6 +132,27 @@ function onKeydown(e: KeyboardEvent) { } } +// RSS reactions: map of rss_item_id -> 'up' | 'down' | null +const reactions = ref>({}) + +async function handleReaction(itemId: number, reaction: 'up' | 'down') { + const current = reactions.value[itemId] + reactions.value[itemId] = current === reaction ? null : reaction + try { + if (current === reaction) { + await deleteRssReaction(itemId) + } else { + await postRssReaction(itemId, reaction) + } + } catch { + reactions.value[itemId] = current ?? null + } +} + +function msgMetadata(msg: BriefingMessage): MessageMetadata | null { + return (msg.metadata as MessageMetadata | null | undefined) ?? null +} + // Manual trigger const triggering = ref(false) async function triggerNow() { @@ -192,12 +231,42 @@ onMounted(async () => {

Click "Refresh" to generate a briefing now, or wait for the scheduled slot.

- + { } .btn-send:disabled { opacity: 0.4; cursor: not-allowed; } .btn-send:hover:not(:disabled) { opacity: 0.9; } + +.rss-reactions { + display: flex; + flex-direction: column; + gap: 0.35rem; + padding: 0.5rem 0.75rem; + margin-bottom: 0.25rem; +} + +.rss-reaction-row { + display: flex; + align-items: center; + gap: 0.4rem; +} + +.reaction-label { + font-size: 0.78rem; + color: var(--color-text-muted); + min-width: 4rem; +} + +.reaction-btn { + background: none; + border: 1px solid var(--color-border); + border-radius: 6px; + padding: 0.15rem 0.4rem; + cursor: pointer; + font-size: 0.85rem; + line-height: 1.4; + opacity: 0.55; + transition: opacity 0.15s, border-color 0.15s; +} + +.reaction-btn:hover { + opacity: 1; + border-color: var(--color-primary); +} + +.reaction-btn.active { + opacity: 1; + border-color: var(--color-primary); + background: color-mix(in srgb, var(--color-primary) 12%, transparent); +} From 5f6107bbf82bc89ba0e6306a40fc468bd41a29b9 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 25 Mar 2026 10:46:59 -0400 Subject: [PATCH 26/45] feat(briefing): add News Preferences section with topic include/exclude inputs Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/views/SettingsView.vue | 66 +++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/frontend/src/views/SettingsView.vue b/frontend/src/views/SettingsView.vue index 7dbe453..d230735 100644 --- a/frontend/src/views/SettingsView.vue +++ b/frontend/src/views/SettingsView.vue @@ -7,6 +7,7 @@ import { apiGet, apiPost, apiPut, apiDelete, listGroups, createGroup, deleteGrou import { usePushStore } from "@/stores/push"; import type { User } from "@/types/auth"; import PaginationBar from "@/components/PaginationBar.vue"; +import TagInput from "@/components/TagInput.vue"; const store = useSettingsStore(); const authStore = useAuthStore(); @@ -268,6 +269,17 @@ const newFeedUrl = ref(''); const newFeedCategory = ref(''); const addingFeed = ref(false); const refreshingFeeds = ref(false); +const briefingIncludeTopics = ref([]); +const briefingExcludeTopics = ref([]); + +function _parseTopics(raw: unknown): string[] { + try { + const val = typeof raw === 'string' ? JSON.parse(raw) : raw; + return Array.isArray(val) ? val.map(String) : []; + } catch { + return []; + } +} async function loadBriefingTab() { briefingConfig.value = await getBriefingConfig(); @@ -276,6 +288,25 @@ async function loadBriefingTab() { if (!briefingConfig.value.timezone) { briefingConfig.value.timezone = Intl.DateTimeFormat().resolvedOptions().timeZone; } + const allSettings = await apiGet>('/api/settings').catch(() => ({} as Record)); + briefingIncludeTopics.value = _parseTopics(allSettings['briefing_include_topics'] ?? '[]'); + briefingExcludeTopics.value = _parseTopics(allSettings['briefing_exclude_topics'] ?? '[]'); +} + +async function saveIncludeTopics(topics: string[]) { + briefingIncludeTopics.value = topics; + await apiPut('/api/settings', { briefing_include_topics: JSON.stringify(topics) }); +} + +async function saveExcludeTopics(topics: string[]) { + briefingExcludeTopics.value = topics; + await apiPut('/api/settings', { briefing_exclude_topics: JSON.stringify(topics) }); +} + +const _STANDARD_TOPICS = ['technology', 'science', 'politics', 'business', 'health', 'environment', 'local', 'entertainment', 'sports', 'other']; +async function fetchTopicSuggestions(q: string): Promise { + if (!q) return _STANDARD_TOPICS; + return _STANDARD_TOPICS.filter((t) => t.startsWith(q.toLowerCase())); } async function geocodeLocation(key: 'home' | 'work') { @@ -1763,6 +1794,41 @@ function formatUserDate(iso: string): string {
+ +
+

News Preferences

+

+ Tell the briefing what topics you care about. Topics are matched against + classified RSS items before each briefing runs. +

+
+ + +
+
+ + +
+
+ Standard topic vocabulary +

+ technology · science · politics · business · health · environment · + local · entertainment · sports · other +

+

Custom terms are also accepted.

+
+
+

Notifications

From 940dd0c08e2b9d16e9718f108bc521edc8607fa8 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 25 Mar 2026 10:48:57 -0400 Subject: [PATCH 27/45] feat(fable-mcp): add RSS feed management tools (list/add/remove) Co-Authored-By: Claude Sonnet 4.6 --- fable-mcp/fable_mcp/server.py | 43 ++++++++++++++++++++++++++- fable-mcp/fable_mcp/tools/briefing.py | 32 ++++++++++++++++++++ 2 files changed, 74 insertions(+), 1 deletion(-) create mode 100644 fable-mcp/fable_mcp/tools/briefing.py diff --git a/fable-mcp/fable_mcp/server.py b/fable-mcp/fable_mcp/server.py index 492d068..bf0a361 100644 --- a/fable-mcp/fable_mcp/server.py +++ b/fable-mcp/fable_mcp/server.py @@ -5,7 +5,7 @@ from mcp.server.fastmcp import FastMCP from dotenv import load_dotenv from fable_mcp.client import FableClient -from fable_mcp.tools import notes, tasks, projects, milestones, search, chat, admin +from fable_mcp.tools import notes, tasks, projects, milestones, search, chat, admin, briefing load_dotenv() @@ -366,6 +366,47 @@ async def fable_get_app_logs( ) +# --------------------------------------------------------------------------- +# Briefing / RSS +# --------------------------------------------------------------------------- + + +@mcp.tool() +async def fable_list_rss_feeds() -> dict: + """List all RSS feeds configured in Fable for the current user.""" + async with FableClient() as client: + return await briefing.list_rss_feeds(client) + + +@mcp.tool() +async def fable_add_rss_feed( + url: str, + title: str = "", + category: str = "", +) -> dict: + """Add a new RSS feed to Fable. Title is optional — auto-populated from feed metadata. + + Args: + url: The RSS/Atom feed URL. + title: Optional display name override. + category: Optional category label (e.g. 'news', 'tech'). + """ + async with FableClient() as client: + return await briefing.add_rss_feed( + client, + url=url, + title=title or None, + category=category or None, + ) + + +@mcp.tool() +async def fable_remove_rss_feed(feed_id: int) -> dict: + """Remove an RSS feed from Fable by its ID.""" + async with FableClient() as client: + return await briefing.remove_rss_feed(client, feed_id=feed_id) + + # --------------------------------------------------------------------------- # Entry point # --------------------------------------------------------------------------- diff --git a/fable-mcp/fable_mcp/tools/briefing.py b/fable-mcp/fable_mcp/tools/briefing.py new file mode 100644 index 0000000..6c21c7e --- /dev/null +++ b/fable-mcp/fable_mcp/tools/briefing.py @@ -0,0 +1,32 @@ +"""MCP tools for Fable RSS feed management.""" +from __future__ import annotations + +from typing import Any + +from fable_mcp.client import FableClient + + +async def list_rss_feeds(client: FableClient) -> dict[str, Any]: + """List the user's RSS feeds.""" + return await client.get("/api/briefing/feeds") + + +async def add_rss_feed( + client: FableClient, + *, + url: str, + title: str | None = None, + category: str | None = None, +) -> dict[str, Any]: + """Add a new RSS feed. Title is optional — auto-populated from feed metadata.""" + payload: dict[str, Any] = {"url": url} + if title: + payload["title"] = title + if category: + payload["category"] = category + return await client.post("/api/briefing/feeds", json=payload) + + +async def remove_rss_feed(client: FableClient, *, feed_id: int) -> dict[str, Any]: + """Remove an RSS feed by ID.""" + return await client.delete(f"/api/briefing/feeds/{feed_id}") From e57ac2674973856f79891db4e599b8774a3b5a60 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 25 Mar 2026 10:55:18 -0400 Subject: [PATCH 28/45] docs: add quickstart compose file; condense README features section Adds docker-compose.quickstart.yml that pulls the pre-built image from the registry so users can get started without a local build. Updates README Quick Start to use the new file as the default path. Co-Authored-By: Claude Sonnet 4.6 --- README.md | 26 +++++------ docker-compose.quickstart.yml | 82 +++++++++++++++++++++++++++++++++++ 2 files changed, 93 insertions(+), 15 deletions(-) create mode 100644 docker-compose.quickstart.yml diff --git a/README.md b/README.md index 28d1b6c..a329927 100644 --- a/README.md +++ b/README.md @@ -4,30 +4,26 @@ A self-hosted second brain and project management application with integrated LL ## Features -- **Notes** — Markdown editor (Tiptap), wikilinks, backlinks, tags, version history, AI writing assist -- **Tasks** — Status, priority, due dates, sub-tasks, milestones, work logs -- **Projects** — Kanban view, milestone progress, three-panel project workspace (tasks / chat / notes) -- **AI Chat** — SSE streaming, RAG, tool calls (create/update content, search web, check weather, calendar) -- **Daily Briefing** — Scheduled digest of tasks, calendar, weather, and RSS; interactive follow-up conversation -- **Knowledge Graph** — D3 force-directed graph of all notes and tags -- **Sharing** — Share projects and notes with users or groups at viewer/editor/admin permissions -- **Fable MCP** — MCP server for Claude and other clients to read/write your data via API keys -- **PWA** — Installable, push notifications, web and Android companion app +Notes and tasks with a Markdown editor, sub-tasks, milestones, and kanban project workspaces. AI chat with streaming responses, RAG over your notes, and tool use (web search, calendar, weather). A daily briefing that digests your tasks, RSS feeds, and weather on a schedule. Knowledge graph, per-user/group sharing, PWA with push notifications, an MCP server for external AI clients, and an Android companion app. ## Quick Start **Prerequisites:** Docker and Docker Compose. 8 GB+ RAM recommended for LLM inference. +Download [`docker-compose.quickstart.yml`](docker-compose.quickstart.yml) from this repo, then: + ```bash -git clone https://git.fabledsword.com/bvandeusen/FabledAssistant.git -cd FabledAssistant -cp .env.example .env # Edit .env to set SECRET_KEY -docker compose up --build +# Optional but recommended — set a secret key +export SECRET_KEY=your-random-secret-here + +docker compose -f docker-compose.quickstart.yml up -d ``` -Open `http://localhost:5000`. The first user to register becomes admin. +Open `http://localhost:5000`. The first user to register becomes admin. Go to **Settings → General** to pull an LLM model — `qwen3:8b` or `llama3.1:8b` are good starting points. -Go to **Settings → General** to pull an LLM model. `qwen3:8b` or `llama3.1:8b` are good starting points. +> **GPU:** Ollama runs CPU-only by default. See the comments in `docker-compose.quickstart.yml` to enable NVIDIA GPU passthrough. + +> **Development:** To build from source, see [Development](docs/development.md). ## Documentation diff --git a/docker-compose.quickstart.yml b/docker-compose.quickstart.yml new file mode 100644 index 0000000..a5a294f --- /dev/null +++ b/docker-compose.quickstart.yml @@ -0,0 +1,82 @@ +# Fabled Assistant — Quick Start +# +# No build required. Pulls the latest pre-built image from the registry. +# +# Usage: +# 1. Download this file +# 2. docker compose -f docker-compose.quickstart.yml up -d +# 3. Open http://localhost:5000 — the first account registered becomes admin +# 4. Go to Settings → General to pull an LLM model (qwen3:8b or llama3.1:8b are good starting points) +# +# Set SECRET_KEY via environment variable or a .env file alongside this file: +# SECRET_KEY=your-random-secret-here + +services: + app: + image: git.fabledsword.com/bvandeusen/fabledassistant:latest + ports: + - "5000:5000" + environment: + DATABASE_URL: "postgresql+asyncpg://fabled:fabled@db:5432/fabledassistant" + SECRET_KEY: "${SECRET_KEY:-change-me-in-production}" + OLLAMA_URL: "http://ollama:11434" + OLLAMA_MODEL: "${OLLAMA_MODEL:-llama3.1:8b}" + LOG_LEVEL: "${LOG_LEVEL:-INFO}" + volumes: + - app_data:/data + depends_on: + db: + condition: service_healthy + ollama: + condition: service_healthy + restart: unless-stopped + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:5000/api/health')"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 30s + + db: + image: postgres:16-alpine + volumes: + - pgdata:/var/lib/postgresql/data + environment: + POSTGRES_USER: fabled + POSTGRES_PASSWORD: fabled + POSTGRES_DB: fabledassistant + healthcheck: + test: ["CMD-SHELL", "pg_isready -U fabled"] + interval: 10s + timeout: 5s + retries: 5 + restart: unless-stopped + + ollama: + image: ollama/ollama + volumes: + - ollama_models:/root/.ollama + environment: + OLLAMA_MAX_LOADED_MODELS: "2" + OLLAMA_KEEP_ALIVE: "30m" + OLLAMA_FLASH_ATTENTION: "1" + healthcheck: + test: ["CMD-SHELL", "ollama list > /dev/null 2>&1"] + interval: 30s + timeout: 10s + retries: 5 + start_period: 15s + restart: unless-stopped + # Uncomment to enable NVIDIA GPU passthrough (requires nvidia-container-toolkit): + # deploy: + # resources: + # reservations: + # devices: + # - driver: nvidia + # count: all + # capabilities: [gpu] + +volumes: + app_data: + pgdata: + ollama_models: From fe6afbad1783d0ca3919d3ee69dd8f7143dd8da8 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 25 Mar 2026 10:56:44 -0400 Subject: [PATCH 29/45] docs: update architecture and development docs with recent additions Co-Authored-By: Claude Sonnet 4.6 --- docs/architecture.md | 43 ++++++++++++++++++++++++++++++++++++++++--- docs/development.md | 3 ++- 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 4a37bf3..d6ee9fd 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -161,6 +161,10 @@ Permission resolution is centralised in `services/access.py`. `get_project_permi `rss_items`: `id`, `feed_id` FK, `guid`, `title`, `url`, `summary`, `pub_date`. `weather_cache`: per-user cache with `lat`, `lon`, `location_name`, `forecast_json`, `fetched_at`. +### API Keys + +`api_keys`: `id`, `user_id` FK CASCADE, `prefix` (first 8 chars, displayed in UI), `key_hash` (SHA-256 of full key — full key never stored), `name`, `scope` (`read`/`write`), `created_at`, `last_used_at`. + ### App Logs `category` (`audit`/`usage`/`error`), `user_id` FK nullable (SET NULL on delete), `username` (denormalised), `action`, `endpoint`, `method`, `status_code`, `duration_ms`, `error_type`, `error_message`, `traceback`, `details` JSONB. @@ -172,37 +176,68 @@ Permission resolution is centralised in `services/access.py`. `get_project_permi | File | Responsibility | |------|---------------| | `app.py` | Quart app factory; SPA via 404 handler; JSON 404/500 for API; request logging; security headers in `after_request` | -| `auth.py` | `login_required`, `admin_required`, `get_current_user_id` — shared `_check_auth()` helper | +| `auth.py` | `login_required`, `admin_required`, `get_current_user_id` — shared `_check_auth()` helper; accepts session cookie or `Authorization: Bearer ` | | `config.py` | All config from env vars + Docker secret file support (`_read_secret`); `SECURE_COOKIES`, `TRUST_PROXY_HEADERS`, `OLLAMA_NUM_CTX`; `oidc_enabled()`, `searxng_enabled()` classmethods | | `rate_limit.py` | In-memory sliding-window rate limiter (`asyncio.Lock` + `defaultdict`); `is_rate_limited(key, max, window)` | | `models/note.py` | Unified Note model (notes + tasks) | | `models/user.py` | `id`, `username`, `email`, `password_hash` (nullable — NULL for OAuth-only), `oauth_sub` (unique nullable), `role`, `session_version` | | `models/conversation.py` | `Conversation` + `Message` models | | `models/app_log.py` | `AppLog` with `category`, denormalised `username`, `details` JSONB | +| `models/api_key.py` | `ApiKey`: `id`, `user_id`, `prefix`, `key_hash` (SHA-256), `name`, `scope` (`read`/`write`), `created_at`, `last_used_at` | +| `models/event.py` | Dead code — CalDAV/Radicale internal events table. Radicale was trialled and removed; `calendar_sync.py` is also dead. | | `routes/api.py` | `/api` blueprint; `GET /api/health` (public) | | `routes/auth.py` | Register, login, logout, me, password/email change, password reset, invite registration, OAuth login+callback; rate limiting; `LOCAL_AUTH_ENABLED` guards | | `routes/admin.py` | Backup, restore, user management, registration toggle, invitations, base URL, SMTP (admin only) | | `routes/chat.py` | Conversations CRUD; SSE generation stream; model pull/delete/list/warm; briefing conversation routes | | `routes/notes.py` | Notes CRUD + wikilinks + backlinks + assist + link suggestions + version history + drafts | | `routes/tasks.py` | Tasks CRUD; `POST` accepts `project` name string (resolved to `project_id`) | +| `routes/task_logs.py` | Task work log CRUD (`GET/POST/DELETE /api/tasks/:id/logs`) | +| `routes/projects.py` | Projects CRUD + summary endpoint | +| `routes/milestones.py` | Milestones CRUD under `/api/projects/:id/milestones` | +| `routes/settings.py` | Per-user settings key-value (`GET/PUT /api/settings/:key`) | +| `routes/briefing.py` | Briefing conversation + reply + history; RSS feed management | +| `routes/groups.py` | Group CRUD + membership management (admin) | +| `routes/shares.py` | Share project/note with user or group; revoke; list incoming shares | +| `routes/in_app_notifications.py` | In-app notification list + mark-read + count | +| `routes/push.py` | Web Push subscription subscribe/unsubscribe; VAPID public key | +| `routes/users.py` | User profile; admin user list + delete | +| `routes/images.py` | Serve cached images at `/api/images/` | +| `routes/export.py` | `GET /api/export` — personal Markdown ZIP or JSON array download | +| `routes/api_keys.py` | API key CRUD (`GET/POST/DELETE /api/api-keys`) | +| `routes/fable_mcp_dist.py` | `GET /api/fable-mcp/info` + `GET /api/fable-mcp/download` — package distribution | | `routes/quick_capture.py` | `POST /api/quick-capture` — single-shot natural language item creation | | `routes/search.py` | `GET /api/search` — semantic + keyword hybrid search | | `services/auth.py` | `create_user`, `authenticate`, user lookups, password reset tokens, invitation tokens | | `services/oauth.py` | OIDC discovery (cached), PKCE auth URL, code exchange, `find_or_create_oauth_user` | +| `services/api_keys.py` | `generate_key()`, `create_api_key()`, `list_api_keys()`, `revoke_api_key()`, `lookup_key()` (SHA-256 hash lookup) | | `services/llm.py` | `build_context()`, RAG injection, history summarisation, `stream_chat_with_tools()`, URL fetching, SSRF guard | | `services/generation_task.py` | `run_generation()` — full chat pipeline: intent routing, tool loop, SSE fan-out, push notification; `run_assist_generation()` | | `services/intent.py` | `classify_intent()` — fast non-streaming LLM call; intent skip heuristic; `_PRIOR_WORK_REFS` fast-path | | `services/tools.py` | All LLM tool definitions + `execute_tool()` dispatcher; duplicate guards; `_resolve_project()` 4-step lookup | | `services/embeddings.py` | `upsert_note_embedding()`, `semantic_search_notes()` (pgvector cosine similarity) | | `services/generation_buffer.py` | In-memory SSE event buffer; `cancel_event`; 60s cleanup; supports both chat (int keys) and assist (string keys) | +| `services/notes.py` | Note CRUD, wikilink resolution, backlink queries, tag management | +| `services/note_versions.py` | Version snapshot on save; restore-from-version; diff metadata | +| `services/note_drafts.py` | Per-user per-note draft persistence (AI Assist pending state) | +| `services/settings.py` | `get_setting()`, `set_setting()`, `get_all_settings()` — key-value per user | +| `services/tag_suggestions.py` | `/api/notes/suggest-tags` — LLM-generated tag suggestions for note body | +| `services/access.py` | Permission resolution for all shared resources | +| `services/sharing.py` | Create/revoke shares; list shares; `get_shared_with_me()` | +| `services/groups.py` | Group CRUD; membership management | +| `services/notifications.py` | Create/read/mark-read in-app notifications | +| `services/task_logs.py` | Append/list/delete work log entries on tasks | | `services/briefing_pipeline.py` | Two-lane parallel gather → LLM synthesis → `GenerationBuffer` stream | | `services/briefing_scheduler.py` | APScheduler `BackgroundScheduler`; slots with catch-up logic; async-safe via `asyncio.create_task` | +| `services/briefing_conversations.py` | Briefing conversation persistence and history queries | +| `services/briefing_profile.py` | Per-user profile note that the assistant updates over time | | `services/research.py` | SearXNG research pipeline: 5 sub-queries → parallel fetch → synthesis; `search_images` for image category | | `services/caldav.py` | Full CalDAV event/todo lifecycle; synchronous library calls in asyncio executor | +| `services/calendar_sync.py` | Dead code — Radicale sync service; was trialled and removed | | `services/images.py` | `fetch_and_store_image()` — SHA-256 dedup, content-type validation, 5 MB cap | | `services/backup.py` | `export_full_backup()`, `export_user_backup()`, `restore_full_backup()` (version 2 with ID maps) | | `services/push.py` | VAPID key auto-generation; `send_push_notification()` fire-and-forget; 410 Gone cleanup | | `services/logging.py` | `log_audit`, `log_usage`, `log_error`, `start_log_retention_loop` (hourly cleanup) | +| `services/email.py` | SMTP email sending for password reset; reads `SMTP_*` env vars | | `services/weather.py` | Nominatim geocoding + Open-Meteo forecast + per-user DB cache | | `services/rss.py` | feedparser fetch; per-feed DB cache; prune-to-100 items | | `services/assist.py` | `build_assist_messages()` — section/whole-doc modes; project context injection | @@ -261,9 +296,11 @@ Permission resolution is centralised in `services/access.py`. `get_project_permi ## Authentication -Local username/password auth and OIDC/OAuth (PKCE). `services/oauth.py` handles discovery and `find_or_create_oauth_user`. On OAuth login: checks existing `oauth_sub` → matching email → creates new user. +**Session cookies** — `HttpOnly`, `SameSite=Lax`, optionally `Secure` (`SECURE_COOKIES` env var). Session includes `session_version`; mismatch with DB value (after password change) results in 401 and session clear. -Session cookies: `HttpOnly`, `SameSite=Lax`, optionally `Secure`. Session includes `session_version`; mismatch with DB value (after password change) results in 401 and session clear. +**Bearer token / API keys** — `Authorization: Bearer ` accepted by `_check_auth()` as an alternative to session cookies. The raw key is SHA-256 hashed and looked up via `services/api_keys.py`. Keys with `scope=read` are rejected on non-safe methods (`POST`/`PATCH`/`DELETE`). Used by Fable MCP and any external API consumers. + +**Local auth + OIDC/OAuth (PKCE)** — `services/oauth.py` handles discovery and `find_or_create_oauth_user`. On OAuth login: checks existing `oauth_sub` → matching email → creates new user. See [sso-oauth.md](sso-oauth.md) for provider-specific setup instructions. diff --git a/docs/development.md b/docs/development.md index ae21c2c..592ae86 100644 --- a/docs/development.md +++ b/docs/development.md @@ -117,7 +117,7 @@ Current migration sequence (all idempotent raw SQL): 0016 add_image_cache 0017 add_projects 0018 add_push_subscriptions -0019 add_note_parent_and_project_fields +0019 add_events (dead code — internal CalDAV/Radicale table; Radicale was removed) 0020 add_milestones 0021 add_task_logs 0022 add_note_versions_and_drafts @@ -125,6 +125,7 @@ Current migration sequence (all idempotent raw SQL): 0024 add_session_version 0025 add_sharing_and_notifications 0026 add_briefing_tables +0027 add_api_keys ``` **Important:** Do NOT use `op.create_table()` or `sa.Enum()` — SQLAlchemy's event system can fire `CREATE TYPE` even with `create_type=False`, causing failures on re-run. Always use raw SQL with `IF NOT EXISTS` / `DO $$ BEGIN ... EXCEPTION WHEN duplicate_object` guards. From 0e27be5b6370c1da605a9ab4d61915b66418f673 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 25 Mar 2026 11:41:14 -0400 Subject: [PATCH 30/45] docs: add internal calendar with CalDAV sync design spec Co-Authored-By: Claude Sonnet 4.6 --- .../2026-03-25-internal-calendar-design.md | 187 ++++++++++++++++++ 1 file changed, 187 insertions(+) create mode 100644 docs/specs/2026-03-25-internal-calendar-design.md diff --git a/docs/specs/2026-03-25-internal-calendar-design.md b/docs/specs/2026-03-25-internal-calendar-design.md new file mode 100644 index 0000000..8327021 --- /dev/null +++ b/docs/specs/2026-03-25-internal-calendar-design.md @@ -0,0 +1,187 @@ +# Internal Calendar with CalDAV Sync — Design Spec + +## Goal + +Add an internal event store to Fable's database, a full calendar UI (month/week grid), and a best-effort push sync to the user's existing external CalDAV server. Fable becomes the source of truth for events; CalDAV is an optional downstream target. + +## Background + +The existing `services/caldav.py` lets the AI assistant create, update, and delete events directly on an external CalDAV server (Nextcloud, etc.) via tool calls. There is no calendar UI and no internal event storage — all reads go to CalDAV at query time. The `models/event.py` and `events` DB table exist as dead code from an abandoned Radicale integration. + +This design revives the internal event store, adds a REST API and calendar view, and re-wires the AI tools to write through the internal DB so all events are accessible regardless of CalDAV availability. + +--- + +## Architecture + +### Source of Truth + +Fable's `events` DB table is the single source of truth. CalDAV sync is a best-effort push: after every create, update, or delete, a background task attempts to mirror the change to the user's configured CalDAV server. If CalDAV is unconfigured or unreachable, the operation still succeeds and the event remains in Fable. + +### Timezone Handling + +- **Storage:** `start_dt` / `end_dt` stored as UTC (`TIMESTAMPTZ`). +- **Display:** FullCalendar configured with `timeZone: 'local'` — renders in the browser's IANA timezone automatically. +- **Input:** Frontend datetime inputs are in local time; converted to a timezone-aware ISO string (with UTC offset) before the API call. Backend parses to UTC. +- **CalDAV push:** Uses the user's `caldav_timezone` setting for iCal `DTSTART`/`DTEND`, consistent with existing behaviour. +- **AI tools:** The LLM receives the user's timezone in the system prompt and sends timezone-aware strings, as today. + +--- + +## Data Model + +### `events` table (existing, extend via migration) + +| Column | Type | Notes | +|--------|------|-------| +| `id` | `SERIAL PK` | | +| `user_id` | `INT FK users CASCADE` | | +| `project_id` | `INT FK projects SET NULL` nullable | Optional link to a Fable project | +| `uid` | `TEXT` | iCal UID — UUID generated on create, used in CalDAV push | +| `caldav_uid` | `TEXT DEFAULT ''` | **New.** UID confirmed by CalDAV server after successful push; empty if never synced | +| `title` | `TEXT` | | +| `start_dt` | `TIMESTAMPTZ` | UTC | +| `end_dt` | `TIMESTAMPTZ` nullable | UTC; null for open-ended events | +| `all_day` | `BOOLEAN DEFAULT false` | When true, time component is ignored in display | +| `description` | `TEXT DEFAULT ''` | | +| `location` | `TEXT DEFAULT ''` | | +| `color` | `TEXT DEFAULT ''` | **New.** Hex colour for calendar display (e.g. `#6366f1`) | +| `recurrence` | `TEXT` nullable | iCal RRULE string (e.g. `FREQ=WEEKLY;BYDAY=MO`) | +| `created_at` | `TIMESTAMPTZ` | | +| `updated_at` | `TIMESTAMPTZ` | | + +**Migration `0029_add_calendar_columns`:** `ALTER TABLE events ADD COLUMN IF NOT EXISTS caldav_uid TEXT DEFAULT ''` and `ADD COLUMN IF NOT EXISTS color TEXT DEFAULT ''`. Raw SQL with `IF NOT EXISTS` guards, consistent with the project's migration convention. + +--- + +## Service Layer + +**`src/fabledassistant/services/events.py`** — owns all event CRUD and CalDAV push coordination. + +### Public functions + +``` +create_event(user_id, title, start_dt, end_dt, all_day, description, location, color, recurrence, project_id) → Event +get_event(user_id, event_id) → Event +list_events(user_id, date_from, date_to) → list[Event] +search_events(user_id, query, days_ahead=90) → list[Event] +update_event(user_id, event_id, **fields) → Event +delete_event(user_id, event_id) → None +``` + +### CalDAV push + +After every write to the DB: + +- `create_event` → `asyncio.create_task(_push_create(event, user_id))` +- `update_event` → `asyncio.create_task(_push_update(event, user_id))` +- `delete_event` → `asyncio.create_task(_push_delete(caldav_uid, user_id))` if `caldav_uid` is set + +Each push function calls the relevant function in `services/caldav.py`, writes `caldav_uid` back on success, and logs a warning on failure. CalDAV being unconfigured is treated as a no-op (not an error). + +The existing `services/caldav.py` is **not modified** — it remains a dumb push target. + +--- + +## REST API + +**`src/fabledassistant/routes/events.py`** — new blueprint, registered in `app.py`. + +| Method | Path | Description | +|--------|------|-------------| +| `GET` | `/api/events?from=&to=` | List events in ISO datetime range | +| `POST` | `/api/events` | Create event | +| `GET` | `/api/events/:id` | Get single event | +| `PATCH` | `/api/events/:id` | Partial update | +| `DELETE` | `/api/events/:id` | Delete event | + +All routes require `@login_required`. Ownership enforced in the service layer (404 if event not owned by requesting user). + +--- + +## AI Tool Rewiring + +The following tools in `services/tools.py` are updated to call `services/events.py` instead of `services/caldav.py` directly: + +| Tool | Change | +|------|--------| +| `create_event` | Calls `events.create_event(...)` | +| `list_events` | Calls `events.list_events(...)` | +| `search_events` | Calls `events.search_events(...)` | +| `update_event` | Queries internal DB by title `ILIKE`, then calls `events.update_event(...)` | +| `delete_event` | Queries internal DB by title `ILIKE`, then calls `events.delete_event(...)` | + +The tool interface (parameter names, LLM-facing descriptions) does not change. Users with an existing CalDAV connection see no behaviour change. + +--- + +## Frontend + +### Dependencies + +``` +@fullcalendar/vue3 +@fullcalendar/daygrid # month view +@fullcalendar/timegrid # week / day view +@fullcalendar/interaction # drag-to-move, resize, click-to-create +``` + +All MIT licensed. + +### Components + +**`frontend/src/views/CalendarView.vue`** — main view at `/calendar`: +- FullCalendar instance with `timeZone: 'local'`, `initialView: 'dayGridMonth'`, header toolbar toggling between month and week +- Loads events for the visible date range via `GET /api/events?from=&to=` +- Re-fetches when the visible range changes +- Click on empty cell → opens `EventSlideOver` in create mode +- Click on event → opens `EventSlideOver` in edit mode +- Drag event → `PATCH /api/events/:id` with new times (optimistic update, revert on error) +- Resize event → same + +**`frontend/src/components/EventSlideOver.vue`** — reusable slide-over panel (same pattern as the workspace task slide-over): +- Fields: title, start date/time, end date/time, all-day toggle, location, description, colour picker, project selector +- Create mode: `POST /api/events` +- Edit mode: `PATCH /api/events/:id` + delete button (`DELETE /api/events/:id`) +- Closes on save, cancel, or Escape + +**`frontend/src/api/client.ts`** — add typed helpers: +```typescript +listEvents(from: string, to: string): Promise +createEvent(body: CreateEventBody): Promise +getEvent(id: number): Promise +updateEvent(id: number, body: Partial): Promise +deleteEvent(id: number): Promise +``` + +### Navigation + +- `/calendar` added to Vue Router +- Sidebar nav item added +- Keyboard shortcut: `g` + `l` (ca**l**endar) — `c` is already taken by chat + +--- + +## Error Handling + +- CalDAV push failures are logged at `WARNING` level and do not surface to the user +- If CalDAV is unconfigured, push is skipped silently +- Drag-to-move failures (PATCH error) revert the event to its previous position in the UI +- 404 returned for event access by non-owner + +--- + +## Testing + +- `tests/test_events_service.py` — unit tests for `create_event`, `list_events`, `search_events`, `update_event`, `delete_event` with mocked DB session; separate test verifying CalDAV push is fired as a background task +- `tests/test_events_routes.py` — route tests for all five endpoints (create, list, get, update, delete) verifying ownership enforcement +- TypeScript check (`make typecheck`) after all frontend changes + +--- + +## Out of Scope + +- Pull sync (importing events from CalDAV into Fable) — possible future Option B upgrade +- Recurring event expansion in the UI (RRULE stored and pushed to CalDAV but not expanded client-side) +- Shared calendars / multi-user event invites +- Event notifications / reminders in the Fable push system From 651bc1ba7b6618216b2b62116182fb7c1cf2c87e Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 25 Mar 2026 11:47:52 -0400 Subject: [PATCH 31/45] docs: revise internal calendar spec based on reviewer feedback Co-Authored-By: Claude Sonnet 4.6 --- .../2026-03-25-internal-calendar-design.md | 81 ++++++++++++------- 1 file changed, 54 insertions(+), 27 deletions(-) diff --git a/docs/specs/2026-03-25-internal-calendar-design.md b/docs/specs/2026-03-25-internal-calendar-design.md index 8327021..1f6d87f 100644 --- a/docs/specs/2026-03-25-internal-calendar-design.md +++ b/docs/specs/2026-03-25-internal-calendar-design.md @@ -6,7 +6,7 @@ Add an internal event store to Fable's database, a full calendar UI (month/week ## Background -The existing `services/caldav.py` lets the AI assistant create, update, and delete events directly on an external CalDAV server (Nextcloud, etc.) via tool calls. There is no calendar UI and no internal event storage — all reads go to CalDAV at query time. The `models/event.py` and `events` DB table exist as dead code from an abandoned Radicale integration. +The existing `services/caldav.py` lets the AI assistant create, update, and delete events directly on an external CalDAV server (Nextcloud, etc.) via tool calls. There is no calendar UI and no internal event storage — all reads go to CalDAV at query time. The `events` DB table and `models/event.py` exist as dead code from an abandoned Radicale integration (created in migration `0019_add_events`); the table exists in the database but all columns are empty. This design revives the internal event store, adds a REST API and calendar view, and re-wires the AI tools to write through the internal DB so all events are accessible regardless of CalDAV availability. @@ -30,15 +30,15 @@ Fable's `events` DB table is the single source of truth. CalDAV sync is a best-e ## Data Model -### `events` table (existing, extend via migration) +### `events` table (existing since migration 0019, extend via migration 0029) | Column | Type | Notes | |--------|------|-------| | `id` | `SERIAL PK` | | | `user_id` | `INT FK users CASCADE` | | | `project_id` | `INT FK projects SET NULL` nullable | Optional link to a Fable project | -| `uid` | `TEXT` | iCal UID — UUID generated on create, used in CalDAV push | -| `caldav_uid` | `TEXT DEFAULT ''` | **New.** UID confirmed by CalDAV server after successful push; empty if never synced | +| `uid` | `TEXT` | iCal UID — UUID generated on `create_event`, embedded in CalDAV push | +| `caldav_uid` | `TEXT DEFAULT ''` | **New.** Same value as `uid` after a successful CalDAV push (confirms sync); empty if never synced | | `title` | `TEXT` | | | `start_dt` | `TIMESTAMPTZ` | UTC | | `end_dt` | `TIMESTAMPTZ` nullable | UTC; null for open-ended events | @@ -46,11 +46,18 @@ Fable's `events` DB table is the single source of truth. CalDAV sync is a best-e | `description` | `TEXT DEFAULT ''` | | | `location` | `TEXT DEFAULT ''` | | | `color` | `TEXT DEFAULT ''` | **New.** Hex colour for calendar display (e.g. `#6366f1`) | -| `recurrence` | `TEXT` nullable | iCal RRULE string (e.g. `FREQ=WEEKLY;BYDAY=MO`) | +| `recurrence` | `TEXT` nullable | iCal RRULE string — stored and forwarded to CalDAV; not exposed in the UI slide-over | | `created_at` | `TIMESTAMPTZ` | | | `updated_at` | `TIMESTAMPTZ` | | -**Migration `0029_add_calendar_columns`:** `ALTER TABLE events ADD COLUMN IF NOT EXISTS caldav_uid TEXT DEFAULT ''` and `ADD COLUMN IF NOT EXISTS color TEXT DEFAULT ''`. Raw SQL with `IF NOT EXISTS` guards, consistent with the project's migration convention. +**Migration `0029_add_calendar_columns`:** `ALTER TABLE events ADD COLUMN IF NOT EXISTS caldav_uid TEXT DEFAULT ''` and `ADD COLUMN IF NOT EXISTS color TEXT DEFAULT ''`. Raw SQL with `IF NOT EXISTS` guards (table already exists from migration 0019). + +**`models/event.py`:** Add two `mapped_column` declarations to the `Event` class: +```python +caldav_uid: Mapped[str] = mapped_column(Text, default="") +color: Mapped[str] = mapped_column(Text, default="") +``` +Update `to_dict()` to include `"caldav_uid": self.caldav_uid` and `"color": self.color`. --- @@ -61,25 +68,36 @@ Fable's `events` DB table is the single source of truth. CalDAV sync is a best-e ### Public functions ``` -create_event(user_id, title, start_dt, end_dt, all_day, description, location, color, recurrence, project_id) → Event +create_event(user_id, title, start_dt, end_dt, all_day, description, location, + color, recurrence, project_id, + duration, reminder_minutes, attendees, calendar_name) → Event get_event(user_id, event_id) → Event list_events(user_id, date_from, date_to) → list[Event] search_events(user_id, query, days_ahead=90) → list[Event] update_event(user_id, event_id, **fields) → Event delete_event(user_id, event_id) → None +find_events_by_query(user_id, query) → list[Event] ``` -### CalDAV push +`duration`, `reminder_minutes`, `attendees`, and `calendar_name` are accepted by `create_event` for forwarding to CalDAV (they are not stored in the DB). `find_events_by_query` does a case-insensitive `ILIKE` search on `title` and is used by the AI `update_event` / `delete_event` tools. + +### CalDAV push — UID strategy + +`create_event` generates a UUID and stores it as the event's `uid`. This same UUID must be embedded as the iCal `UID` field when pushing to CalDAV. To enable this, `services/caldav.py`'s `create_event` function is **minimally modified** to accept an optional `uid: str | None = None` parameter; when provided, `event.add("uid", uid)` is called before `cal.add_component(event)`. All other CalDAV functions are unchanged. + +On a successful push, `caldav_uid` is set to the same UUID value in the DB (confirming sync). This means `caldav_uid` is Fable's own UID confirmed-as-pushed, not a server-assigned value — which is fine since we generated it. After every write to the DB: -- `create_event` → `asyncio.create_task(_push_create(event, user_id))` +- `create_event` → `asyncio.create_task(_push_create(event, user_id, extra_fields))` - `update_event` → `asyncio.create_task(_push_update(event, user_id))` - `delete_event` → `asyncio.create_task(_push_delete(caldav_uid, user_id))` if `caldav_uid` is set -Each push function calls the relevant function in `services/caldav.py`, writes `caldav_uid` back on success, and logs a warning on failure. CalDAV being unconfigured is treated as a no-op (not an error). +Each push function calls the relevant function in `services/caldav.py`, marks `caldav_uid` on success, and logs a warning on failure. CalDAV being unconfigured is treated as a no-op (not an error). -The existing `services/caldav.py` is **not modified** — it remains a dumb push target. +### Match policy for AI update/delete tools + +`find_events_by_query` returns all events where `title ILIKE '%query%'`. The calling tool applies the same match-count policy as the existing CalDAV tools: zero matches → raise `ValueError("No event found matching '…'.")`; more than 3 matches → raise `ValueError("Too many matches (N) for '…'. Be more specific. Found: …")`. One or two matches use the first result. --- @@ -101,17 +119,15 @@ All routes require `@login_required`. Ownership enforced in the service layer (4 ## AI Tool Rewiring -The following tools in `services/tools.py` are updated to call `services/events.py` instead of `services/caldav.py` directly: +Calendar tools in `services/tools.py` are updated to call `services/events.py` instead of `services/caldav.py` directly. The `is_caldav_configured` gate that currently hides these tools is **removed** — calendar tools are always available since the internal DB is always present. The tool definitions currently grouped under `_CALDAV_TOOLS` (appended to the tools list only when `is_caldav_configured` is true) must be moved into the unconditional `_CORE_TOOLS` list (or equivalent always-included list). The tool parameter names and LLM-facing descriptions do not change. | Tool | Change | |------|--------| -| `create_event` | Calls `events.create_event(...)` | +| `create_event` | Calls `events.create_event(...)` — all existing params forwarded | | `list_events` | Calls `events.list_events(...)` | | `search_events` | Calls `events.search_events(...)` | -| `update_event` | Queries internal DB by title `ILIKE`, then calls `events.update_event(...)` | -| `delete_event` | Queries internal DB by title `ILIKE`, then calls `events.delete_event(...)` | - -The tool interface (parameter names, LLM-facing descriptions) does not change. Users with an existing CalDAV connection see no behaviour change. +| `update_event` | Calls `events.find_events_by_query(...)`, then `events.update_event(...)` | +| `delete_event` | Calls `events.find_events_by_query(...)`, then `events.delete_event(...)` | --- @@ -134,19 +150,30 @@ All MIT licensed. - FullCalendar instance with `timeZone: 'local'`, `initialView: 'dayGridMonth'`, header toolbar toggling between month and week - Loads events for the visible date range via `GET /api/events?from=&to=` - Re-fetches when the visible range changes -- Click on empty cell → opens `EventSlideOver` in create mode -- Click on event → opens `EventSlideOver` in edit mode -- Drag event → `PATCH /api/events/:id` with new times (optimistic update, revert on error) +- Click on empty date cell → opens `EventSlideOver` in create mode, pre-filled with the clicked date +- Click on existing event → opens `EventSlideOver` in edit mode +- Drag event to new date/time → `PATCH /api/events/:id` with updated times (optimistic update, revert on error) - Resize event → same **`frontend/src/components/EventSlideOver.vue`** — reusable slide-over panel (same pattern as the workspace task slide-over): -- Fields: title, start date/time, end date/time, all-day toggle, location, description, colour picker, project selector +- Fields: title (required), start date/time, end date/time, all-day toggle, location, description, colour picker, optional project selector +- `recurrence` is **not** a form field — it is stored and pushed to CalDAV via AI tools only - Create mode: `POST /api/events` - Edit mode: `PATCH /api/events/:id` + delete button (`DELETE /api/events/:id`) - Closes on save, cancel, or Escape -**`frontend/src/api/client.ts`** — add typed helpers: +**`frontend/src/api/client.ts`** — add typed helpers and `EventEntry` / `CreateEventBody` interfaces: + ```typescript +interface EventEntry { + id: number; uid: string; title: string; + start_dt: string; end_dt: string | null; + all_day: boolean; description: string; location: string; + color: string; recurrence: string | null; + project_id: number | null; caldav_uid: string; + created_at: string; updated_at: string; +} + listEvents(from: string, to: string): Promise createEvent(body: CreateEventBody): Promise getEvent(id: number): Promise @@ -166,22 +193,22 @@ deleteEvent(id: number): Promise - CalDAV push failures are logged at `WARNING` level and do not surface to the user - If CalDAV is unconfigured, push is skipped silently -- Drag-to-move failures (PATCH error) revert the event to its previous position in the UI +- Drag-to-move/resize failures (PATCH error) revert the event to its previous position in FullCalendar's UI via the `revert` callback - 404 returned for event access by non-owner --- ## Testing -- `tests/test_events_service.py` — unit tests for `create_event`, `list_events`, `search_events`, `update_event`, `delete_event` with mocked DB session; separate test verifying CalDAV push is fired as a background task -- `tests/test_events_routes.py` — route tests for all five endpoints (create, list, get, update, delete) verifying ownership enforcement +- `tests/test_events_service.py` — unit tests for `create_event`, `list_events`, `search_events`, `update_event`, `delete_event`, `find_events_by_query` with mocked DB session; separate test verifying CalDAV push background task is fired +- `tests/test_events_routes.py` — route tests for all five endpoints verifying ownership enforcement and correct status codes - TypeScript check (`make typecheck`) after all frontend changes --- ## Out of Scope -- Pull sync (importing events from CalDAV into Fable) — possible future Option B upgrade -- Recurring event expansion in the UI (RRULE stored and pushed to CalDAV but not expanded client-side) +- Pull sync (importing events from CalDAV into Fable) — possible future upgrade +- Recurring event expansion in the UI (RRULE stored and forwarded to CalDAV via AI tools only) - Shared calendars / multi-user event invites - Event notifications / reminders in the Fable push system From da55e32a1af6a3ccc97e49a2a18da88fee9e8fa9 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 25 Mar 2026 12:16:25 -0400 Subject: [PATCH 32/45] docs(calendar): implementation plan for internal calendar with CalDAV sync Co-Authored-By: Claude Sonnet 4.6 --- docs/plans/2026-03-25-internal-calendar.md | 1953 ++++++++++++++++++++ 1 file changed, 1953 insertions(+) create mode 100644 docs/plans/2026-03-25-internal-calendar.md diff --git a/docs/plans/2026-03-25-internal-calendar.md b/docs/plans/2026-03-25-internal-calendar.md new file mode 100644 index 0000000..b77a6f4 --- /dev/null +++ b/docs/plans/2026-03-25-internal-calendar.md @@ -0,0 +1,1953 @@ +# Internal Calendar with CalDAV Sync — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Revive the dormant `events` DB table as the source of truth for calendar events, add CRUD REST API, rewire AI tools to write through internal DB, and build a FullCalendar month/week UI with a slide-over form — with best-effort push sync to CalDAV. + +**Architecture:** Fable DB is source of truth; CalDAV push is fire-and-forget background tasks after each write. AI tools are always available (no `is_caldav_configured` gate). Two new columns (`caldav_uid`, `color`) are added to the existing `events` table via migration 0029. + +**Tech Stack:** Python/SQLAlchemy (service + routes), Quart (blueprint), FullCalendar Vue 3 (`@fullcalendar/vue3`, `daygrid`, `timegrid`, `interaction`), Vue 3 SFC. + +**Spec:** `docs/specs/2026-03-25-internal-calendar-design.md` + +--- + +## File Map + +| File | Action | Purpose | +|------|--------|---------| +| `alembic/versions/0029_add_calendar_columns.py` | Create | Migration: add `caldav_uid`, `color` to events table | +| `src/fabledassistant/models/event.py` | Modify | Add `caldav_uid`, `color` mapped columns; update `to_dict()` | +| `src/fabledassistant/services/caldav.py` | Modify | Add optional `uid` param to `create_event` for UID injection | +| `src/fabledassistant/services/events.py` | Create | CRUD service + CalDAV push coordination | +| `src/fabledassistant/routes/events.py` | Create | REST blueprint: GET/POST /api/events, GET/PATCH/DELETE /api/events/:id | +| `src/fabledassistant/app.py` | Modify | Register events blueprint | +| `src/fabledassistant/services/tools.py` | Modify | Rewire calendar tools to events.py; move to _CORE_TOOLS; remove gate | +| `frontend/src/api/client.ts` | Modify | Add `EventEntry` interface + 5 typed helpers | +| `frontend/package.json` | Modify | Add FullCalendar dependencies | +| `frontend/src/components/EventSlideOver.vue` | Create | Slide-over form for create/edit/delete | +| `frontend/src/views/CalendarView.vue` | Create | Month/week FullCalendar view | +| `frontend/src/router/index.ts` | Modify | Add `/calendar` route | +| `frontend/src/components/AppHeader.vue` | Modify | Add Calendar nav link | +| `frontend/src/App.vue` | Modify | Add `g`+`l` keyboard shortcut | +| `tests/test_events_service.py` | Create | Service unit tests (mocked DB) | +| `tests/test_events_routes.py` | Create | Route unit tests | + +--- + +## Task 1: Migration 0029 — add caldav_uid and color columns + +**Files:** +- Create: `alembic/versions/0029_add_calendar_columns.py` + +- [ ] **Step 1: Write the migration** + +```python +"""Add caldav_uid and color columns to events table.""" + +from alembic import op + +revision = "0029" +down_revision = "0028" + + +def upgrade() -> None: + op.execute(""" + ALTER TABLE events + ADD COLUMN IF NOT EXISTS caldav_uid TEXT DEFAULT '', + ADD COLUMN IF NOT EXISTS color TEXT DEFAULT '' + """) + + +def downgrade() -> None: + op.execute("ALTER TABLE events DROP COLUMN IF EXISTS color") + op.execute("ALTER TABLE events DROP COLUMN IF EXISTS caldav_uid") +``` + +- [ ] **Step 2: Commit** + +```bash +git add alembic/versions/0029_add_calendar_columns.py +git commit -m "feat(calendar): migration 0029 — add caldav_uid and color to events" +``` + +--- + +## Task 2: Update Event model + +**Files:** +- Modify: `src/fabledassistant/models/event.py` + +- [ ] **Step 1: Write the failing test** + +Create `tests/test_events_model.py`: +```python +def test_event_model_has_new_columns(): + from fabledassistant.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 fabledassistant.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" +``` + +- [ ] **Step 2: Run test to verify it fails** + +```bash +docker compose exec app python -m pytest tests/test_events_model.py -v +``` +Expected: FAIL with `AssertionError` (columns not yet present) + +- [ ] **Step 3: Add the two columns to the Event class** + +In `src/fabledassistant/models/event.py`, after the `location` column (line 27), add: +```python +caldav_uid: Mapped[str] = mapped_column(Text, default="") +color: Mapped[str] = mapped_column(Text, default="") +``` + +Update `to_dict()` to include both new fields (add after `"location": self.location,`): +```python +"caldav_uid": self.caldav_uid, +"color": self.color, +``` + +Also add `user_id` to `to_dict()` since routes will need it for ownership checks. After `"id": self.id,` add: +```python +"user_id": self.user_id, +``` + +- [ ] **Step 4: Run test to verify it passes** + +```bash +docker compose exec app python -m pytest tests/test_events_model.py -v +``` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add src/fabledassistant/models/event.py tests/test_events_model.py +git commit -m "feat(calendar): add caldav_uid, color to Event model" +``` + +--- + +## Task 3: Patch caldav.py — optional uid parameter for create_event + +**Files:** +- Modify: `src/fabledassistant/services/caldav.py:165-257` + +- [ ] **Step 1: Write the failing test** + +Add to `tests/test_events_model.py`: +```python +def test_caldav_create_event_accepts_uid_param(): + """caldav.create_event signature must accept an optional uid param.""" + import inspect + from fabledassistant.services.caldav import create_event + sig = inspect.signature(create_event) + assert "uid" in sig.parameters +``` + +- [ ] **Step 2: Run test to verify it fails** + +```bash +docker compose exec app python -m pytest tests/test_events_model.py::test_caldav_create_event_accepts_uid_param -v +``` +Expected: FAIL + +- [ ] **Step 3: Add optional uid parameter to caldav.create_event** + +In `src/fabledassistant/services/caldav.py`, modify the `create_event` function signature (line 165) to add `uid: str | None = None` as the last positional parameter (before `**kwargs` if any, otherwise just append): + +```python +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: +``` + +Then, after the line `event = icalendar.Event()` (around line 195), add before `event.add("summary", title)`: +```python +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) +``` + +- [ ] **Step 4: Run test to verify it passes** + +```bash +docker compose exec app python -m pytest tests/test_events_model.py -v +``` +Expected: PASS (both tests) + +- [ ] **Step 5: Commit** + +```bash +git add src/fabledassistant/services/caldav.py tests/test_events_model.py +git commit -m "feat(calendar): add optional uid param to caldav.create_event" +``` + +--- + +## Task 4: Create services/events.py — CRUD + CalDAV push + +**Files:** +- Create: `src/fabledassistant/services/events.py` +- Test: `tests/test_events_service.py` + +- [ ] **Step 1: Write the failing tests** + +Create `tests/test_events_service.py`: +```python +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=""): + 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.end_dt = datetime(2026, 3, 25, 11, 0, tzinfo=timezone.utc) + 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, + } + return e + + +@pytest.mark.asyncio +async def test_create_event_stores_to_db(): + mock_session = _make_mock_session() + with patch("fabledassistant.services.events.async_session") as mock_cls, \ + patch("fabledassistant.services.events.asyncio.create_task") as mock_task: + mock_cls.return_value = mock_session + from fabledassistant.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("fabledassistant.services.events.async_session") as mock_cls: + mock_cls.return_value = mock_session + from fabledassistant.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("fabledassistant.services.events.async_session") as mock_cls: + mock_cls.return_value = mock_session + from fabledassistant.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() + # get_event query + mock_result.scalar_one_or_none.return_value = mock_event + mock_session.execute = AsyncMock(return_value=mock_result) + + with patch("fabledassistant.services.events.async_session") as mock_cls, \ + patch("fabledassistant.services.events.asyncio.create_task") as mock_task: + mock_cls.return_value = mock_session + from fabledassistant.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("fabledassistant.services.events.async_session") as mock_cls, \ + patch("fabledassistant.services.events.asyncio.create_task") as mock_task: + mock_cls.return_value = mock_session + from fabledassistant.services.events import update_event + await update_event(user_id=1, event_id=1, title="Updated Title") + assert mock_task.called +``` + +- [ ] **Step 2: Run test to verify it fails** + +```bash +docker compose exec app python -m pytest tests/test_events_service.py -v +``` +Expected: FAIL with `ModuleNotFoundError` (service doesn't exist yet) + +- [ ] **Step 3: Implement services/events.py** + +Create `src/fabledassistant/services/events.py`: +```python +"""Internal event store service with CalDAV push sync.""" +from __future__ import annotations + +import asyncio +import logging +import uuid +from datetime import datetime, timedelta, timezone + +from sqlalchemy import and_, or_, select + +from fabledassistant.models import async_session +from fabledassistant.models.event import Event + +logger = logging.getLogger(__name__) + + +async def create_event( + user_id: int, + title: str, + start_dt: datetime, + end_dt: datetime | None = None, + all_day: bool = False, + description: str = "", + location: str = "", + color: str = "", + recurrence: str | None = None, + project_id: int | None = None, + # CalDAV-only fields (not stored in DB, forwarded to push) + duration: int | None = None, + reminder_minutes: 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.""" + uid = str(uuid.uuid4()) + async with async_session() as session: + event = Event( + user_id=user_id, + uid=uid, + title=title, + start_dt=start_dt, + end_dt=end_dt, + all_day=all_day, + description=description, + location=location, + color=color, + recurrence=recurrence, + project_id=project_id, + ) + session.add(event) + await session.commit() + await session.refresh(event) + + extra_fields = { + "duration": duration, + "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) + ) + return result.scalar_one_or_none() + + +async def list_events( + user_id: int, + date_from: datetime, + date_to: datetime, +) -> list[Event]: + """List events for user_id within [date_from, date_to].""" + async with async_session() as session: + result = await session.execute( + select(Event).where( + Event.user_id == user_id, + Event.start_dt >= date_from, + Event.start_dt <= date_to, + ).order_by(Event.start_dt) + ) + return result.scalars().all() + + +async def search_events( + user_id: int, + query: str, + days_ahead: int = 90, +) -> list[Event]: + """Search events by keyword in title, description, or location.""" + now = datetime.now(timezone.utc) + date_to = now + timedelta(days=days_ahead) + q = f"%{query}%" + async with async_session() as session: + result = await session.execute( + select(Event).where( + Event.user_id == user_id, + Event.start_dt >= now, + Event.start_dt <= date_to, + or_( + Event.title.ilike(q), + Event.description.ilike(q), + Event.location.ilike(q), + ), + ).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.""" + 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 None + old_title = event.title # capture before mutation for CalDAV lookup + allowed = {"title", "start_dt", "end_dt", "all_day", "description", + "location", "color", "recurrence", "project_id"} + for key, value in fields.items(): + if key in allowed and value is not None: + setattr(event, key, value) + 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.""" + q = f"%{query}%" + async with async_session() as session: + result = await session.execute( + select(Event).where( + Event.user_id == user_id, + Event.title.ilike(q), + ).order_by(Event.start_dt) + ) + return result.scalars().all() + + +# --------------------------------------------------------------------------- +# CalDAV push helpers (fire-and-forget) +# --------------------------------------------------------------------------- + +async def _push_create(event: Event, user_id: int, extra: dict) -> None: + try: + from fabledassistant.services.caldav import ( + create_event as caldav_create, + is_caldav_configured, + ) + if not await is_caldav_configured(user_id): + return + await caldav_create( + user_id=user_id, + title=event.title, + start=event.start_dt.isoformat(), + end=event.end_dt.isoformat() if event.end_dt 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 fabledassistant.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 + await caldav_update( + user_id=user_id, + query=query_title, + title=event.title, + start=event.start_dt.isoformat(), + end=event.end_dt.isoformat() if event.end_dt else None, + description=event.description or None, + location=event.location or None, + ) + 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 fabledassistant.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) +``` + +- [ ] **Step 4: Run tests to verify they pass** + +```bash +docker compose exec app python -m pytest tests/test_events_service.py -v +``` +Expected: PASS (all 5 tests) + +- [ ] **Step 5: Commit** + +```bash +git add src/fabledassistant/services/events.py tests/test_events_service.py +git commit -m "feat(calendar): implement events service with CalDAV push" +``` + +--- + +## Task 5: Create routes/events.py + register in app.py + +**Files:** +- Create: `src/fabledassistant/routes/events.py` +- Modify: `src/fabledassistant/app.py` +- Test: `tests/test_events_routes.py` + +- [ ] **Step 1: Write the failing tests** + +Create `tests/test_events_routes.py`: +```python +import pytest +from unittest.mock import AsyncMock, patch, MagicMock +from datetime import datetime, timezone + + +def _make_mock_event(): + e = MagicMock() + e.id = 1 + e.user_id = 1 + e.uid = "uid-abc" + e.title = "Meeting" + e.caldav_uid = "" + e.color = "" + e.start_dt = datetime(2026, 3, 25, 10, 0, tzinfo=timezone.utc) + e.end_dt = None + e.all_day = False + e.description = "" + e.location = "" + e.recurrence = None + e.project_id = None + e.created_at = datetime(2026, 3, 25, tzinfo=timezone.utc) + e.updated_at = datetime(2026, 3, 25, tzinfo=timezone.utc) + e.to_dict.return_value = {"id": 1, "title": "Meeting", "user_id": 1} + return e + + +def test_events_blueprint_registered(): + """events_bp must be importable from its module.""" + from fabledassistant.routes.events import events_bp + assert events_bp.name == "events" + + +@pytest.mark.asyncio +async def test_list_events_requires_from_and_to(): + """GET /api/events without from/to returns 400.""" + from fabledassistant.app import create_app + app = create_app() + async with app.test_client() as client: + with patch("fabledassistant.routes.events.login_required", lambda f: f), \ + patch("fabledassistant.routes.events._get_current_user_id", return_value=1): + response = await client.get("/api/events") + assert response.status_code == 400 + + +@pytest.mark.asyncio +async def test_get_event_404_for_nonowner(): + """GET /api/events/:id returns 404 when owned by different user.""" + from fabledassistant.app import create_app + app = create_app() + async with app.test_client() as client: + with patch("fabledassistant.routes.events.login_required", lambda f: f), \ + patch("fabledassistant.routes.events._get_current_user_id", return_value=2), \ + patch("fabledassistant.routes.events.events_svc.get_event", new_callable=AsyncMock) as mock_get: + mock_get.return_value = None # service returns None for non-owner + response = await client.get("/api/events/1") + assert response.status_code == 404 + + +@pytest.mark.asyncio +async def test_patch_event_404_for_nonowner(): + """PATCH /api/events/:id returns 404 for non-owner.""" + from fabledassistant.app import create_app + app = create_app() + async with app.test_client() as client: + with patch("fabledassistant.routes.events.login_required", lambda f: f), \ + patch("fabledassistant.routes.events._get_current_user_id", return_value=2), \ + patch("fabledassistant.routes.events.events_svc.update_event", new_callable=AsyncMock) as mock_upd: + mock_upd.return_value = None # service returns None for non-owner + response = await client.patch("/api/events/1", json={"title": "New"}) + assert response.status_code == 404 + + +@pytest.mark.asyncio +async def test_delete_event_404_for_nonowner(): + """DELETE /api/events/:id returns 404 for non-owner.""" + from fabledassistant.app import create_app + app = create_app() + async with app.test_client() as client: + with patch("fabledassistant.routes.events.login_required", lambda f: f), \ + patch("fabledassistant.routes.events._get_current_user_id", return_value=2), \ + patch("fabledassistant.routes.events.events_svc.get_event", new_callable=AsyncMock) as mock_get: + mock_get.return_value = None # service returns None for non-owner + response = await client.delete("/api/events/1") + assert response.status_code == 404 +``` + +- [ ] **Step 2: Run test to verify it fails** + +```bash +docker compose exec app python -m pytest tests/test_events_routes.py::test_events_blueprint_registered -v +``` +Expected: FAIL with ImportError + +- [ ] **Step 3: Implement routes/events.py** + +Create `src/fabledassistant/routes/events.py`: +```python +"""Calendar events REST API.""" +from __future__ import annotations + +from datetime import datetime + +from quart import Blueprint, g, jsonify, request + +from fabledassistant.auth import login_required +import fabledassistant.services.events as events_svc + +events_bp = Blueprint("events", __name__, url_prefix="/api/events") + + +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 = datetime.fromisoformat(date_from_str) + date_to = datetime.fromisoformat(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([e.to_dict() for e in 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 = datetime.fromisoformat(data["start_dt"]) + end_dt = datetime.fromisoformat(data["end_dt"]) if data.get("end_dt") else None + except ValueError: + return jsonify({"error": "Invalid datetime format"}), 400 + event = await events_svc.create_event( + user_id=_get_current_user_id(), + title=data["title"], + start_dt=start_dt, + end_dt=end_dt, + 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"), + ) + 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",): + if int_field in data: + fields[int_field] = data[int_field] + for dt_field in ("start_dt", "end_dt"): + if dt_field in data and data[dt_field]: + try: + fields[dt_field] = datetime.fromisoformat(data[dt_field]) + except ValueError: + return jsonify({"error": f"Invalid datetime for {dt_field}"}), 400 + event = await events_svc.update_event( + user_id=_get_current_user_id(), + event_id=event_id, + **fields, + ) + 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): + 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 + await events_svc.delete_event( + user_id=_get_current_user_id(), + event_id=event_id, + ) + return "", 204 +``` + +- [ ] **Step 4: Register the blueprint in app.py** + +In `src/fabledassistant/app.py`, add to the imports: +```python +from fabledassistant.routes.events import events_bp +``` +And in `create_app()`, alongside the other `app.register_blueprint(...)` calls, add: +```python +app.register_blueprint(events_bp) +``` + +- [ ] **Step 5: Run tests** + +```bash +docker compose exec app python -m pytest tests/test_events_routes.py -v +``` +Expected: PASS + +- [ ] **Step 6: Commit** + +```bash +git add src/fabledassistant/routes/events.py src/fabledassistant/app.py tests/test_events_routes.py +git commit -m "feat(calendar): events REST API blueprint" +``` + +--- + +## Task 6: Rewire AI tools to events service + +**Files:** +- Modify: `src/fabledassistant/services/tools.py` + +The changes: +1. Replace the `caldav` imports for event functions with `events` service imports +2. Move `_CALDAV_TOOLS` contents (the 5 event tools) into `_CORE_TOOLS` +3. Remove the `list_calendars` tool from `_CALDAV_TOOLS` (CalDAV-only, leave it conditional) +4. Remove `is_caldav_configured` from imports (still imported for `list_calendars` gate) +5. Update `execute_tool` for `create_event`, `list_events`, `search_events`, `update_event`, `delete_event` + +- [ ] **Step 1: Write the failing test** + +Add to `tests/test_events_service.py`: +```python +@pytest.mark.asyncio +async def test_tools_calendar_always_available(): + """Calendar tools must appear in get_tools_for_user even without CalDAV.""" + with patch("fabledassistant.services.tools.is_caldav_configured", new_callable=AsyncMock) as mock_configured: + mock_configured.return_value = False + from fabledassistant.services.tools import get_tools_for_user + tools = await get_tools_for_user(user_id=1) + tool_names = {t["function"]["name"] for t in tools} + assert "create_event" in tool_names + assert "list_events" in tool_names + assert "search_events" in tool_names + assert "update_event" in tool_names + assert "delete_event" in tool_names +``` + +- [ ] **Step 2: Run test to verify it fails** + +```bash +docker compose exec app python -m pytest tests/test_events_service.py::test_tools_calendar_always_available -v +``` +Expected: FAIL (tools only present when caldav is configured) + +- [ ] **Step 3: Rewire tools.py** + +In `src/fabledassistant/services/tools.py`: + +**A. Replace event-related caldav imports** (lines 9-17): +```python +from fabledassistant.services.caldav import ( + is_caldav_configured, + list_calendars, +) +from fabledassistant.services.events import ( + create_event as events_create_event, + list_events as events_list_events, + search_events as events_search_events, + update_event as events_update_event, + delete_event as events_delete_event, + find_events_by_query, +) +``` + +**B. Move the 5 event tool definitions** (currently in `_CALDAV_TOOLS` at lines 579-757) from `_CALDAV_TOOLS` into `_CORE_TOOLS` (append before the closing `]` of `_CORE_TOOLS`). Leave `list_calendars` alone in a renamed `_CALDAV_TOOLS` that only contains it: + +```python +# CalDAV tools — only when CalDAV is configured (server listing) +_CALDAV_TOOLS = [ + { + "type": "function", + "function": { + "name": "list_calendars", + "description": "List all available calendars. Use this when the user asks which calendars they have.", + "parameters": {"type": "object", "properties": {}}, + }, + }, +] +``` + +**C. Update `get_tools_for_user`** (lines 834-844) — the function body stays the same; `_CALDAV_TOOLS` now only contains `list_calendars` so the gate is still correct. + +**D. Update `execute_tool` for the 5 event handlers** to call the `events_*` functions: + +Replace the `create_event` block (around line 1225): +```python +elif tool_name == "create_event": + from fabledassistant.services.events import create_event as _create_ev + from datetime import datetime, timezone + start_str = arguments["start"] + end_str = arguments.get("end") + all_day = arguments.get("all_day", False) + tz_str = arguments.get("timezone") + + def _parse_dt(s: str) -> datetime: + dt = datetime.fromisoformat(s) + if dt.tzinfo is None and tz_str: + from zoneinfo import ZoneInfo + dt = dt.replace(tzinfo=ZoneInfo(tz_str)) + elif dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt.astimezone(timezone.utc) + + start_dt = _parse_dt(start_str) + end_dt = _parse_dt(end_str) if end_str else None + event = await _create_ev( + user_id=user_id, + title=arguments.get("title", "Untitled Event"), + start_dt=start_dt, + end_dt=end_dt, + all_day=all_day, + description=arguments.get("description", ""), + location=arguments.get("location", ""), + color=arguments.get("color", ""), + recurrence=arguments.get("recurrence"), + duration=arguments.get("duration"), + reminder_minutes=arguments.get("reminder_minutes"), + attendees=arguments.get("attendees"), + calendar_name=arguments.get("calendar_name"), + ) + return {"success": True, "type": "event", "data": event.to_dict()} +``` + +Replace the `list_events` block: +```python +elif tool_name == "list_events": + from fabledassistant.services.events import list_events as _list_ev + from datetime import datetime, timezone + dt_from = datetime.fromisoformat(arguments["date_from"]) + dt_to = datetime.fromisoformat(arguments["date_to"]) + if dt_from.tzinfo is None: + dt_from = dt_from.replace(tzinfo=timezone.utc) + if dt_to.tzinfo is None: + dt_to = dt_to.replace(tzinfo=timezone.utc) + events = await _list_ev(user_id=user_id, date_from=dt_from, date_to=dt_to) + return {"success": True, "type": "events", "data": {"count": len(events), "events": [e.to_dict() for e in events]}} +``` + +Replace `search_events`: +```python +elif tool_name == "search_events": + from fabledassistant.services.events import search_events as _search_ev + events = await _search_ev(user_id=user_id, query=arguments.get("query", "")) + return {"success": True, "type": "events", "data": {"count": len(events), "events": [e.to_dict() for e in events]}} +``` + +Replace `update_event`: +```python +elif tool_name == "update_event": + from fabledassistant.services.events import find_events_by_query as _find_ev, update_event as _upd_ev + query = arguments["query"] + matches = await _find_ev(user_id=user_id, query=query) + if not matches: + return {"success": False, "error": f"No event found matching '{query}'."} + if len(matches) > 3: + titles = [e.title for e in matches] + return {"success": False, "error": f"Too many matches ({len(matches)}) for '{query}'. Be more specific. Found: {', '.join(titles[:10])}"} + event = matches[0] + from datetime import datetime, timezone + fields: dict = {} + for k in ("title", "description", "location", "color"): + if arguments.get(k) is not None: + fields[k] = arguments[k] + for dt_k, arg_k in (("start_dt", "start"), ("end_dt", "end")): + if arguments.get(arg_k): + dt = datetime.fromisoformat(arguments[arg_k]) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + fields[dt_k] = dt.astimezone(timezone.utc) + updated = await _upd_ev(user_id=user_id, event_id=event.id, **fields) + return {"success": True, "type": "event_updated", "data": updated.to_dict() if updated else {}} +``` + +Replace `delete_event`: +```python +elif tool_name == "delete_event": + from fabledassistant.services.events import find_events_by_query as _find_ev2, delete_event as _del_ev + query = arguments["query"] + matches = await _find_ev2(user_id=user_id, query=query) + if not matches: + return {"success": False, "error": f"No event found matching '{query}'."} + if len(matches) > 3: + titles = [e.title for e in matches] + return {"success": False, "error": f"Too many matches ({len(matches)}) for '{query}'. Be more specific. Found: {', '.join(titles[:10])}"} + event = matches[0] + await _del_ev(user_id=user_id, event_id=event.id) + return {"success": True, "type": "event_deleted", "data": {"id": event.id, "title": event.title}} +``` + +- [ ] **Step 4: Run tests** + +```bash +docker compose exec app python -m pytest tests/test_events_service.py -v +``` +Expected: PASS (all tests including the new one) + +- [ ] **Step 5: Commit** + +```bash +git add src/fabledassistant/services/tools.py tests/test_events_service.py +git commit -m "feat(calendar): rewire AI calendar tools to internal events service" +``` + +--- + +## Task 7: Frontend API helpers in client.ts + +**Files:** +- Modify: `frontend/src/api/client.ts` + +- [ ] **Step 1: Add EventEntry interface and 5 helpers** + +At the end of `frontend/src/api/client.ts`, add: + +```typescript +// ── Calendar Events ────────────────────────────────────────────────────────── + +export interface EventEntry { + id: number; + uid: string; + title: string; + start_dt: string; + end_dt: string | null; + all_day: boolean; + description: string; + location: string; + color: string; + recurrence: string | null; + project_id: number | null; + caldav_uid: string; + created_at: string; + updated_at: string; +} + +export interface CreateEventBody { + title: string; + start_dt: string; + end_dt?: string | null; + all_day?: boolean; + description?: string; + location?: string; + color?: string; + recurrence?: string | null; + project_id?: number | null; +} + +export function listEvents(from: string, to: string): Promise { + return apiGet(`/api/events?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`); +} + +export function createEvent(body: CreateEventBody): Promise { + return apiPost("/api/events", body); +} + +export function getEvent(id: number): Promise { + return apiGet(`/api/events/${id}`); +} + +export function updateEvent(id: number, body: Partial): Promise { + return apiPatch(`/api/events/${id}`, body); +} + +export function deleteEvent(id: number): Promise { + return apiDelete(`/api/events/${id}`); +} +``` + +- [ ] **Step 2: Run typecheck** + +```bash +docker compose exec frontend npx vue-tsc --noEmit +``` +Expected: no errors + +- [ ] **Step 3: Commit** + +```bash +git add frontend/src/api/client.ts +git commit -m "feat(calendar): add EventEntry types and API helpers to client.ts" +``` + +--- + +## Task 8: Install FullCalendar packages + +**Files:** +- Modify: `frontend/package.json` + +- [ ] **Step 1: Install FullCalendar** + +```bash +docker compose exec frontend npm install @fullcalendar/vue3 @fullcalendar/daygrid @fullcalendar/timegrid @fullcalendar/interaction +``` + +- [ ] **Step 2: Verify the packages appear in package.json** + +```bash +grep fullcalendar frontend/package.json +``` +Expected: 4 lines with `@fullcalendar/...` + +- [ ] **Step 3: Run typecheck** + +```bash +docker compose exec frontend npx vue-tsc --noEmit +``` +Expected: no errors + +- [ ] **Step 4: Commit** + +```bash +git add frontend/package.json frontend/package-lock.json +git commit -m "feat(calendar): add FullCalendar dependencies" +``` + +--- + +## Task 9: Create EventSlideOver.vue + +**Files:** +- Create: `frontend/src/components/EventSlideOver.vue` + +- [ ] **Step 1: Create EventSlideOver.vue** + +Create `frontend/src/components/EventSlideOver.vue`: + +```vue + + +