Compare commits

...

35 Commits

Author SHA1 Message Date
bvandeusen f8b7b51832 Merge pull request 'Release v26.03.29.1 — security fixes and MCP bug fixes' (#18) from dev into main
Release v26.03.29.1 — security fixes and MCP bug fixes
2026-03-29 19:40:35 +00:00
bvandeusen 0cbeb6b7ac fix: honour status param on project and milestone creation
create_project and create_milestone hardcoded status="active" and
ignored any value passed by the MCP or API callers. Route, service,
and model construction now all thread the status field through.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 15:19:50 -04:00
bvandeusen 218f946e48 fix: fable_add_task_log sends content not body to match API
The MCP tool was sending {"body": ...} but the task logs API route
expects {"content": ...}, causing 400 errors.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 15:16:13 -04:00
bvandeusen 00643c778e security: fix 10 vulnerabilities from security audit
- SSRF: block private/internal URLs in image cache fetch
- SSRF: block private/internal URLs in RSS feed fetch (scheme guard)
- SSRF: block private/internal URLs in CalDAV URL setting
- Auth: require login for GET /api/images/<id> (was unauthenticated)
- Auth: restrict Ollama model pull/delete to admin users only
- Info disclosure: remove email from /api/users/search response
- OAuth: skip email-based account linking when email_verified is false
- Config: raise hard error on default SECRET_KEY when SECURE_COOKIES=true
- Rate limit: document proxy header requirement; add startup warning
- XSS: remove src/alt from global DOMPurify ADD_ATTR allowlist

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 00:37:13 -04:00
bvandeusen 20e2333b63 Merge pull request 'Release v26.03.28.2 — bug fixes and add_rss_feed tool' (#17) from dev into main
Release v26.03.28.2 — bug fixes and add_rss_feed tool
2026-03-28 18:02:59 +00:00
bvandeusen 024075329d feat: add add_rss_feed LLM tool so users can add feeds via chat
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 13:50:13 -04:00
bvandeusen 697d99cc4d fix: pass temp_unit from API to WeatherCard so F/C label matches converted values 2026-03-28 13:21:30 -04:00
bvandeusen 164c55e845 fix: increase contrast on app version display in settings 2026-03-28 13:16:15 -04:00
bvandeusen c77c13684f fix: cast feed_id parameter to integer to resolve asyncpg AmbiguousParameterError 2026-03-28 12:56:33 -04:00
bvandeusen 7b95150101 fix: read temp_unit from briefing_config not a nonexistent temperature_unit setting 2026-03-28 12:49:36 -04:00
bvandeusen 96a07690a8 fix: use COALESCE(published_at, fetched_at) in news query — articles with no publish date were silently dropped 2026-03-28 12:32:19 -04:00
bvandeusen 6da4b098e3 fix: /api/briefing/weather returns card-format data for WeatherCard component 2026-03-28 12:30:11 -04:00
bvandeusen 7fdb2ee39d chore: ignore docs/superpowers, docs/plans, docs/specs — keep only root docs 2026-03-28 12:16:23 -04:00
bvandeusen 83cee46078 feat: auto-bump fable-mcp patch version on commit via Claude Code hook
- scripts/bump_fable_mcp_version.sh: increments patch in pyproject.toml and stages it
- scripts/pre_commit_fable_mcp.sh: PreToolUse Bash hook — fires before git commits,
  bumps version if fable-mcp files (other than pyproject.toml itself) are staged
- .claude/settings.json: registers the PreToolUse hook

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 02:14:20 -04:00
bvandeusen 534e0a3a34 v26.03.28.1 — News/briefing redesign, task enhancements, recurrence 2026-03-28 05:38:26 +00:00
bvandeusen 51d2fd9d0a fix: migration 0031 no-op — status column is TEXT not a PG enum 2026-03-28 01:17:00 -04:00
bvandeusen ac90548823 feat: task management enhancements (cancelled status, recurrence, timestamps)
- Add 'cancelled' status to TaskStatus type, StatusBadge, TaskCard,
  TaskEditorView, TaskViewerView, TasksListView
- Add RecurrenceEditor component (none / interval / calendar rules)
- TaskEditorView: wire RecurrenceEditor, show started_at/completed_at
  timestamps read-only, include recurrence_rule in save payload
- TaskViewerView: show recurrence summary, timestamps in meta row
- tasks.ts: statusFilter/priorityFilter as arrays, add recurrence_rule
  to updateTask and createTask payloads

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 00:51:36 -04:00
bvandeusen 37e2420192 feat: add /news archive view with feed filter and pagination
- NewsView.vue: 90-day article archive, feed filter dropdown,
  load-more pagination, topic pills, thumbs up/down reactions
- Router: add /news route
- AppHeader: add News nav link (desktop + mobile)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 00:51:24 -04:00
bvandeusen f81c38df84 feat: redesign BriefingView into 3-column layout (weather · chat · news)
- Left column: weather loaded independently via /api/briefing/weather
- Center column: chat messages with input bar (unchanged behavior)
- Right column: news panel loaded from /api/briefing/news (last 2 days)
- Auto-scroll to bottom on mount and after streaming
- Background refresh also refreshes news panel
- Responsive: stacks to single column on narrow screens
- Fix TaskCard.vue to include 'cancelled' in status records

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 00:38:04 -04:00
bvandeusen 0a6e57e698 feat: add NewsItem type and getNewsItems() API client function
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 00:34:34 -04:00
bvandeusen 260103d533 feat: inject briefing article content for deep article Q&A
Add _build_briefing_article_context() helper to llm.py that reads
rss_item_ids from briefing message metadata and injects article content
into the system prompt. Pass conv_id through build_context() and
generation_task.py.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 00:31:43 -04:00
bvandeusen 35f57e0d3e feat: add GET /api/briefing/news unified news endpoint
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 00:29:59 -04:00
bvandeusen 57bf63e576 feat: extend RSS item retention from 14 to 90 days
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 00:29:32 -04:00
bvandeusen aa18f4f527 docs: add news briefing redesign spec and plan
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 00:13:40 -04:00
bvandeusen 3391825550 docs: add news feed & briefing redesign spec
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 23:59:34 -04:00
bvandeusen 3d7c953627 feat: add recurrence_rule to LLM tools; list_tasks status accepts list + cancelled 2026-03-27 23:02:30 -04:00
bvandeusen 6557fef6a2 feat: support multi-value status and priority filters in list_notes 2026-03-27 23:01:24 -04:00
bvandeusen 0dc3dfa539 feat: add recurrence_rule validation and recurrence-preview endpoint in tasks routes 2026-03-27 22:54:11 -04:00
bvandeusen 3179b60eac feat: wire recurrence into create_note/update_note and add daily APScheduler job 2026-03-27 22:53:20 -04:00
bvandeusen a24257aeed feat: add recurrence service (validate, calculate_next_due, spawn_recurring_tasks) 2026-03-27 22:52:18 -04:00
bvandeusen c271f1b41f feat: add recurrence_rule and recurrence_next_spawn_at columns to notes 2026-03-27 22:49:59 -04:00
bvandeusen c92f4944cc feat: auto-set started_at/completed_at on task status transitions 2026-03-27 22:49:21 -04:00
bvandeusen 1125c8e107 feat: add started_at/completed_at columns to notes 2026-03-27 22:37:55 -04:00
bvandeusen 7888788d42 Merge pull request 'Release v26.03.27.2' (#15) from dev into main
Release v26.03.27.2
2026-03-27 21:23:53 +00:00
bvandeusen 7a12cba4d5 feat: add 'cancelled' task status; fix 500 on invalid status/priority
TaskStatus enum was missing 'cancelled' — the LLM tried to use it and
hit TaskStatus("cancelled") raising ValueError → 500. Added the value,
a migration to extend the task_status Postgres enum, and proper 400
validation guards on both create and update task routes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 09:18:16 -04:00
60 changed files with 2191 additions and 8803 deletions
+2
View File
@@ -23,6 +23,8 @@ settings.local.json
# Claude Code
.claude/
docs/superpowers/
docs/plans/
docs/specs/
# Environment
.env
@@ -0,0 +1,21 @@
"""Add 'cancelled' task status.
The status column is plain TEXT (not a PostgreSQL enum type), so no DDL
change is required — the application layer already accepts the new value.
"""
from alembic import op
revision = "0031"
down_revision = "0030"
branch_labels = None
depends_on = None
def upgrade() -> None:
# No-op: status is stored as TEXT; the new value is valid without DDL changes.
pass
def downgrade() -> None:
pass
@@ -0,0 +1,19 @@
"""Add started_at and completed_at to notes."""
from alembic import op
import sqlalchemy as sa
revision = "0032"
down_revision = "0031"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("notes", sa.Column("started_at", sa.DateTime(timezone=True), nullable=True))
op.add_column("notes", sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True))
def downgrade() -> None:
op.drop_column("notes", "completed_at")
op.drop_column("notes", "started_at")
@@ -0,0 +1,30 @@
"""Add recurrence_rule and recurrence_next_spawn_at to notes."""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import JSONB
revision = "0033"
down_revision = "0032"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("notes", sa.Column("recurrence_rule", JSONB(), nullable=True))
op.add_column(
"notes",
sa.Column("recurrence_next_spawn_at", sa.DateTime(timezone=True), nullable=True),
)
op.create_index(
"ix_notes_recurrence_next_spawn_at",
"notes",
["recurrence_next_spawn_at"],
postgresql_where=sa.text("recurrence_next_spawn_at IS NOT NULL"),
)
def downgrade() -> None:
op.drop_index("ix_notes_recurrence_next_spawn_at", table_name="notes")
op.drop_column("notes", "recurrence_next_spawn_at")
op.drop_column("notes", "recurrence_rule")
-538
View File
@@ -1,538 +0,0 @@
# Backup Service Rewrite Plan
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Rewrite `services/backup.py` so that full and user backup/restore correctly includes every model added since the original implementation (projects, milestones, task logs, drafts, versions), with correct FK re-mapping on restore.
**Architecture:** Bump backup JSON format to version 2. Export is additive — all new tables exported. Restore builds an ID map for each table and patches FK references in the correct dependency order. V1 backups continue to restore via the existing code path.
**Tech Stack:** Python/SQLAlchemy 2.0 async, no new dependencies.
**Spec:** `docs/superpowers/specs/2026-03-11-backup-rewrite-design.md`
**Dependency note:** This plan can be split into Part A (pre-sharing models) and Part B (sharing models). Part A is independent and should be done first.
---
## Task 1: Extend export — full backup
**Files:**
- Modify: `src/fabledassistant/services/backup.py`
- [ ] **Step 1: Read the current `export_full_backup()` function**
Understand what is currently exported: users, notes (partial fields), conversations+messages, settings.
- [ ] **Step 2: Rewrite `export_full_backup()` to version 2**
Add all missing models to the import list at the top of `backup.py`:
```python
from fabledassistant.models.project import Project
from fabledassistant.models.milestone import Milestone
from fabledassistant.models.task_log import TaskLog
from fabledassistant.models.note_draft import NoteDraft
from fabledassistant.models.note_version import NoteVersion
from fabledassistant.models.push_subscription import PushSubscription
```
Rewrite `export_full_backup()`:
```python
async def export_full_backup() -> dict:
async with async_session() as session:
users = (await session.execute(select(User))).scalars().all()
projects = (await session.execute(select(Project))).scalars().all()
milestones = (await session.execute(select(Milestone))).scalars().all()
notes = (await session.execute(select(Note))).scalars().all()
task_logs = (await session.execute(select(TaskLog))).scalars().all()
note_drafts = (await session.execute(select(NoteDraft))).scalars().all()
note_versions = (await session.execute(select(NoteVersion).order_by(NoteVersion.note_id, NoteVersion.version_number))).scalars().all()
conversations = (await session.execute(
select(Conversation).options(selectinload(Conversation.messages))
)).scalars().all()
settings = (await session.execute(select(Setting))).scalars().all()
push_subs = (await session.execute(select(PushSubscription))).scalars().all()
return {
"version": 2,
"scope": "full",
"exported_at": datetime.now(timezone.utc).isoformat(),
"_security_notice": (
"This backup contains hashed passwords and push subscription keys. "
"Store it securely and restrict access."
),
"users": [
{
"id": u.id,
"username": u.username,
"email": u.email,
"password_hash": u.password_hash,
"oauth_sub": u.oauth_sub,
"role": u.role,
"session_version": u.session_version,
"created_at": u.created_at.isoformat(),
}
for u in users
],
"projects": [
{
"id": p.id,
"user_id": p.user_id,
"title": p.title,
"description": p.description,
"goal": p.goal,
"status": p.status,
"color": p.color,
"created_at": p.created_at.isoformat(),
"updated_at": p.updated_at.isoformat(),
}
for p in projects
],
"milestones": [
{
"id": m.id,
"user_id": m.user_id,
"project_id": m.project_id,
"title": m.title,
"description": m.description,
"status": m.status,
"order_index": m.order_index,
"created_at": m.created_at.isoformat(),
"updated_at": m.updated_at.isoformat(),
}
for m in milestones
],
"notes": [
{
"id": n.id,
"user_id": n.user_id,
"title": n.title,
"body": n.body,
"tags": n.tags or [],
"parent_id": n.parent_id,
"project_id": n.project_id,
"milestone_id": n.milestone_id,
"is_task": n.is_task,
"status": n.status,
"priority": n.priority,
"due_date": n.due_date.isoformat() if n.due_date else None,
"is_starred": getattr(n, "is_starred", False),
"is_pinned": getattr(n, "is_pinned", False),
"created_at": n.created_at.isoformat(),
"updated_at": n.updated_at.isoformat(),
}
for n in notes
],
"task_logs": [
{
"id": tl.id,
"user_id": tl.user_id,
"note_id": tl.note_id,
"description": tl.description,
"duration_minutes": tl.duration_minutes,
"logged_at": tl.logged_at.isoformat() if tl.logged_at else None,
"created_at": tl.created_at.isoformat(),
}
for tl in task_logs
],
"note_drafts": [
{
"id": nd.id,
"user_id": nd.user_id,
"note_id": nd.note_id,
"title": nd.title,
"body": nd.body,
"saved_at": nd.saved_at.isoformat() if nd.saved_at else None,
}
for nd in note_drafts
],
"note_versions": [
{
"id": nv.id,
"user_id": nv.user_id,
"note_id": nv.note_id,
"title": nv.title,
"body": nv.body,
"version_number": nv.version_number,
"created_at": nv.created_at.isoformat(),
}
for nv in note_versions
],
"conversations": [
{
"id": c.id,
"user_id": c.user_id,
"title": c.title,
"created_at": c.created_at.isoformat(),
"updated_at": c.updated_at.isoformat(),
"messages": [
{
"id": m.id,
"role": m.role,
"content": m.content,
"context_note_id": m.context_note_id,
"created_at": m.created_at.isoformat(),
}
for m in c.messages
],
}
for c in conversations
],
"settings": [
{"user_id": s.user_id, "key": s.key, "value": s.value}
for s in settings
],
"push_subscriptions": [
{
"user_id": ps.user_id,
"endpoint": ps.endpoint,
"p256dh": ps.p256dh,
"auth": ps.auth,
"created_at": ps.created_at.isoformat(),
}
for ps in push_subs
],
}
```
Check what exact field names exist on each model before assuming. Use `getattr(obj, "field", default)` for fields that may not exist on older DB versions.
- [ ] **Step 3: Commit**
```bash
git add src/fabledassistant/services/backup.py
git commit -m "feat(backup): v2 full export with projects, milestones, task_logs, drafts, versions"
```
---
## Task 2: Extend export — user backup
**Files:**
- Modify: `src/fabledassistant/services/backup.py`
- [ ] **Step 1: Rewrite `export_user_backup(user_id)`**
Same structure as full backup but filtered to `WHERE user_id = uid`. Apply same field additions. Omit sensitive fields (password_hash, oauth_sub) from user self-export.
```python
async def export_user_backup(user_id: int) -> dict:
async with async_session() as session:
user = await session.get(User, user_id)
projects = (await session.execute(select(Project).where(Project.user_id == user_id))).scalars().all()
milestones = (await session.execute(select(Milestone).where(Milestone.user_id == user_id))).scalars().all()
notes = (await session.execute(select(Note).where(Note.user_id == user_id))).scalars().all()
task_logs = (await session.execute(select(TaskLog).where(TaskLog.user_id == user_id))).scalars().all()
note_drafts = (await session.execute(select(NoteDraft).where(NoteDraft.user_id == user_id))).scalars().all()
note_versions = (await session.execute(
select(NoteVersion).where(NoteVersion.user_id == user_id)
.order_by(NoteVersion.note_id, NoteVersion.version_number)
)).scalars().all()
conversations = (await session.execute(
select(Conversation).options(selectinload(Conversation.messages))
.where(Conversation.user_id == user_id)
)).scalars().all()
settings = (await session.execute(select(Setting).where(Setting.user_id == user_id))).scalars().all()
return {
"version": 2,
"scope": "user",
"exported_at": datetime.now(timezone.utc).isoformat(),
"user": {
"id": user.id,
"username": user.username,
"email": user.email,
"role": user.role,
"created_at": user.created_at.isoformat(),
} if user else None,
"projects": [...], # same as full but user-filtered
"milestones": [...],
"notes": [...],
"task_logs": [...],
"note_drafts": [...],
"note_versions": [...],
"conversations": [...],
"settings": [...],
}
```
- [ ] **Step 2: Commit**
```bash
git add src/fabledassistant/services/backup.py
git commit -m "feat(backup): v2 user backup with all models"
```
---
## Task 3: Rewrite restore for v2
**Files:**
- Modify: `src/fabledassistant/services/backup.py`
- [ ] **Step 1: Add version dispatch to `restore_full_backup`**
```python
async def restore_full_backup(data: dict) -> dict:
version = data.get("version", 1)
if version == 1:
return await _restore_v1(data)
return await _restore_v2(data)
```
Move existing restore code into `_restore_v1(data)` with no changes.
- [ ] **Step 2: Implement `_restore_v2(data)` with full FK re-mapping**
Restore order (respects FK dependencies):
1. Users → build `user_id_map`
2. Projects (fk: user_id) → build `project_id_map`
3. Milestones (fk: user_id, project_id) → build `milestone_id_map`
4. Notes — first pass: insert without `parent_id` (fk: user_id, project_id, milestone_id) → build `note_id_map`
5. Notes — second pass: patch `parent_id` using `note_id_map`
6. TaskLogs (fk: user_id, note_id via note_id_map)
7. NoteDrafts (fk: user_id, note_id via note_id_map)
8. NoteVersions (fk: user_id, note_id via note_id_map) — export only, no restore by default (skip unless flag set)
9. Conversations (fk: user_id) → Messages (fk: conversation_id, context_note_id via note_id_map)
10. Settings (fk: user_id)
```python
async def _restore_v2(data: dict) -> dict:
from datetime import date, datetime, timezone
stats = {
"users": 0, "projects": 0, "milestones": 0, "notes": 0,
"task_logs": 0, "note_drafts": 0, "conversations": 0,
"messages": 0, "settings": 0
}
async with async_session() as session:
user_id_map: dict[int, int] = {}
project_id_map: dict[int, int] = {}
milestone_id_map: dict[int, int] = {}
note_id_map: dict[int, int] = {}
# 1. Users
for u_data in data.get("users", []):
old_id = u_data["id"]
user = User(
username=u_data["username"],
email=u_data.get("email"),
password_hash=u_data.get("password_hash"),
oauth_sub=u_data.get("oauth_sub"),
role=u_data.get("role", "user"),
session_version=u_data.get("session_version", 1),
created_at=datetime.fromisoformat(u_data["created_at"]) if u_data.get("created_at") else datetime.now(timezone.utc),
)
session.add(user)
await session.flush()
user_id_map[old_id] = user.id
stats["users"] += 1
# 2. Projects
for p_data in data.get("projects", []):
mapped_uid = user_id_map.get(p_data.get("user_id", 0))
if mapped_uid is None:
continue
proj = Project(
user_id=mapped_uid,
title=p_data.get("title", ""),
description=p_data.get("description"),
goal=p_data.get("goal"),
status=p_data.get("status", "active"),
color=p_data.get("color"),
created_at=datetime.fromisoformat(p_data["created_at"]) if p_data.get("created_at") else datetime.now(timezone.utc),
updated_at=datetime.fromisoformat(p_data["updated_at"]) if p_data.get("updated_at") else datetime.now(timezone.utc),
)
session.add(proj)
await session.flush()
project_id_map[p_data["id"]] = proj.id
stats["projects"] += 1
# 3. Milestones
for m_data in data.get("milestones", []):
mapped_uid = user_id_map.get(m_data.get("user_id", 0))
mapped_pid = project_id_map.get(m_data.get("project_id", 0))
if mapped_uid is None:
continue
ms = Milestone(
user_id=mapped_uid,
project_id=mapped_pid,
title=m_data.get("title", ""),
description=m_data.get("description"),
status=m_data.get("status", "open"),
order_index=m_data.get("order_index", 0),
created_at=datetime.fromisoformat(m_data["created_at"]) if m_data.get("created_at") else datetime.now(timezone.utc),
updated_at=datetime.fromisoformat(m_data["updated_at"]) if m_data.get("updated_at") else datetime.now(timezone.utc),
)
session.add(ms)
await session.flush()
milestone_id_map[m_data["id"]] = ms.id
stats["milestones"] += 1
# 4. Notes — first pass (no parent_id)
note_objects: list[tuple[int, int]] = [] # (old_parent_id, new_note_id)
for n_data in data.get("notes", []):
mapped_uid = user_id_map.get(n_data.get("user_id", 0))
if mapped_uid is None:
continue
due = None
if n_data.get("due_date"):
due = date.fromisoformat(n_data["due_date"])
note = Note(
user_id=mapped_uid,
title=n_data.get("title", ""),
body=n_data.get("body", ""),
tags=n_data.get("tags", []),
parent_id=None, # patched in second pass
project_id=project_id_map.get(n_data.get("project_id")) if n_data.get("project_id") else None,
milestone_id=milestone_id_map.get(n_data.get("milestone_id")) if n_data.get("milestone_id") else None,
is_task=n_data.get("is_task", False),
status=n_data.get("status"),
priority=n_data.get("priority"),
due_date=due,
created_at=datetime.fromisoformat(n_data["created_at"]) if n_data.get("created_at") else datetime.now(timezone.utc),
updated_at=datetime.fromisoformat(n_data["updated_at"]) if n_data.get("updated_at") else datetime.now(timezone.utc),
)
session.add(note)
await session.flush()
note_id_map[n_data["id"]] = note.id
if n_data.get("parent_id"):
note_objects.append((n_data["parent_id"], note.id))
stats["notes"] += 1
# 5. Notes — second pass: patch parent_id
for old_parent_id, new_note_id in note_objects:
new_parent_id = note_id_map.get(old_parent_id)
if new_parent_id:
note_row = await session.get(Note, new_note_id)
if note_row:
note_row.parent_id = new_parent_id
# 6. TaskLogs
for tl_data in data.get("task_logs", []):
mapped_uid = user_id_map.get(tl_data.get("user_id", 0))
mapped_nid = note_id_map.get(tl_data.get("note_id", 0))
if mapped_uid is None or mapped_nid is None:
continue
from fabledassistant.models.task_log import TaskLog
tl = TaskLog(
user_id=mapped_uid,
note_id=mapped_nid,
description=tl_data.get("description", ""),
duration_minutes=tl_data.get("duration_minutes"),
logged_at=datetime.fromisoformat(tl_data["logged_at"]) if tl_data.get("logged_at") else None,
created_at=datetime.fromisoformat(tl_data["created_at"]) if tl_data.get("created_at") else datetime.now(timezone.utc),
)
session.add(tl)
stats["task_logs"] += 1
# 7. NoteDrafts
for nd_data in data.get("note_drafts", []):
mapped_uid = user_id_map.get(nd_data.get("user_id", 0))
mapped_nid = note_id_map.get(nd_data.get("note_id", 0))
if mapped_uid is None or mapped_nid is None:
continue
from fabledassistant.models.note_draft import NoteDraft
nd = NoteDraft(
user_id=mapped_uid,
note_id=mapped_nid,
title=nd_data.get("title", ""),
body=nd_data.get("body", ""),
saved_at=datetime.fromisoformat(nd_data["saved_at"]) if nd_data.get("saved_at") else None,
)
session.add(nd)
stats["note_drafts"] += 1
# 8. Conversations + Messages
for c_data in data.get("conversations", []):
mapped_uid = user_id_map.get(c_data.get("user_id", 0))
if mapped_uid is None:
continue
conv = Conversation(
user_id=mapped_uid,
title=c_data.get("title", ""),
created_at=datetime.fromisoformat(c_data["created_at"]) if c_data.get("created_at") else datetime.now(timezone.utc),
updated_at=datetime.fromisoformat(c_data["updated_at"]) if c_data.get("updated_at") else datetime.now(timezone.utc),
)
session.add(conv)
await session.flush()
stats["conversations"] += 1
for m_data in c_data.get("messages", []):
msg = Message(
conversation_id=conv.id,
role=m_data["role"],
content=m_data.get("content", ""),
context_note_id=note_id_map.get(m_data["context_note_id"]) if m_data.get("context_note_id") else None,
created_at=datetime.fromisoformat(m_data["created_at"]) if m_data.get("created_at") else datetime.now(timezone.utc),
)
session.add(msg)
stats["messages"] += 1
# 9. Settings
for s_data in data.get("settings", []):
mapped_uid = user_id_map.get(s_data.get("user_id", 0))
if mapped_uid is None:
continue
setting = Setting(user_id=mapped_uid, key=s_data["key"], value=s_data.get("value", ""))
session.add(setting)
stats["settings"] += 1
await session.commit()
logger.info("Restored v2 backup: %s", stats)
return stats
```
- [ ] **Step 3: Add missing imports at top of file**
```python
from datetime import datetime, timezone
from fabledassistant.models.project import Project
from fabledassistant.models.milestone import Milestone
```
- [ ] **Step 4: Test restore round-trip**
```bash
# Export
curl -s -b cookies.txt "http://localhost:5000/api/admin/backup?scope=full" -o /tmp/backup_v2.json
# Inspect structure
python3 -c "import json; d=json.load(open('/tmp/backup_v2.json')); print(list(d.keys()))"
# Expected keys: version, scope, exported_at, users, projects, milestones, notes, task_logs, ...
```
- [ ] **Step 5: Typecheck**
```bash
docker compose exec app python -m py_compile src/fabledassistant/services/backup.py
```
Expected: No errors.
- [ ] **Step 6: Commit**
```bash
git add src/fabledassistant/services/backup.py
git commit -m "feat(backup): v2 restore with full FK re-mapping; v1 restore preserved"
```
---
## Task 4: Verification
- [ ] **Step 1: Full round-trip test**
1. Export full backup as admin
2. Verify JSON has `"version": 2` and all expected top-level keys
3. Verify notes have `project_id`, `milestone_id`, `is_task` fields
4. Verify `task_logs` array has entries (create a task log entry first if needed)
5. Restore backup to a clean test instance (or verify restore code path runs without error in dry-run)
- [ ] **Step 2: V1 backward compat test**
Take an old v1 backup JSON (or construct one without the `version` key) and run restore. Verify it takes the v1 path and doesn't error.
- [ ] **Step 3: Commit final verification note**
```bash
git commit --allow-empty -m "chore(backup): v2 backup rewrite verified"
```
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,285 +0,0 @@
# Briefing Service Improvements — Design Spec
**Date:** 2026-03-25
**Status:** Approved
**Scope:** Web-first (no Android changes in this cycle)
---
## Problem Statement
The daily briefing has several usability issues:
1. **Task repetition** — tasks are restated identically every day regardless of whether anything changed, making the briefing feel stale and hard to scan.
2. **RSS repetition** — the same news stories resurface across days with no mechanism to learn what the user cares about.
3. **No path to sources** — news items are summarised in prose with no link to the original article.
4. **Stale weather** — if the weather cache is outdated, the briefing silently uses old data rather than failing gracefully. The current prose weather format is also hard to scan.
5. **No feedback loop** — there is no way to teach the briefing what topics are interesting or uninteresting.
---
## Approach
**Pre-processing pipeline with explicit state tracking.** Rather than relying on the synthesis LLM to handle deduplication and filtering, we add deterministic pre-processing steps before synthesis runs. Each concern is isolated: task change detection, RSS topic classification and filtering, weather staleness gating. The synthesis LLM receives pre-filtered, structured input and focuses on tone and flow.
---
## Data Model
### Migration: `0028_add_briefing_improvements`
**`rss_items` table — two new columns**
```sql
ALTER TABLE rss_items ADD COLUMN IF NOT EXISTS topics TEXT[] DEFAULT '{}';
ALTER TABLE rss_items ADD COLUMN IF NOT EXISTS classified_at TIMESTAMPTZ;
```
`topics` stores LLM-assigned topic tags (e.g. `["technology", "ai"]`). `classified_at` is NULL until classification runs, allowing backfill queries. The `RssFeed` / `RssItem` SQLAlchemy model (`models/rss_feed.py`) must also be updated to add these two mapped columns and expose them in `to_dict()`.
**`messages` table — new metadata column**
```sql
ALTER TABLE messages ADD COLUMN IF NOT EXISTS metadata JSONB;
```
The briefing pipeline populates this when it creates the compiled message. The frontend reads it when loading the conversation to render the `WeatherCard` and attach reaction buttons. No SSE events are needed — structured data travels with the message record.
Schema stored in `metadata`:
```json
{
"weather": {
"location": "Berlin",
"fetched_at": "2026-03-25T06:00:00Z",
"current_temp": 12,
"condition": "Partly Cloudy",
"today_high": 16,
"today_low": 8,
"yesterday_high": 14,
"yesterday_low": 9,
"forecast": [
{"day": "Wed", "condition": "Sunny", "high": 18, "low": 10},
{"day": "Thu", "condition": "Cloudy", "high": 14, "low": 9}
]
},
"rss_item_ids": [42, 17, 89, 103, 55]
}
```
If weather is unavailable, `metadata.weather` is `null` and the card renders a failure placeholder. The `Message` SQLAlchemy model (`models/conversation.py`) must be updated to add the `metadata` mapped column.
**New table: `rss_item_reactions`**
```sql
CREATE TABLE IF NOT EXISTS rss_item_reactions (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
rss_item_id INTEGER NOT NULL REFERENCES rss_items(id) ON DELETE CASCADE,
reaction TEXT NOT NULL CHECK (reaction IN ('up', 'down')),
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE (user_id, rss_item_id)
);
CREATE INDEX IF NOT EXISTS ix_rss_item_reactions_user_id ON rss_item_reactions(user_id);
```
One reaction per user per item. A second click on the same button removes the reaction; clicking the opposite button flips it.
**New table: `briefing_task_snapshot`**
```sql
CREATE TABLE IF NOT EXISTS briefing_task_snapshot (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
task_id INTEGER NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
snapshot_hash TEXT NOT NULL,
last_briefed TIMESTAMPTZ DEFAULT NOW(),
UNIQUE (user_id, task_id)
);
CREATE INDEX IF NOT EXISTS ix_briefing_task_snapshot_user_id ON briefing_task_snapshot(user_id);
```
`snapshot_hash` is `SHA-256(status + priority + due_date + title)`. The pipeline diffs current task state against these rows to detect what has changed since the last briefing.
**Settings keys (existing key-value store — no new table)**
- `briefing_include_topics` — JSON array of topic strings to prioritise
- `briefing_exclude_topics` — JSON array of topics to hard-exclude from briefings
---
## Pipeline Changes
### Pre-processing Stage (new, runs before parallel gather)
Three sequential steps added to `services/briefing_pipeline.py`. Note: `_gather_internal` currently serialises tasks as dicts without `id`. It must be updated to include `task_id` in each serialised task dict (the `Note` ORM object has `.id` available) so the post-briefing snapshot upsert has the required FK value.
**1. Task change detection**
For each of the user's current tasks, compute `SHA-256(status + priority + due_date + title)` and compare against `briefing_task_snapshot`. Split into:
- `changed_tasks` — new hash or no snapshot row (included fully in briefing)
- `unchanged_count` — integer count passed to the synthesis prompt as context
The synthesis prompt receives `changed_tasks` and the instruction: "N tasks are unchanged since the last briefing — acknowledge this briefly rather than listing them."
**2. RSS item filtering**
Load the user's `briefing_include_topics` and `briefing_exclude_topics` settings, plus reaction history (last 30 days, aggregated per topic as a net score). Score each recent classified item:
- Hard-remove items tagged with any excluded topic
- Boost items tagged with any included or positively-reacted topic
- Penalise items from negatively-reacted topics
- Sort by score, take top 10
Items with `classified_at IS NULL` pass through unfiltered (new feeds not yet classified) and are queued for background classification.
**3. Weather staleness gate**
Check `weather_cache.fetched_at`. If older than 24 hours: skip weather entirely, set `weather_unavailable = True`. The frontend renders a `WeatherCard` placeholder in the failure state. If fresh: pass the forecast JSON (including `past_days=1` data) to the pipeline for card rendering.
### RSS Classification (background, triggered at fetch time)
When `services/rss.py` stores new items, it queues a fire-and-forget async task to classify them. Classification is a fast, non-streaming LLM call processing batches of up to 10 items:
```
Classify each news item into 1-3 topics from this vocabulary:
technology, science, politics, business, health, environment,
local, entertainment, sports, other, [user_defined_topics]
Return JSON: {"item_id": ["topic1", "topic2"]}
```
The vocabulary is extended with the user's declared preference topics so custom interests can be matched. Results are written to `rss_items.topics` and `classified_at`.
Model: the user's `default_model` setting (same as chat). If the LLM is unavailable or classification fails, the item is stored with `topics = []` and `classified_at` left NULL — it will be retried the next time new items are fetched. No retry loop; classification is best-effort.
### Post-briefing Stage (new, runs after `post_message()` returns)
1. **Upsert task snapshots** — upsert `briefing_task_snapshot` rows for all tasks included in this briefing so the next run can diff against current state.
2. **Populate message metadata** — the `metadata` dict (`weather` + `rss_item_ids`) is assembled during pre-processing and passed through to `post_message()`, which writes it to the `Message.metadata` column. No separate post-step is needed — the metadata is stored atomically with the message.
---
## Weather Card
The weather section is no longer generated as prose by the synthesis LLM. Instead:
- `services/weather.py` is updated to request `past_days=1` from Open-Meteo, including yesterday's high/low in the same API response.
- The pipeline parses the forecast into the `metadata.weather` schema (defined in the Data Model section) and stores it on the `Message` record when `post_message()` is called.
- `BriefingView.vue` reads `message.metadata.weather` when loading the conversation and renders `WeatherCard.vue` above the message text if the field is present.
- The synthesis LLM's weather section is suppressed entirely — the prompt instructs it to skip weather since it is handled by the card.
**`WeatherCard.vue` displays:**
- Location name and "as of" timestamp
- Current temperature and condition
- Today's high / low
- Yesterday's high / low with delta ("3° warmer than yesterday")
- Compact 35 day forecast strip (day name, condition, high/low)
**Failure state:** If `metadata.weather` is `null`, the same card position renders a muted placeholder: "Weather data unavailable — will retry at next slot."
---
## News Cards
### Format
The synthesis LLM is instructed to format each included news item as:
```markdown
**[Headline text](source_url)**
*Outlet Name · Day Month*
One or two sentence summary of the story.
```
No prose wrapper between cards. The synthesis prompt must explicitly instruct the LLM to **present news items in the exact order provided**`metadata.rss_item_ids` records this order and the frontend maps reaction buttons positionally. Reordering by the LLM would break the mapping.
The briefing message structure becomes:
1. Greeting / task summary
2. `WeatherCard` (rendered from `message.metadata.weather`, not prose)
3. News cards (markdown blocks with links)
4. Calendar / other sections
### Reaction Buttons
`BriefingView.vue` reads `message.metadata.rss_item_ids` when loading the conversation. The ordered list of IDs maps directly to the news cards in the rendered message (cards appear in synthesis output in the same order). A 👍 / 👎 pair is rendered below each card. Reaction buttons are only shown in the briefing view — not in message history exports.
Clicking a reaction:
1. Optimistic UI update (button enters selected state immediately)
2. `POST /api/briefing/rss-reactions``{rss_item_id, reaction}`
3. Backend validates ownership: joins through `rss_items → rss_feeds` to confirm `rss_feeds.user_id = g.user.id` before upserting
4. Upserts into `rss_item_reactions` — same reaction removes it, opposite flips it
**New endpoints in `routes/briefing.py`:**
- `POST /api/briefing/rss-reactions` — upsert or remove reaction (ownership-checked)
- `DELETE /api/briefing/rss-reactions/:item_id` — explicit removal (useful for MCP/external API consumers that cannot use the toggle behaviour of POST)
---
## Topic Preferences UI
**Settings → Briefing tab — new "News Preferences" subsection** (added below existing RSS feed management):
Two chip-input fields using the existing `TagInput.vue` component:
- **Interested in** → `briefing_include_topics` setting
- **Not interested in** → `briefing_exclude_topics` setting
A collapsed hint lists the standard topic vocabulary so users know valid terms. Custom terms are accepted — the RSS classifier will attempt to match them.
Saved via the existing `PUT /api/settings/:key` endpoint.
---
## MCP Tool Additions
New file: `fable-mcp/fable_mcp/tools/briefing.py`
Three tools registered in `server.py`:
| Tool | Description |
|------|-------------|
| `add_rss_feed(url, title=None, category=None)` | Adds a feed to the user's RSS list. `title` is optional — the feed title is auto-populated from feed metadata after the first fetch, but an override can be passed. Returns the created feed object. |
| `list_rss_feeds()` | Returns current feed list with id, title, url, category, last_fetched_at. |
| `remove_rss_feed(feed_id)` | Removes a feed by ID. |
These call existing RSS endpoints in `routes/briefing.py` via `FableClient`. No new backend routes required.
---
## New Backend Files
| File | Purpose |
|------|---------|
| `services/briefing_preferences.py` | Load/compute topic preference weights; apply to RSS item scoring |
| `services/rss_classifier.py` | Batch LLM classification of RSS items; background task management |
## Modified Backend Files
| File | Changes |
|------|---------|
| `services/briefing_pipeline.py` | Add pre-processing and post-briefing stages; carry `task_id` through serialised task dicts; pass `metadata` dict to `post_message()` |
| `services/rss.py` | Trigger background classification after storing new items |
| `services/weather.py` | Add `past_days=1` to Open-Meteo request; expose parsed yesterday data |
| `routes/briefing.py` | Add `POST/DELETE /api/briefing/rss-reactions` endpoints |
| `models/rss_feed.py` | Add `topics` and `classified_at` mapped columns to `RssItem`; expose in `to_dict()` |
| `models/conversation.py` | Add `metadata` JSONB mapped column to `Message`; update `to_dict()` to include `metadata` in the returned dict |
| `services/briefing_conversations.py` | Extend `post_message(conversation_id, role, content)` signature to accept an optional `metadata: dict \| None = None` parameter; pass it to the `Message(...)` constructor |
## New Frontend Files
| File | Purpose |
|------|---------|
| `components/WeatherCard.vue` | Weather display card (current, today, yesterday delta, 3-5 day strip, failure state) |
## Modified Frontend Files
| File | Changes |
|------|---------|
| `views/BriefingView.vue` | Read `message.metadata.weather` on conversation load → render `WeatherCard.vue` above message text; read `message.metadata.rss_item_ids` → attach reaction buttons to news cards in order |
| `views/SettingsView.vue` | Add "News Preferences" subsection with two `TagInput` fields |
| `api/client.ts` | Add `postRssReaction()`, `deleteRssReaction()` helpers |
---
## Out of Scope
- Android companion app changes (web-first; parity deferred)
- Full numeric scoring system (Approach C) — can evolve to this once reaction data accumulates
- Push notification integration for briefing reactions
@@ -1,214 +0,0 @@
# 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 `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.
---
## 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 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_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 |
| `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 — 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 (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`.
---
## 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,
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]
```
`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, 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`, marks `caldav_uid` on success, and logs a warning on failure. CalDAV being unconfigured is treated as a no-op (not an error).
### 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.
---
## 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
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(...)` — all existing params forwarded |
| `list_events` | Calls `events.list_events(...)` |
| `search_events` | Calls `events.search_events(...)` |
| `update_event` | Calls `events.find_events_by_query(...)`, then `events.update_event(...)` |
| `delete_event` | Calls `events.find_events_by_query(...)`, then `events.delete_event(...)` |
---
## 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 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 (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 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<EventEntry[]>
createEvent(body: CreateEventBody): Promise<EventEntry>
getEvent(id: number): Promise<EventEntry>
updateEvent(id: number, body: Partial<CreateEventBody>): Promise<EventEntry>
deleteEvent(id: number): Promise<void>
```
### 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/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`, `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 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
-236
View File
@@ -1,236 +0,0 @@
# RAG Scoping and Context Isolation Design
> **For agentic workers:** Use superpowers:executing-plans or superpowers:subagent-driven-development to implement this plan task-by-task.
**Goal:** Prevent project-associated notes from polluting global chat RAG, and give the LLM tools to discover and switch project scope during a conversation.
**Problem being solved:** Users with multiple projects (e.g. scifi worldbuilding, personal productivity) find that semantic search pulls notes from whichever project has the most content, contaminating general-purpose chat context. Project notes should be siloed by default.
---
## Architecture
### Default RAG behavior change
Currently `rag_project_id=None` in `build_context()` searches all notes. After this change the semantics become a three-value system:
- `rag_project_id = None` → search only orphan notes (`project_id IS NULL`)
- `rag_project_id = <positive int>` → search only that project's notes
- `rag_project_id = -1` → search all notes (explicit opt-in to current global behavior)
**`semantic_search_notes()` change** (`services/embeddings.py`): add `orphan_only: bool = False` parameter. When `True`, add `.where(Note.project_id.is_(None))` to the SQLAlchemy query.
**`search_notes_for_context()` change** (`services/notes.py`): add `orphan_only: bool = False` parameter with the same filter.
**`build_context()` change** (`services/llm.py`): compute the flags before calling search:
```python
# rag_project_id=None → orphan only; -1 → all notes; positive int → that project
orphan_only = rag_project_id is None
effective_project_id = rag_project_id if (rag_project_id is not None and rag_project_id != -1) else None
for score, note in await semantic_search_notes(
user_id, user_message, exclude_ids=search_exclude or None, limit=8,
project_id=effective_project_id,
orphan_only=orphan_only,
):
...
```
The same `orphan_only` / `effective_project_id` pattern applies to the keyword fallback call to `search_notes_for_context()`.
### Conversation scope persistence
**New DB column:** `conversations.rag_project_id INTEGER` (nullable, default `NULL`).
Semantics match the three-value system above. `NULL` is the default for all new and existing conversations after migration — existing conversations transparently gain orphan-only scoping.
**Model change** (`models/conversation.py`):
```python
rag_project_id: Mapped[int | None] = mapped_column(Integer, nullable=True, default=None)
```
`to_dict()` must include `"rag_project_id": self.rag_project_id`.
**Route change** (`routes/chat.py`): `PATCH /api/chat/conversations/:id` must accept `rag_project_id` in the request body and call `update_conversation(conv_id, user_id, rag_project_id=value)`. `GET /api/chat/conversations/:id` returns `rag_project_id` via `to_dict()`.
**Service change** (`services/chat.py`): `update_conversation()` must accept and persist `rag_project_id`.
### Project summarization background job
**New DB columns** on `projects`:
```python
auto_summary: Mapped[str | None] = mapped_column(Text, nullable=True)
summary_updated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
```
**`generate_project_summary(user_id: int, project_id: int) -> None`** (`services/projects.py`):
- Fetches project metadata (title, description, goal)
- Fetches up to 10 notes for that project: `SELECT title, body[:200] FROM notes WHERE project_id=? ORDER BY updated_at DESC LIMIT 10`
- Builds an Ollama prompt: "Summarize this project in 3-4 sentences covering its purpose, themes, and content. Title: {title}. Description: {description}. Goal: {goal}. Recent notes: {sample}"
- Calls Ollama (same `generate_completion()` helper used elsewhere, `stream=False`)
- Stores result in `project.auto_summary` and `project.summary_updated_at = datetime.now(UTC)`
- On Ollama failure: log debug message, return without updating (graceful degradation)
**`backfill_project_summaries() -> None`** (`services/projects.py`): fire-and-forget, called at startup alongside `backfill_note_embeddings()`. Generates summaries for all projects where `auto_summary IS NULL`.
**Trigger from `update_project()`**: after committing, call `asyncio.create_task(generate_project_summary(user_id, project_id))` unconditionally.
**Trigger from note saves**: in `create_note()` and `update_note()` within `services/notes.py`, when `project_id` is not None, check `project.summary_updated_at`. If `None` or more than 1 hour old, fire `asyncio.create_task(generate_project_summary(user_id, project_id))`. This avoids flooding Ollama during heavy note-editing sessions.
`generate_project_summary` and `backfill_project_summaries` are **internal background tasks only**, not exposed as LLM tools.
### New LLM tools
Both tools are added to `_CORE_TOOLS` in `services/tools.py`.
**`search_projects(query: str)`**:
- Loads all projects for the user from the DB (title, description, goal, auto_summary)
- Scores each project: `SequenceMatcher(None, query.lower(), combined_text.lower()).ratio()` where `combined_text = f"{title} {description or ''} {auto_summary or ''}"`, plus keyword overlap bonus
- Returns top 5 as `[{id, title, summary_snippet, score}]` where `summary_snippet` is `auto_summary[:200]` or `description[:200]`
- Tool result type: `"projects_list"`
**`set_rag_scope(project_id: int | None)`**:
- `project_id = <positive int>` → scope to that project
- `project_id = null` → orphan-only
- `project_id = -1` → all notes
- **Workspace guard:** if `workspace_project_id` is set on the current generation context, return `{success: false, error: "Cannot change RAG scope in workspace view"}` and make no changes
- Validates positive project_id exists and belongs to user (returns error if not)
- Calls `await update_conversation(conv_id, user_id, rag_project_id=project_id)` to persist immediately
- Returns `{success: true, type: "rag_scope_set", scope_label: "<human-readable>"}` e.g. `"Orphan notes only"`, `"SciFi Project"`, `"All notes"`
**`conv_id` threading** (`services/tools.py`, `services/generation_task.py`):
Current `execute_tool()` signature:
```python
async def execute_tool(user_id: int, tool_name: str, arguments: dict) -> dict
```
Updated signature:
```python
async def execute_tool(
user_id: int,
tool_name: str,
arguments: dict,
conv_id: int | None = None,
workspace_project_id: int | None = None,
) -> dict
```
In `generation_task.py`, the call site in the tool execution loop must pass `conv_id=conv_id, workspace_project_id=workspace_project_id` — both are already available as local variables in `run_generation()`.
**SSE `new_rag_scope` wiring:**
In `generation_task.py`, after each tool call returns, check `if result.get("type") == "rag_scope_set" and result.get("success")`. Store `new_rag_scope = arguments["project_id"]` and `new_rag_scope_label = result["scope_label"]`.
When emitting the final SSE `done` event (currently `{"done": True, "message_id": msg_id, "timing": timing}`), add the scope fields when present:
```python
done_payload = {"done": True, "message_id": msg_id, "timing": timing}
if new_rag_scope_label is not None:
done_payload["new_rag_scope"] = new_rag_scope # raw int or None
done_payload["new_rag_scope_label"] = new_rag_scope_label
yield f"data: {json.dumps(done_payload)}\n\n"
```
In `stores/chat.ts`, the SSE `done` handler must check `event.data.new_rag_scope !== undefined` and set `currentConversation.value.rag_project_id = event.data.new_rag_scope`.
### Frontend scope indicator
**Chat store** (`stores/chat.ts`):
- `currentConversation` already holds the full conversation object; add `rag_project_id: number | null` to the conversation interface
- Add a computed `ragProjectId` that reads `currentConversation.value?.rag_project_id ?? null`
- Add `async updateRagScope(convId: number, ragProjectId: number | null)` that calls `PATCH /api/chat/conversations/:id { rag_project_id }` and updates `currentConversation.value.rag_project_id` locally on success
- In the SSE `done` handler, if `event.data.new_rag_scope !== undefined`, set `currentConversation.value.rag_project_id = event.data.new_rag_scope`
**ChatView** (`views/ChatView.vue`):
- Remove the existing `ProjectSelector` from the sidebar RAG scope section
- Add a scope chip just above the message input. Shows: `⊙ Orphan notes` (null) / `⊙ All notes` (-1) / `⊙ {project title}` (positive int)
- Clicking opens a compact dropdown: "Orphan notes only", all active projects by title, "All notes"
- On selection, call `chatStore.updateRagScope(convId, selectedValue)`
- When `chatStore.ragProjectId` changes (from SSE or UI), briefly apply a CSS pulse animation to the chip
- Load the projects list once on mount via `apiGet("/api/projects?status=active")`
**WorkspaceView** — no change. It hardcodes `ragProjectId` to the workspace project's id (positive int). Scope chip is not shown in workspace view.
---
## Files Changed
### Backend
| File | Change |
|------|--------|
| `alembic/versions/0030_rag_scoping.py` | Add `conversations.rag_project_id`, `projects.auto_summary`, `projects.summary_updated_at` |
| `models/conversation.py` | Add `rag_project_id: Mapped[int \| None]`; include in `to_dict()` |
| `models/project.py` | Add `auto_summary: Mapped[str \| None]`, `summary_updated_at: Mapped[datetime \| None]` |
| `services/projects.py` | Add `generate_project_summary()`, `backfill_project_summaries()`; trigger from `update_project()` |
| `services/notes.py` | Add `orphan_only` param to `search_notes_for_context()`; trigger summary regen from note saves |
| `services/embeddings.py` | Add `orphan_only: bool = False` param to `semantic_search_notes()` |
| `services/llm.py` | Compute `orphan_only` + `effective_project_id` from `rag_project_id`; pass to both search calls |
| `services/tools.py` | Add `search_projects` and `set_rag_scope` definitions and handlers; update `execute_tool()` signature |
| `services/generation_task.py` | Pass `conv_id` + `workspace_project_id` to `execute_tool()`; capture scope change; emit in SSE done |
| `services/chat.py` | Add `rag_project_id` parameter to `update_conversation()` |
| `routes/chat.py` | Accept `rag_project_id` in PATCH body; return in GET responses via `to_dict()` |
| `app.py` | Call `backfill_project_summaries()` at startup |
### Frontend
| File | Change |
|------|--------|
| `api/client.ts` | Conversation type includes `rag_project_id`; helpers accept it |
| `stores/chat.ts` | `ragProjectId` computed; `updateRagScope()` method; SSE done handler updates scope |
| `views/ChatView.vue` | Replace sidebar `ProjectSelector` with scope chip above input bar |
---
## Data Flow
```
User changes scope via chip
→ chatStore.updateRagScope(convId, value)
→ PATCH /api/chat/conversations/:id { rag_project_id: value }
→ DB updated; currentConversation.rag_project_id updated locally
→ chip re-renders immediately
User sends message
→ sendMessage() reads chatStore.ragProjectId → passes as rag_project_id in POST body
→ build_context() derives orphan_only + effective_project_id
→ RAG results scoped accordingly
Model calls set_rag_scope(3) during generation
→ execute_tool(conv_id=X, workspace_project_id=None) writes rag_project_id=3 to DB
→ returns { success: true, type: "rag_scope_set", scope_label: "SciFi Project" }
→ generation_task stores new_rag_scope=3, new_rag_scope_label="SciFi Project"
→ SSE done event: { done: true, new_rag_scope: 3, new_rag_scope_label: "SciFi Project", ... }
→ chat store done handler: currentConversation.rag_project_id = 3
→ chip pulses → "⊙ SciFi Project"
User opens existing conversation
→ GET /api/chat/conversations/:id returns rag_project_id
→ chat store sets currentConversation; ragProjectId computed picks it up
→ chip renders correct scope immediately
```
---
## Error Handling
- **Ollama unavailable during summary gen:** log debug, return without updating; old summary (or null) retained; `search_projects` falls back to title + description matching
- **`set_rag_scope` called in workspace:** return `{success: false, error: "Cannot change RAG scope in workspace view"}`; no DB write
- **`set_rag_scope` with unknown positive project_id:** return `{success: false, error: "Project not found"}`; scope unchanged
- **`search_projects` with no summaries yet:** score on title + description only; still returns results
- **Existing conversations after migration:** `rag_project_id` defaults to `NULL` → orphan-only; behavior silently improves without user action
---
## Testing
- Unit: `semantic_search_notes(orphan_only=True)` returns only notes where `project_id IS NULL`
- Unit: `semantic_search_notes(project_id=3, orphan_only=False)` returns only notes for project 3
- Unit: `build_context()` passes correct `orphan_only` / `effective_project_id` for all three `rag_project_id` values (None, -1, positive)
- Unit: `search_projects()` scoring ranks correct project first for representative queries; degrades gracefully with no summaries
- Unit: `generate_project_summary()` builds correct Ollama prompt; handles Ollama failure gracefully
- Integration: `set_rag_scope()` handler writes to DB, returns correct scope_label, rejects in workspace context
- Integration: SSE `done` event includes `new_rag_scope` when scope changes mid-conversation
- Frontend: scope chip reflects loaded conversation scope; updates reactively on model tool call
+2 -2
View File
@@ -298,7 +298,7 @@ async def fable_update_task(
@mcp.tool()
async def fable_add_task_log(task_id: int, body: str) -> dict:
async def fable_add_task_log(task_id: int, content: str) -> dict:
"""Append a timestamped progress log entry to a Fable task.
Use this to record work sessions, decisions, or status updates over time without
@@ -306,7 +306,7 @@ async def fable_add_task_log(task_id: int, body: str) -> dict:
chronologically in the task view.
"""
async with FableClient() as client:
return await tasks.add_task_log(client, task_id=task_id, body=body)
return await tasks.add_task_log(client, task_id=task_id, content=content)
# ---------------------------------------------------------------------------
+2 -2
View File
@@ -87,7 +87,7 @@ async def add_task_log(
client: FableClient,
*,
task_id: int,
body: str,
content: str,
) -> dict[str, Any]:
"""Append a log entry to a task."""
return await client.post(f"/api/tasks/{task_id}/logs", json={"body": body})
return await client.post(f"/api/tasks/{task_id}/logs", json={"content": content})
+3 -3
View File
@@ -46,9 +46,9 @@ async def test_update_task_patches(client):
@pytest.mark.asyncio
async def test_add_task_log_posts_to_logs_endpoint(client):
from fable_mcp.tools.tasks import add_task_log
client.post = AsyncMock(return_value={"id": 1, "body": "progress"})
await add_task_log(client, task_id=9, body="progress")
client.post = AsyncMock(return_value={"id": 1, "content": "progress"})
await add_task_log(client, task_id=9, content="progress")
client.post.assert_called_once()
path = client.post.call_args[0][0]
assert path == "/api/tasks/9/logs"
assert client.post.call_args[1]["json"]["body"] == "progress"
assert client.post.call_args[1]["json"]["content"] == "progress"
+22 -1
View File
@@ -123,7 +123,6 @@ export interface NotificationEntry {
export interface UserSearchResult {
id: number
username: string
email: string | null
}
// --- User search ---
@@ -598,3 +597,25 @@ export const createApiKey = (name: string, scope: 'read' | 'write') =>
apiPost<{ key: string; api_key: ApiKeyEntry }>('/api/api-keys', { name, scope })
export const revokeApiKey = (id: number) => apiDelete(`/api/api-keys/${id}`)
// ─── News ─────────────────────────────────────────────────────────────────────
import type { NewsItem } from '@/types/news'
export interface GetNewsItemsParams {
days?: number
limit?: number
offset?: number
feed_id?: number | null
}
export function getNewsItems(params: GetNewsItemsParams = {}) {
const p = new URLSearchParams()
if (params.days != null) p.set('days', String(params.days))
if (params.limit != null) p.set('limit', String(params.limit))
if (params.offset != null) p.set('offset', String(params.offset))
if (params.feed_id != null) p.set('feed_id', String(params.feed_id))
return apiGet<{ items: NewsItem[]; offset: number; limit: number }>(
`/api/briefing/news?${p}`
)
}
+2
View File
@@ -79,6 +79,7 @@ router.afterEach(() => {
<router-link to="/graph" class="nav-link">Graph</router-link>
<router-link to="/calendar" class="nav-link">Calendar</router-link>
<router-link to="/briefing" class="nav-link">Briefing</router-link>
<router-link to="/news" class="nav-link">News</router-link>
<router-link to="/shared" class="nav-link">Shared</router-link>
</div>
@@ -129,6 +130,7 @@ router.afterEach(() => {
<router-link to="/graph" class="nav-link">Graph</router-link>
<router-link to="/calendar" class="nav-link">Calendar</router-link>
<router-link to="/briefing" class="nav-link">Briefing</router-link>
<router-link to="/news" class="nav-link">News</router-link>
<router-link to="/shared" class="nav-link">Shared</router-link>
<div class="mobile-divider"></div>
<router-link to="/settings" class="nav-link">Settings</router-link>
@@ -0,0 +1,171 @@
<script setup lang="ts">
import { ref, watch, computed } from "vue";
const props = defineProps<{
modelValue: Record<string, unknown> | null;
}>();
const emit = defineEmits<{
(e: "update:modelValue", val: Record<string, unknown> | null): void;
}>();
type RecurrenceType = "none" | "interval" | "calendar";
type IntervalUnit = "day" | "week" | "month" | "year";
type CalendarUnit = "month" | "year";
const rType = ref<RecurrenceType>("none");
const intervalEvery = ref(1);
const intervalUnit = ref<IntervalUnit>("week");
const calendarUnit = ref<CalendarUnit>("month");
const calendarDay = ref(1);
const calendarMonth = ref(1);
function ruleFromState(): Record<string, unknown> | null {
if (rType.value === "none") return null;
if (rType.value === "interval") {
return { type: "interval", every: intervalEvery.value, unit: intervalUnit.value };
}
// calendar
const rule: Record<string, unknown> = {
type: "calendar",
unit: calendarUnit.value,
day_of_month: calendarDay.value,
};
if (calendarUnit.value === "year") {
rule.month = calendarMonth.value;
}
return rule;
}
function loadFromRule(rule: Record<string, unknown> | null) {
if (!rule) {
rType.value = "none";
return;
}
const t = rule.type as string;
if (t === "interval") {
rType.value = "interval";
intervalEvery.value = (rule.every as number) ?? 1;
intervalUnit.value = (rule.unit as IntervalUnit) ?? "week";
} else if (t === "calendar") {
rType.value = "calendar";
calendarUnit.value = (rule.unit as CalendarUnit) ?? "month";
calendarDay.value = (rule.day_of_month as number) ?? 1;
calendarMonth.value = (rule.month as number) ?? 1;
} else {
rType.value = "none";
}
}
// Load initial value
loadFromRule(props.modelValue);
// Watch for external changes (e.g., task loaded)
watch(() => props.modelValue, (val) => {
loadFromRule(val);
}, { deep: true });
// Emit on any state change
function onChange() {
emit("update:modelValue", ruleFromState());
}
const monthNames = [
"January","February","March","April","May","June",
"July","August","September","October","November","December",
];
const calendarDayMax = computed(() =>
calendarUnit.value === "month" ? 28 : 28
);
</script>
<template>
<div class="rec-editor">
<select v-model="rType" class="sb-select" @change="onChange">
<option value="none">No recurrence</option>
<option value="interval">Every N days/weeks/months</option>
<option value="calendar">On a calendar date</option>
</select>
<div v-if="rType === 'interval'" class="rec-row">
<span class="rec-label">Every</span>
<input
v-model.number="intervalEvery"
type="number"
min="1"
max="365"
class="rec-num-input"
@input="onChange"
/>
<select v-model="intervalUnit" class="sb-select rec-unit" @change="onChange">
<option value="day">day(s)</option>
<option value="week">week(s)</option>
<option value="month">month(s)</option>
<option value="year">year(s)</option>
</select>
</div>
<div v-if="rType === 'calendar'" class="rec-row rec-col">
<div class="rec-row">
<span class="rec-label">Unit</span>
<select v-model="calendarUnit" class="sb-select" @change="onChange">
<option value="month">Monthly</option>
<option value="year">Yearly</option>
</select>
</div>
<div class="rec-row">
<span class="rec-label">Day</span>
<input
v-model.number="calendarDay"
type="number"
min="1"
:max="calendarDayMax"
class="rec-num-input"
@input="onChange"
/>
</div>
<div v-if="calendarUnit === 'year'" class="rec-row">
<span class="rec-label">Month</span>
<select v-model.number="calendarMonth" class="sb-select" @change="onChange">
<option v-for="(name, i) in monthNames" :key="i + 1" :value="i + 1">{{ name }}</option>
</select>
</div>
</div>
</div>
</template>
<style scoped>
.rec-editor {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.rec-row {
display: flex;
align-items: center;
gap: 0.4rem;
flex-wrap: wrap;
}
.rec-col {
flex-direction: column;
align-items: flex-start;
}
.rec-label {
font-size: 0.78rem;
color: var(--color-text-muted);
min-width: 2.5rem;
}
.rec-num-input {
width: 4rem;
padding: 0.25rem 0.4rem;
border: 1px solid var(--color-input-border, var(--color-border));
border-radius: var(--radius-sm);
background: var(--color-bg);
color: var(--color-text);
font-size: 0.85rem;
font-family: inherit;
}
.rec-num-input:focus { outline: none; border-color: var(--color-primary); }
.rec-unit { min-width: 6rem; }
</style>
-1
View File
@@ -138,7 +138,6 @@ onMounted(async () => {
<ul v-if="userResults.length" class="user-results">
<li v-for="u in userResults" :key="u.id" @click="selectUser(u)" class="user-result-item">
<span class="user-result-name">{{ u.username }}</span>
<span class="user-result-email">{{ u.email }}</span>
</li>
</ul>
</div>
+5
View File
@@ -12,6 +12,7 @@ const labels: Record<TaskStatus, string> = {
todo: "Todo",
in_progress: "In Progress",
done: "Done",
cancelled: "Cancelled",
};
</script>
@@ -48,6 +49,10 @@ const labels: Record<TaskStatus, string> = {
background: color-mix(in srgb, var(--color-status-done-bg) 78%, var(--color-status-done) 22%);
color: color-mix(in srgb, var(--color-status-done) 85%, #000 15%);
}
.status-cancelled {
background: color-mix(in srgb, var(--color-bg-secondary) 78%, var(--color-text-muted) 22%);
color: var(--color-text-muted);
}
.clickable {
cursor: pointer;
}
+6
View File
@@ -20,18 +20,21 @@ const statusCycle: Record<TaskStatus, TaskStatus> = {
todo: "in_progress",
in_progress: "done",
done: "todo",
cancelled: "todo",
};
const statusDotClass: Record<TaskStatus, string> = {
todo: "dot-todo",
in_progress: "dot-in-progress",
done: "dot-done",
cancelled: "dot-cancelled",
};
const statusTitle: Record<TaskStatus, string> = {
todo: "Todo — click to mark In Progress",
in_progress: "In Progress — click to mark Done",
done: "Done — click to mark Todo",
cancelled: "Cancelled — click to mark Todo",
};
function cycleStatus() {
@@ -152,6 +155,9 @@ function isOverdue(): boolean {
.dot-done {
background: var(--color-status-done, #22c55e);
}
.dot-cancelled {
background: var(--color-status-cancelled, #6b7280);
}
.task-title-compact {
font-size: 0.9rem;
+5
View File
@@ -120,6 +120,11 @@ const router = createRouter({
name: "briefing",
component: () => import("@/views/BriefingView.vue"),
},
{
path: "/news",
name: "news",
component: () => import("@/views/NewsView.vue"),
},
{
path: "/settings",
name: "settings",
+10 -10
View File
@@ -12,8 +12,8 @@ export const useTasksStore = defineStore("tasks", () => {
// Filter / pagination / sort state
const activeTagFilters = ref<string[]>([]);
const statusFilter = ref<TaskStatus | "">("");
const priorityFilter = ref<TaskPriority | "">("");
const statusFilter = ref<TaskStatus[]>([]);
const priorityFilter = ref<TaskPriority[]>([]);
const limit = ref(20);
const offset = ref(0);
const sortField = ref("updated_at");
@@ -28,9 +28,8 @@ export const useTasksStore = defineStore("tasks", () => {
for (const t of activeTagFilters.value) {
searchParams.append("tag", t);
}
if (statusFilter.value) searchParams.set("status", statusFilter.value);
if (priorityFilter.value)
searchParams.set("priority", priorityFilter.value);
for (const s of statusFilter.value) searchParams.append("status", s);
for (const p of priorityFilter.value) searchParams.append("priority", p);
searchParams.set("sort", sortField.value);
searchParams.set("order", sortOrder.value);
searchParams.set("limit", String(limit.value));
@@ -72,6 +71,7 @@ export const useTasksStore = defineStore("tasks", () => {
project_id?: number | null;
milestone_id?: number | null;
parent_id?: number | null;
recurrence_rule?: Record<string, unknown> | null;
}): Promise<Task> {
try {
return await apiPost<Task>("/api/tasks", data);
@@ -84,7 +84,7 @@ export const useTasksStore = defineStore("tasks", () => {
async function updateTask(
id: number,
data: Partial<
Pick<Task, "title" | "body" | "tags" | "status" | "priority" | "due_date" | "project_id" | "milestone_id" | "parent_id">
Pick<Task, "title" | "body" | "tags" | "status" | "priority" | "due_date" | "project_id" | "milestone_id" | "parent_id" | "recurrence_rule">
>
): Promise<Task> {
try {
@@ -129,14 +129,14 @@ export const useTasksStore = defineStore("tasks", () => {
}
}
function setStatusFilter(status: TaskStatus | "") {
statusFilter.value = status;
function setStatusFilter(statuses: TaskStatus[]) {
statusFilter.value = statuses;
offset.value = 0;
refresh();
}
function setPriorityFilter(priority: TaskPriority | "") {
priorityFilter.value = priority;
function setPriorityFilter(priorities: TaskPriority[]) {
priorityFilter.value = priorities;
offset.value = 0;
refresh();
}
+10
View File
@@ -0,0 +1,10 @@
export interface NewsItem {
id: number
title: string
url: string
snippet: string
published_at: string | null
topics: string[]
source: string
reaction: 'up' | 'down' | null
}
+5 -1
View File
@@ -1,4 +1,4 @@
export type TaskStatus = "todo" | "in_progress" | "done";
export type TaskStatus = "todo" | "in_progress" | "done" | "cancelled";
export type TaskPriority = "none" | "low" | "medium" | "high";
export interface Note {
@@ -13,6 +13,10 @@ export interface Note {
status: TaskStatus | null;
priority: TaskPriority | null;
due_date: string | null;
started_at: string | null;
completed_at: string | null;
recurrence_rule: Record<string, unknown> | null;
recurrence_next_spawn_at: string | null;
is_task: boolean;
created_at: string;
updated_at: string;
+1 -1
View File
@@ -38,7 +38,7 @@ const headingRenderer = {
marked.use({ renderer: headingRenderer });
const PURIFY_OPTS_FULL = {
ADD_ATTR: ["data-tag", "data-title", "src", "alt"],
ADD_ATTR: ["data-tag", "data-title"],
FORCE_BODY: true,
};
+271 -139
View File
@@ -1,11 +1,12 @@
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import { ref, computed, onMounted, watch, nextTick } from 'vue'
import { useBackgroundRefresh } from '@/composables/useBackgroundRefresh'
import { useChatStore } from '@/stores/chat'
import ChatMessage from '@/components/ChatMessage.vue'
import WeatherCard from '@/components/WeatherCard.vue'
import BriefingSetupWizard from '@/components/BriefingSetupWizard.vue'
import {
apiGet,
getBriefingConfig,
getBriefingConversations,
getBriefingToday,
@@ -13,34 +14,23 @@ import {
triggerBriefingSlot,
postRssReaction,
deleteRssReaction,
getNewsItems,
type BriefingConversation,
type BriefingMessage,
} from '@/api/client'
import type { Message } from '@/types/chat'
import type { NewsItem } from '@/types/news'
interface RssItemMeta {
id: number
title: string
url: string
source: string
snippet: string
published_at: string | null
}
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[]
rss_items?: RssItemMeta[]
interface WeatherData {
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 }[]
}
const chatStore = useChatStore()
@@ -71,15 +61,55 @@ const isToday = computed(() => selectedConvId.value === todayConvId.value)
const messages = ref<BriefingMessage[]>([])
const loadingMessages = ref(false)
// Weather panel (left column)
const weatherData = ref<WeatherData[]>([])
const tempUnit = ref<string>('C')
async function loadWeather() {
try {
const data = await apiGet<{ locations: WeatherData[]; temp_unit: string }>('/api/briefing/weather')
weatherData.value = data.locations ?? []
tempUnit.value = data.temp_unit ?? 'C'
} catch { /* silent */ }
}
// News panel (right column)
const newsItems = ref<NewsItem[]>([])
async function loadNews() {
try {
const data = await getNewsItems({ days: 2, limit: 40 })
newsItems.value = data.items
// Seed reactions from API response
for (const item of data.items) {
if (reactions.value[item.id] === undefined) {
reactions.value[item.id] = item.reaction
}
}
} catch { /* silent */ }
}
// Scroll to bottom of messages
const messagesEl = ref<HTMLElement | null>(null)
function scrollToBottom() {
nextTick(() => {
if (messagesEl.value) {
messagesEl.value.scrollTop = messagesEl.value.scrollHeight
}
})
}
async function loadAll() {
const [convList, today] = await Promise.all([
getBriefingConversations(),
getBriefingToday().catch(() => null),
loadWeather(),
loadNews(),
])
conversations.value = convList
if (today) {
todayConvId.value = today.id
// Ensure today is in the list
if (!convList.find((c) => c.id === today.id)) {
conversations.value = [
{ id: today.id, title: today.title ?? 'Today', briefing_date: null, message_count: 0, created_at: new Date().toISOString() },
@@ -88,9 +118,9 @@ async function loadAll() {
}
selectedConvId.value = today.id
messages.value = today.messages
// Load into chatStore so we can stream
await chatStore.fetchConversation(today.id)
}
scrollToBottom()
}
watch(selectedConvId, async (id) => {
@@ -98,11 +128,13 @@ watch(selectedConvId, async (id) => {
if (id === todayConvId.value) {
await chatStore.fetchConversation(id)
messages.value = (chatStore.currentConversation?.messages ?? []) as unknown as BriefingMessage[]
scrollToBottom()
return
}
loadingMessages.value = true
try {
messages.value = await getBriefingConvMessages(id)
scrollToBottom()
} finally {
loadingMessages.value = false
}
@@ -113,6 +145,7 @@ watch(() => chatStore.streaming, async (streaming) => {
if (!streaming && selectedConvId.value === todayConvId.value && todayConvId.value) {
const today = await getBriefingToday().catch(() => null)
if (today) messages.value = today.messages
scrollToBottom()
}
})
@@ -123,7 +156,6 @@ const sending = ref(false)
async function send() {
const text = input.value.trim()
if (!text || !todayConvId.value || chatStore.streaming || sending.value) return
// Ensure today's conv is loaded in chatStore
if (chatStore.currentConversation?.id !== todayConvId.value) {
await chatStore.fetchConversation(todayConvId.value)
}
@@ -160,10 +192,6 @@ async function handleReaction(itemId: number, reaction: 'up' | 'down') {
}
}
function msgMetadata(msg: BriefingMessage): MessageMetadata | null {
return (msg.metadata as MessageMetadata | null | undefined) ?? null
}
function formatRelativeDate(iso: string | null): string {
if (!iso) return ''
const d = new Date(iso)
@@ -180,7 +208,6 @@ async function triggerNow() {
triggering.value = true
try {
await triggerBriefingSlot('compilation')
// Reload
await loadAll()
} finally {
triggering.value = false
@@ -221,6 +248,7 @@ async function _backgroundRefreshMessages() {
if (fresh.length !== messages.value.length || last?.content !== cur?.content) {
messages.value = fresh
}
await loadNews()
} catch { /* silent — don't disturb the UI on network hiccup */ }
}
@@ -241,16 +269,15 @@ onMounted(async () => {
<!-- Setup wizard overlay -->
<BriefingSetupWizard v-if="wizardChecked && showWizard" @done="onWizardDone" />
<!-- Main view (shown after wizard check and only if not showing wizard) -->
<!-- Main view -->
<div class="briefing-shell" v-if="wizardChecked && !showWizard">
<!-- Header -->
<!-- Header spans all columns -->
<header class="briefing-header">
<div class="briefing-header-left">
<h1 class="briefing-title">Briefing</h1>
<span class="briefing-today-badge">{{ new Date().toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric' }) }}</span>
</div>
<div class="briefing-header-right">
<!-- Conversation history dropdown -->
<select v-if="conversations.length" v-model="selectedConvId" class="briefing-conv-select">
<option v-for="c in conversations" :key="c.id" :value="c.id">{{ convLabel(c) }}</option>
</select>
@@ -263,102 +290,116 @@ onMounted(async () => {
</div>
</header>
<!-- Messages -->
<div class="briefing-messages-wrap">
<div v-if="loadingMessages" class="briefing-loading">Loading</div>
<template v-else>
<div v-if="!messages.length" class="briefing-empty">
<p>No briefing yet for today.</p>
<p class="briefing-empty-hint">Click "Refresh" to generate a briefing now, or wait for the scheduled slot.</p>
</div>
<div v-else class="briefing-messages">
<template v-for="msg in messages" :key="msg.id">
<!-- Weather card above the first assistant message with weather metadata -->
<WeatherCard
v-if="msg.role === 'assistant' && msgMetadata(msg)?.weather !== undefined"
:weather="msgMetadata(msg)?.weather ?? null"
/>
<ChatMessage
:message="toMsg(msg)"
:is-streaming="false"
/>
<!-- News cards below assistant messages with rss_items -->
<div
v-if="msg.role === 'assistant' && (msgMetadata(msg)?.rss_items?.length ?? 0) > 0"
class="news-cards"
>
<div
v-for="item in msgMetadata(msg)!.rss_items"
:key="item.id"
class="news-card"
>
<div class="news-card-meta">
<span class="news-source">{{ item.source }}</span>
<span v-if="item.published_at" class="news-date">{{ formatRelativeDate(item.published_at) }}</span>
</div>
<a
v-if="item.url"
:href="item.url"
target="_blank"
rel="noopener noreferrer"
class="news-title"
>{{ item.title }}</a>
<p v-else class="news-title news-title--plain">{{ item.title }}</p>
<p v-if="item.snippet" class="news-snippet">{{ item.snippet }}</p>
<div class="news-reactions">
<button
class="reaction-btn"
:class="{ active: reactions[item.id] === 'up' }"
@click="handleReaction(item.id, 'up')"
title="Interested"
>👍</button>
<button
class="reaction-btn"
:class="{ active: reactions[item.id] === 'down' }"
@click="handleReaction(item.id, 'down')"
title="Not interested"
>👎</button>
</div>
</div>
</div>
</template>
<!-- Live streaming bubble for today -->
<ChatMessage
v-if="isToday && chatStore.streaming && chatStore.streamingContent"
:message="{
id: -1,
conversation_id: todayConvId ?? -1,
role: 'assistant',
content: chatStore.streamingContent,
context_note_id: null,
context_note_title: null,
created_at: new Date().toISOString(),
}"
:is-streaming="true"
/>
</div>
<!-- Left column: Weather -->
<div class="briefing-left">
<div class="panel-label">Weather</div>
<template v-if="weatherData.length">
<WeatherCard
v-for="loc in weatherData"
:key="(loc as WeatherData).location"
:weather="loc"
:temp-unit="tempUnit"
/>
</template>
<div v-else class="panel-empty">No weather configured</div>
</div>
<!-- Input bar (today only) -->
<div v-if="isToday" class="briefing-input-bar">
<textarea
v-model="input"
class="briefing-input"
placeholder="Reply to your briefing…"
rows="1"
@keydown="onKeydown"
></textarea>
<button
class="btn-send"
@click="send"
:disabled="!input.trim() || chatStore.streaming || sending"
aria-label="Send"
<!-- Center column: Chat -->
<div class="briefing-center">
<div class="briefing-messages-wrap" ref="messagesEl">
<div v-if="loadingMessages" class="briefing-loading">Loading</div>
<template v-else>
<div v-if="!messages.length" class="briefing-empty">
<p>No briefing yet for today.</p>
<p class="briefing-empty-hint">Click "Refresh" to generate a briefing now, or wait for the scheduled slot.</p>
</div>
<div v-else class="briefing-messages">
<template v-for="msg in messages" :key="msg.id">
<ChatMessage
:message="toMsg(msg)"
:is-streaming="false"
/>
</template>
<!-- Live streaming bubble for today -->
<ChatMessage
v-if="isToday && chatStore.streaming && chatStore.streamingContent"
:message="{
id: -1,
conversation_id: todayConvId ?? -1,
role: 'assistant',
content: chatStore.streamingContent,
context_note_id: null,
context_note_title: null,
created_at: new Date().toISOString(),
}"
:is-streaming="true"
/>
</div>
</template>
</div>
<!-- Input bar (today only) -->
<div v-if="isToday" class="briefing-input-bar">
<textarea
v-model="input"
class="briefing-input"
placeholder="Reply to your briefing…"
rows="1"
@keydown="onKeydown"
></textarea>
<button
class="btn-send"
@click="send"
:disabled="!input.trim() || chatStore.streaming || sending"
aria-label="Send"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
<path d="M2 21l21-9L2 3v7l15 2-15 2v7z"/>
</svg>
</button>
</div>
</div>
<!-- Right column: News -->
<div class="briefing-right">
<div class="panel-label-row">
<div class="panel-label">Today's News</div>
<span v-if="newsItems.length" class="news-count">{{ newsItems.length }} items</span>
</div>
<div v-if="!newsItems.length" class="panel-empty">No articles in the last 2 days</div>
<div
v-for="item in newsItems"
:key="item.id"
class="news-card"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
<path d="M2 21l21-9L2 3v7l15 2-15 2v7z"/>
</svg>
</button>
<div class="news-card-meta">
<span class="news-source">{{ item.source }}</span>
<span v-if="item.published_at" class="news-date">{{ formatRelativeDate(item.published_at) }}</span>
</div>
<a
v-if="item.url"
:href="item.url"
target="_blank"
rel="noopener noreferrer"
class="news-title"
>{{ item.title }}</a>
<p v-else class="news-title news-title--plain">{{ item.title }}</p>
<p v-if="item.snippet" class="news-snippet">{{ item.snippet }}</p>
<div class="news-reactions">
<button
class="reaction-btn"
:class="{ active: reactions[item.id] === 'up' }"
@click="handleReaction(item.id, 'up')"
title="Interested"
>👍</button>
<button
class="reaction-btn"
:class="{ active: reactions[item.id] === 'down' }"
@click="handleReaction(item.id, 'down')"
title="Not interested"
>👎</button>
</div>
</div>
</div>
</div>
</div>
@@ -373,21 +414,20 @@ onMounted(async () => {
}
.briefing-shell {
display: flex;
flex-direction: column;
display: grid;
grid-template-columns: 1fr 2fr 1fr;
grid-template-rows: auto 1fr;
height: 100%;
min-height: 0;
max-width: 760px;
margin: 0 auto;
width: 100%;
padding: 0 1rem;
}
.briefing-header {
grid-column: 1 / -1;
grid-row: 1;
display: flex;
align-items: center;
justify-content: space-between;
padding: 1.25rem 0 1rem;
padding: 1.25rem 1rem 1rem;
border-bottom: 1px solid var(--color-border);
flex-shrink: 0;
gap: 1rem;
@@ -448,10 +488,38 @@ onMounted(async () => {
}
.btn-trigger:disabled { opacity: 0.5; cursor: not-allowed; }
/* ─── Left column (Weather) ──────────────────────────────────────────────── */
.briefing-left {
grid-column: 1;
grid-row: 2;
border-right: 1px solid var(--color-border);
overflow-y: auto;
padding: 1rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.briefing-left :deep(.weather-card) {
margin-bottom: 0;
}
/* ─── Center column (Chat) ───────────────────────────────────────────────── */
.briefing-center {
grid-column: 2;
grid-row: 2;
display: flex;
flex-direction: column;
min-height: 0;
}
.briefing-messages-wrap {
flex: 1;
overflow-y: auto;
padding: 1rem 0;
padding: 1rem;
min-height: 0;
}
.briefing-loading,
@@ -478,7 +546,7 @@ onMounted(async () => {
display: flex;
gap: 0.5rem;
align-items: flex-end;
padding: 0.75rem 0 1rem;
padding: 0.75rem 1rem 1rem;
border-top: 1px solid var(--color-border);
flex-shrink: 0;
}
@@ -517,12 +585,44 @@ onMounted(async () => {
.btn-send:disabled { opacity: 0.4; cursor: not-allowed; }
.btn-send:hover:not(:disabled) { opacity: 0.9; }
.news-cards {
/* ─── Right column (News) ────────────────────────────────────────────────── */
.briefing-right {
grid-column: 3;
grid-row: 2;
border-left: 1px solid var(--color-border);
overflow-y: auto;
padding: 1rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-top: 0.25rem;
margin-bottom: 0.25rem;
}
.panel-label {
font-size: 0.72rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--color-primary);
flex-shrink: 0;
}
.panel-label-row {
display: flex;
align-items: center;
justify-content: space-between;
flex-shrink: 0;
}
.news-count {
font-size: 0.72rem;
color: var(--color-text-muted);
}
.panel-empty {
font-size: 0.82rem;
color: var(--color-text-muted);
padding: 0.5rem 0;
}
.news-card {
@@ -533,6 +633,7 @@ onMounted(async () => {
display: flex;
flex-direction: column;
gap: 0.25rem;
flex-shrink: 0;
}
.news-card-meta {
@@ -604,4 +705,35 @@ a.news-title:hover { text-decoration: underline; color: var(--color-primary); }
border-color: var(--color-primary);
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
}
/* ─── Responsive ─────────────────────────────────────────────────────────── */
@media (max-width: 900px) {
.briefing-shell {
grid-template-columns: 1fr;
grid-template-rows: auto auto 1fr auto;
}
.briefing-header {
grid-column: 1;
grid-row: 1;
}
.briefing-left {
grid-column: 1;
grid-row: 2;
border-right: none;
border-bottom: 1px solid var(--color-border);
max-height: 220px;
}
.briefing-center {
grid-column: 1;
grid-row: 3;
}
.briefing-right {
grid-column: 1;
grid-row: 4;
border-left: none;
border-top: 1px solid var(--color-border);
max-height: 260px;
}
}
</style>
+371
View File
@@ -0,0 +1,371 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import {
getBriefingFeeds,
postRssReaction,
deleteRssReaction,
getNewsItems,
type BriefingFeed,
} from '@/api/client'
import type { NewsItem } from '@/types/news'
const LIMIT = 40
const items = ref<NewsItem[]>([])
const offset = ref(0)
const hasMore = ref(true)
const loading = ref(false)
const feeds = ref<BriefingFeed[]>([])
const selectedFeedId = ref<number | null>(null)
// Reactions map: item id → current reaction
const reactions = ref<Record<number, 'up' | 'down' | null>>({})
async function loadMore() {
if (loading.value || !hasMore.value) return
loading.value = true
try {
const data = await getNewsItems({
days: 90,
limit: LIMIT,
offset: offset.value,
feed_id: selectedFeedId.value,
})
for (const item of data.items) {
if (reactions.value[item.id] === undefined) {
reactions.value[item.id] = item.reaction
}
}
items.value = [...items.value, ...data.items]
offset.value += data.items.length
hasMore.value = data.items.length === LIMIT
} finally {
loading.value = false
}
}
function onFeedChange() {
items.value = []
offset.value = 0
hasMore.value = true
reactions.value = {}
loadMore()
}
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 formatRelativeDate(iso: string | null): string {
if (!iso) return ''
const d = new Date(iso)
const now = new Date()
const diffH = (now.getTime() - d.getTime()) / 3_600_000
if (diffH < 1) return 'Just now'
if (diffH < 24) return `${Math.round(diffH)}h ago`
if (diffH < 48) return 'Yesterday'
const days = Math.floor(diffH / 24)
if (days < 7) return `${days}d ago`
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
}
onMounted(async () => {
feeds.value = await getBriefingFeeds().catch(() => [])
await loadMore()
})
</script>
<template>
<div class="news-root">
<div class="news-header">
<div class="news-header-left">
<h1 class="news-title">News</h1>
<span class="news-subtitle">Last 90 days</span>
</div>
<div class="news-header-right">
<select
v-model="selectedFeedId"
class="feed-select"
@change="onFeedChange"
>
<option :value="null">All feeds</option>
<option v-for="feed in feeds" :key="feed.id" :value="feed.id">
{{ feed.title }}
</option>
</select>
</div>
</div>
<div class="news-list">
<div v-if="!items.length && !loading" class="news-empty">
No articles found for the selected feed.
</div>
<div
v-for="item in items"
:key="item.id"
class="news-card"
>
<div class="news-card-meta">
<span class="news-source">{{ item.source }}</span>
<span v-if="item.published_at" class="news-date">{{ formatRelativeDate(item.published_at) }}</span>
</div>
<a
v-if="item.url"
:href="item.url"
target="_blank"
rel="noopener noreferrer"
class="news-card-title"
>{{ item.title }}</a>
<p v-else class="news-card-title news-card-title--plain">{{ item.title }}</p>
<p v-if="item.snippet" class="news-snippet">{{ item.snippet }}</p>
<div v-if="item.topics?.length" class="news-topics">
<span v-for="topic in item.topics" :key="topic" class="news-topic">{{ topic }}</span>
</div>
<div class="news-reactions">
<button
class="reaction-btn"
:class="{ active: reactions[item.id] === 'up' }"
@click="handleReaction(item.id, 'up')"
title="Interested"
>👍</button>
<button
class="reaction-btn"
:class="{ active: reactions[item.id] === 'down' }"
@click="handleReaction(item.id, 'down')"
title="Not interested"
>👎</button>
</div>
</div>
<div class="news-footer">
<button
v-if="hasMore"
class="btn-load-more"
@click="loadMore"
:disabled="loading"
>{{ loading ? 'Loading…' : 'Load more' }}</button>
<div v-if="loading && !items.length" class="news-loading">Loading</div>
<p v-if="!hasMore && items.length" class="news-end">All articles loaded</p>
</div>
</div>
</div>
</template>
<style scoped>
.news-root {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
overflow-y: auto;
}
.news-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1.25rem 1.5rem 1rem;
border-bottom: 1px solid var(--color-border);
flex-shrink: 0;
flex-wrap: wrap;
gap: 0.75rem;
}
.news-header-left {
display: flex;
align-items: baseline;
gap: 0.75rem;
}
.news-title {
font-family: 'Fraunces', Georgia, serif;
font-size: 1.3rem;
font-weight: 700;
margin: 0;
color: var(--color-text);
}
.news-subtitle {
font-size: 0.82rem;
color: var(--color-text-muted);
}
.news-header-right {
display: flex;
align-items: center;
gap: 0.5rem;
}
.feed-select {
padding: 0.35rem 0.6rem;
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--color-bg-card);
color: var(--color-text);
font-size: 0.82rem;
cursor: pointer;
font-family: inherit;
}
.news-list {
padding: 1rem 1.5rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
max-width: 860px;
width: 100%;
margin: 0 auto;
}
.news-empty,
.news-loading {
text-align: center;
padding: 3rem 1rem;
color: var(--color-text-muted);
font-size: 0.9rem;
}
.news-card {
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: 10px;
padding: 0.85rem 1rem;
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.news-card-meta {
display: flex;
align-items: center;
gap: 0.5rem;
}
.news-source {
font-size: 0.72rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-primary);
}
.news-date {
font-size: 0.72rem;
color: var(--color-text-muted);
}
.news-card-title {
font-size: 0.95rem;
font-weight: 600;
color: var(--color-text);
line-height: 1.4;
text-decoration: none;
margin: 0;
}
a.news-card-title:hover {
text-decoration: underline;
color: var(--color-primary);
}
.news-snippet {
font-size: 0.82rem;
color: var(--color-text-muted);
line-height: 1.5;
margin: 0;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.news-topics {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
margin-top: 0.1rem;
}
.news-topic {
font-size: 0.68rem;
padding: 0.15rem 0.5rem;
background: color-mix(in srgb, var(--color-primary) 10%, transparent);
color: var(--color-primary);
border-radius: 99px;
font-weight: 500;
}
.news-reactions {
display: flex;
gap: 0.3rem;
margin-top: 0.2rem;
}
.reaction-btn {
background: none;
border: 1px solid var(--color-border);
border-radius: 6px;
padding: 0.1rem 0.4rem;
cursor: pointer;
font-size: 0.82rem;
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);
}
.news-footer {
display: flex;
justify-content: center;
padding: 1rem 0 0.5rem;
}
.btn-load-more {
padding: 0.5rem 1.5rem;
border: 1px solid var(--color-border);
border-radius: 8px;
background: var(--color-bg-card);
color: var(--color-text-muted);
font-size: 0.85rem;
cursor: pointer;
font-family: inherit;
transition: all 0.15s;
}
.btn-load-more:hover:not(:disabled) {
border-color: var(--color-primary);
color: var(--color-primary);
}
.btn-load-more:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.news-end {
font-size: 0.8rem;
color: var(--color-text-muted);
margin: 0;
}
</style>
+3 -4
View File
@@ -2263,7 +2263,6 @@ FABLE_API_KEY=&lt;your-api-key&gt;</pre>
<ul v-if="groupMemberResults.length" class="member-results">
<li v-for="u in groupMemberResults" :key="u.id" class="member-result-item" @click="addMemberToGroup(g.id, u)">
<span class="member-result-name">{{ u.username }}</span>
<span class="member-result-email">{{ u.email }}</span>
</li>
</ul>
</div>
@@ -3026,7 +3025,7 @@ FABLE_API_KEY=&lt;your-api-key&gt;</pre>
.version-line {
margin: 0;
font-size: 0.9rem;
color: var(--color-text-secondary);
color: var(--color-text);
display: flex;
align-items: center;
gap: 0.6rem;
@@ -3036,9 +3035,9 @@ FABLE_API_KEY=&lt;your-api-key&gt;</pre>
font-size: 0.8rem;
padding: 0.15rem 0.5rem;
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
border: 1px solid var(--color-primary);
border-radius: 4px;
color: var(--color-text-muted);
color: var(--color-primary);
}
/* ── Groups tab ──────────────────────────────────────────────── */
+48
View File
@@ -12,6 +12,7 @@ import { useTagSuggestions } from "@/composables/useTagSuggestions";
import { useFloatingAssist } from "@/composables/useFloatingAssist";
import { apiPost, apiGet, apiPatch } from "@/api/client";
import type { TaskStatus, TaskPriority } from "@/types/task";
import type { Note } from "@/types/note";
import type { Editor } from "@tiptap/vue-3";
import MarkdownToolbar from "@/components/MarkdownToolbar.vue";
import TiptapEditor from "@/components/TiptapEditor.vue";
@@ -23,6 +24,7 @@ import TaskLogSection from "@/components/TaskLogSection.vue";
import DiffView from "@/components/DiffView.vue";
import ConfirmDialog from "@/components/ConfirmDialog.vue";
import VersionHistorySection from "@/components/VersionHistorySection.vue";
import RecurrenceEditor from "@/components/RecurrenceEditor.vue";
const route = useRoute();
const router = useRouter();
@@ -40,6 +42,9 @@ const projectId = ref<number | null>(null);
const milestoneId = ref<number | null>(null);
const parentId = ref<number | null>(null);
const parentTitle = ref("");
const startedAt = ref<string | null>(null);
const completedAt = ref<string | null>(null);
const recurrenceRule = ref<Record<string, unknown> | null>(null);
const parentSearchQuery = ref("");
const parentSearchResults = ref<{ id: number; title: string }[]>([]);
const parentSearchLoading = ref(false);
@@ -274,6 +279,10 @@ onMounted(async () => {
parentId.value = (taskRec.parent_id as number | null) ?? null;
parentTitle.value = (taskRec.parent_title as string | null) ?? "";
parentSearchQuery.value = parentTitle.value;
const noteTask = store.currentTask as unknown as Note;
startedAt.value = noteTask.started_at ?? null;
completedAt.value = noteTask.completed_at ?? null;
recurrenceRule.value = noteTask.recurrence_rule ?? null;
savedTitle = title.value;
savedBody = body.value;
savedTags = [...tags.value];
@@ -309,6 +318,7 @@ async function save() {
project_id: projectId.value,
milestone_id: milestoneId.value,
parent_id: parentId.value,
recurrence_rule: recurrenceRule.value,
};
if (isEditing.value) {
await store.updateTask(taskId.value!, data);
@@ -373,6 +383,7 @@ async function doAutoSave() {
project_id: projectId.value,
milestone_id: milestoneId.value,
parent_id: parentId.value,
recurrence_rule: recurrenceRule.value,
} as Record<string, unknown>);
savedTitle = title.value;
savedBody = body.value;
@@ -483,8 +494,19 @@ useEditorGuards(dirty, save);
<option value="todo">Todo</option>
<option value="in_progress">In Progress</option>
<option value="done">Done</option>
<option value="cancelled">Cancelled</option>
</select>
</div>
<div v-if="startedAt || completedAt" class="sb-timestamps">
<div v-if="startedAt" class="sb-timestamp">
<span class="sb-ts-label">Started</span>
<span class="sb-ts-value">{{ new Date(startedAt).toLocaleString() }}</span>
</div>
<div v-if="completedAt" class="sb-timestamp">
<span class="sb-ts-label">Completed</span>
<span class="sb-ts-value">{{ new Date(completedAt).toLocaleString() }}</span>
</div>
</div>
<div class="sb-field">
<label class="sb-label">Priority</label>
<select v-model="priority" @change="markDirty" class="sb-select">
@@ -498,6 +520,10 @@ useEditorGuards(dirty, save);
<label class="sb-label">Due Date</label>
<input v-model="dueDate" type="date" class="sb-input" @input="markDirty" />
</div>
<div class="sb-field">
<label class="sb-label">Recurrence</label>
<RecurrenceEditor v-model="recurrenceRule" @update:modelValue="markDirty" />
</div>
<div class="sb-field">
<label class="sb-label">Project</label>
<ProjectSelector v-model="projectId" @update:modelValue="markDirty" />
@@ -903,6 +929,28 @@ useEditorGuards(dirty, save);
align-items: center;
}
/* Lifecycle timestamps */
.sb-timestamps {
display: flex;
flex-direction: column;
gap: 0.2rem;
margin-top: 0.25rem;
}
.sb-timestamp {
display: flex;
justify-content: space-between;
gap: 0.4rem;
font-size: 0.75rem;
}
.sb-ts-label {
color: var(--color-text-muted);
flex-shrink: 0;
}
.sb-ts-value {
color: var(--color-text-secondary);
text-align: right;
}
/* Narrow screen: sidebar collapses */
@media (max-width: 720px) {
.task-body { flex-direction: column; overflow-y: auto; overflow-x: hidden; }
+47
View File
@@ -33,12 +33,14 @@ const statusCycle: Record<TaskStatus, TaskStatus> = {
todo: "in_progress",
in_progress: "done",
done: "todo",
cancelled: "todo",
};
const statusDotClass: Record<TaskStatus, string> = {
todo: "dot-todo",
in_progress: "dot-in-progress",
done: "dot-done",
cancelled: "dot-cancelled",
};
function cycleSubTaskStatus(subTask: Note) {
@@ -137,8 +139,25 @@ const forwardStatus: Record<TaskStatus, TaskStatus | null> = {
todo: "in_progress",
in_progress: "done",
done: null,
cancelled: null,
};
function recurrenceSummary(rule: Record<string, unknown> | null): string | null {
if (!rule) return null;
if (rule.type === "interval") {
return `Every ${rule.every} ${rule.unit}(s)`;
}
if (rule.type === "calendar") {
if (rule.unit === "month") return `Monthly on day ${rule.day_of_month}`;
if (rule.unit === "year") {
const months = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];
const m = months[((rule.month as number) ?? 1) - 1];
return `Yearly on ${m} ${rule.day_of_month}`;
}
}
return null;
}
const advanceLabel = computed(() => {
const s = store.currentTask?.status as TaskStatus | undefined;
if (!s) return null;
@@ -318,6 +337,17 @@ const subTaskProgress = computed(() => {
Due: {{ store.currentTask.due_date }}
</span>
</div>
<div class="task-meta-row" v-if="store.currentTask.started_at || store.currentTask.completed_at || store.currentTask.recurrence_rule">
<span v-if="store.currentTask.started_at" class="task-meta-item">
Started: {{ new Date(store.currentTask.started_at).toLocaleString() }}
</span>
<span v-if="store.currentTask.completed_at" class="task-meta-item">
Completed: {{ new Date(store.currentTask.completed_at).toLocaleString() }}
</span>
<span v-if="recurrenceSummary(store.currentTask.recurrence_rule as Record<string, unknown> | null)" class="task-meta-item task-meta-recurrence">
{{ recurrenceSummary(store.currentTask.recurrence_rule as Record<string, unknown> | null) }}
</span>
</div>
<div class="tags" v-if="store.currentTask.tags.length">
<TagPill
v-for="tag in store.currentTask.tags"
@@ -551,6 +581,20 @@ const subTaskProgress = computed(() => {
color: var(--color-overdue);
font-weight: 600;
}
.task-meta-row {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
margin-bottom: 0.5rem;
}
.task-meta-item {
font-size: 0.78rem;
color: var(--color-text-muted);
}
.task-meta-recurrence {
color: var(--color-primary);
font-weight: 500;
}
.tags {
display: flex;
gap: 0.5rem;
@@ -637,6 +681,9 @@ const subTaskProgress = computed(() => {
.dot-done {
background: var(--color-status-done, #22c55e);
}
.dot-cancelled {
background: var(--color-text-muted, #6b7280);
}
.sub-title {
flex: 1;
font-size: 0.9rem;
+51 -22
View File
@@ -2,7 +2,7 @@
import { ref, computed, onMounted, onUnmounted, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useTasksStore } from "@/stores/tasks";
import type { Task, TaskStatus } from "@/types/task";
import type { Task, TaskStatus, TaskPriority } from "@/types/task";
import { apiGet } from "@/api/client";
import { useListKeyboardNavigation } from "@/composables/useListKeyboardNavigation";
import SearchBar from "@/components/SearchBar.vue";
@@ -122,7 +122,8 @@ onMounted(async () => {
store.activeTagFilters = tags;
}
if (route.query.status) {
store.statusFilter = route.query.status as TaskStatus;
const qs = route.query.status;
store.statusFilter = (Array.isArray(qs) ? qs : [qs]).filter(Boolean) as TaskStatus[];
}
store.limit = viewMode.value === "grouped" ? 100 : 200;
collapsedGroups.value.add("done");
@@ -150,14 +151,20 @@ function onSearch(q: string) {
store.setSearch(q);
}
function onStatusFilterChange(e: Event) {
const value = (e.target as HTMLSelectElement).value;
store.setStatusFilter(value as TaskStatus | "");
function toggleStatusChip(value: TaskStatus) {
const current = [...store.statusFilter];
const idx = current.indexOf(value);
if (idx === -1) current.push(value);
else current.splice(idx, 1);
store.setStatusFilter(current);
}
function onPriorityFilterChange(e: Event) {
const value = (e.target as HTMLSelectElement).value;
store.setPriorityFilter(value as any);
function togglePriorityChip(value: TaskPriority) {
const current = [...store.priorityFilter];
const idx = current.indexOf(value);
if (idx === -1) current.push(value);
else current.splice(idx, 1);
store.setPriorityFilter(current);
}
function onTagClick(tag: string) {
@@ -210,19 +217,20 @@ function toggleGroup(key: string) {
<div class="controls">
<div class="filter-controls">
<select :value="store.statusFilter" @change="onStatusFilterChange" class="filter-select">
<option value="">All Statuses</option>
<option value="todo">Todo</option>
<option value="in_progress">In Progress</option>
<option value="done">Done</option>
</select>
<select :value="store.priorityFilter" @change="onPriorityFilterChange" class="filter-select">
<option value="">All Priorities</option>
<option value="none">None</option>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
</select>
<div class="filter-chip-group">
<button v-for="s in (['todo', 'in_progress', 'done', 'cancelled'] as TaskStatus[])" :key="s"
:class="['filter-chip', { active: store.statusFilter.includes(s) }]"
@click="toggleStatusChip(s)">
{{ { todo: 'Todo', in_progress: 'In Progress', done: 'Done', cancelled: 'Cancelled' }[s] }}
</button>
</div>
<div class="filter-chip-group">
<button v-for="p in (['low', 'medium', 'high'] as TaskPriority[])" :key="p"
:class="['filter-chip', { active: store.priorityFilter.includes(p) }]"
@click="togglePriorityChip(p)">
{{ p.charAt(0).toUpperCase() + p.slice(1) }}
</button>
</div>
</div>
<div class="right-controls">
<div class="sort-controls">
@@ -270,7 +278,7 @@ function toggleGroup(key: string) {
</div>
<div v-else-if="store.tasks.length === 0" class="empty-state">
<template v-if="store.searchQuery || store.activeTagFilters.length || store.statusFilter || store.priorityFilter">
<template v-if="store.searchQuery || store.activeTagFilters.length || store.statusFilter.length || store.priorityFilter.length">
<p class="empty-title">No tasks match your filters</p>
<p class="empty-subtitle">Try adjusting your search or removing filters.</p>
</template>
@@ -382,8 +390,29 @@ function toggleGroup(key: string) {
}
.filter-controls {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.filter-chip-group {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.filter-chip {
padding: 3px 10px;
border-radius: 999px;
border: 1px solid var(--color-input-border);
background: transparent;
color: var(--color-text-muted);
cursor: pointer;
font-size: 0.78rem;
transition: background 0.15s, color 0.15s, border-color 0.15s;
}
.filter-chip.active {
background: var(--color-primary);
color: #fff;
border-color: var(--color-primary);
}
.right-controls {
display: flex;
align-items: center;
+17
View File
@@ -0,0 +1,17 @@
#!/usr/bin/env bash
# Bump the patch segment of fable-mcp/pyproject.toml version and stage the file.
# Usage: called automatically by the Claude Code pre-commit hook, or manually.
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
FILE="$REPO_ROOT/fable-mcp/pyproject.toml"
current=$(grep '^version = ' "$FILE" | sed 's/version = "\(.*\)"/\1/')
major=$(echo "$current" | cut -d. -f1)
minor=$(echo "$current" | cut -d. -f2)
patch=$(echo "$current" | cut -d. -f3)
new_version="$major.$minor.$((patch + 1))"
sed -i "s/^version = \"$current\"/version = \"$new_version\"/" "$FILE"
git -C "$REPO_ROOT" add "$FILE"
echo "fable-mcp: $current$new_version"
+39
View File
@@ -0,0 +1,39 @@
#!/usr/bin/env bash
# Claude Code PreToolUse hook for Bash.
# Reads the tool input JSON from stdin; if the command is a git commit
# and fable-mcp files (other than pyproject.toml) are staged, bumps
# the fable-mcp patch version before the commit proceeds.
#
# Exits 0 always so it never blocks the commit.
set -euo pipefail
REPO_ROOT="/home/bvandeusen/Nextcloud/Projects/fabledassistant"
input=$(cat)
command=$(echo "$input" | python3 -c "
import sys, json
data = json.load(sys.stdin)
# Claude Code sends {tool_input: {command: ...}}
ti = data.get('tool_input', data)
print(ti.get('command', ''))
" 2>/dev/null || echo "")
# Only act on git commit commands
if ! echo "$command" | grep -qE "git commit"; then
exit 0
fi
cd "$REPO_ROOT"
# Check if fable-mcp files other than pyproject.toml are staged
fable_staged=$(git diff --cached --name-only 2>/dev/null \
| grep "^fable-mcp/" \
| grep -v "^fable-mcp/pyproject.toml$" \
|| true)
if [ -n "$fable_staged" ]; then
bash "$REPO_ROOT/scripts/bump_fable_mcp_version.sh"
fi
exit 0
+7
View File
@@ -62,6 +62,13 @@ def create_app() -> Quart:
"Set SECRET_KEY or SECRET_KEY_FILE for production use."
)
if not Config.TRUST_PROXY_HEADERS:
logger.warning(
"TRUST_PROXY_HEADERS is not set. If this instance is behind a reverse proxy "
"(nginx, Caddy, Traefik) set TRUST_PROXY_HEADERS=true so rate limiting uses "
"real client IPs rather than the proxy IP."
)
app.register_blueprint(admin_bp)
app.register_blueprint(api)
app.register_blueprint(auth_bp)
+5
View File
@@ -88,5 +88,10 @@ class Config:
errors.append(f"SMTP_PORT={cls.SMTP_PORT} must be between 1 and 65535")
if cls.oidc_enabled() and not cls.BASE_URL.startswith(("http://", "https://")):
errors.append(f"BASE_URL='{cls.BASE_URL}' must start with http:// or https:// when OIDC is enabled")
if cls.SECRET_KEY == "dev-secret-change-me" and cls.SECURE_COOKIES:
errors.append(
"SECRET_KEY is set to the insecure default but SECURE_COOKIES=true indicates "
"a production deployment. Set SECRET_KEY or SECRET_KEY_FILE before starting."
)
if errors:
raise ValueError("Configuration errors:\n" + "\n".join(f" - {e}" for e in errors))
+18 -3
View File
@@ -1,8 +1,8 @@
import enum
from datetime import date
from datetime import date, datetime
from sqlalchemy import Date, ForeignKey, Index, Integer, Text
from sqlalchemy.dialects.postgresql import ARRAY
from sqlalchemy import Date, DateTime, ForeignKey, Index, Integer, Text
from sqlalchemy.dialects.postgresql import ARRAY, JSONB
from sqlalchemy.orm import Mapped, mapped_column
from fabledassistant.models import Base
@@ -13,6 +13,7 @@ class TaskStatus(str, enum.Enum):
todo = "todo"
in_progress = "in_progress"
done = "done"
cancelled = "cancelled"
class TaskPriority(str, enum.Enum):
@@ -44,6 +45,12 @@ class Note(Base, TimestampMixin):
status: Mapped[str | None] = mapped_column(Text, nullable=True)
priority: Mapped[str | None] = mapped_column(Text, nullable=True)
due_date: Mapped[date | None] = mapped_column(Date, nullable=True)
started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
recurrence_rule: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
recurrence_next_spawn_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
__table_args__ = (
Index("ix_notes_tags", "tags", postgresql_using="gin"),
@@ -70,6 +77,14 @@ class Note(Base, TimestampMixin):
"status": self.status,
"priority": self.priority,
"due_date": self.due_date.isoformat() if self.due_date else None,
"started_at": self.started_at.isoformat() if self.started_at else None,
"completed_at": self.completed_at.isoformat() if self.completed_at else None,
"recurrence_rule": self.recurrence_rule,
"recurrence_next_spawn_at": (
self.recurrence_next_spawn_at.isoformat()
if self.recurrence_next_spawn_at
else None
),
"is_task": self.is_task,
"created_at": self.created_at.isoformat(),
"updated_at": self.updated_at.isoformat(),
+9
View File
@@ -1,3 +1,12 @@
"""In-process sliding-window rate limiter.
IMPORTANT — deployment note:
Rate limit counters are stored in memory and are lost on process restart.
When deployed behind a reverse proxy (nginx, Caddy, Traefik) you MUST set
TRUST_PROXY_HEADERS=true so that the real client IP is used as the bucket key
rather than the proxy's IP (which would cause all users to share one bucket).
"""
import asyncio
import time
from collections import defaultdict
+4 -1
View File
@@ -359,7 +359,10 @@ async def oauth_callback():
return redirect("/login?error=oauth")
sub = claims.get("sub", "")
email = claims.get("email", "")
# Only trust the email claim for account linking if the provider has verified it.
# An unverified email could be used to hijack an existing local account.
email_verified = claims.get("email_verified", False)
email = claims.get("email", "") if email_verified else ""
preferred_username = claims.get("preferred_username", "")
if not sub:
+76 -2
View File
@@ -71,6 +71,12 @@ async def add_feed():
url = (data.get("url") or "").strip()
if not url:
return jsonify({"error": "url required"}), 400
scheme = url.split("://")[0].lower() if "://" in url else ""
if scheme not in ("http", "https"):
return jsonify({"error": "Feed URL must use http or https"}), 400
from fabledassistant.services.llm import _is_private_url
if _is_private_url(url):
return jsonify({"error": "Feed URL must not point to an internal address"}), 400
category = data.get("category") or None
async with async_session() as session:
@@ -129,8 +135,21 @@ async def recent_items():
@briefing_bp.route("/weather", methods=["GET"])
@_REQUIRE
async def get_weather():
data = await weather_svc.get_cached_weather(g.user.id)
return jsonify({"locations": data})
rows = await weather_svc.get_cached_weather_rows(g.user.id)
import json as _json
raw = await get_setting(g.user.id, "briefing_config", "{}")
try:
_cfg = _json.loads(raw) if isinstance(raw, str) else (raw or {})
temp_unit = _cfg.get("temp_unit", "C")
if temp_unit not in ("C", "F"):
temp_unit = "C"
except Exception:
temp_unit = "C"
cards = [
card for row in rows
if (card := weather_svc.parse_weather_card_data(row, temp_unit)) is not None
]
return jsonify({"locations": cards, "temp_unit": temp_unit})
@briefing_bp.route("/weather/geocode", methods=["POST"])
@@ -322,3 +341,58 @@ async def delete_rss_reaction(item_id: int):
await session.commit()
return jsonify({"ok": True})
@briefing_bp.route("/news", methods=["GET"])
@_REQUIRE
async def list_news():
"""Return recent RSS articles with optional feed filter and pagination.
Query params:
days — lookback window (default 2, max 90)
limit — items per page (default 40, max 100)
offset — pagination offset (default 0)
feed_id — optional integer filter by feed
"""
from sqlalchemy import text as _text
days = min(int(request.args.get("days", 2)), 90)
limit = min(int(request.args.get("limit", 40)), 100)
offset = max(int(request.args.get("offset", 0)), 0)
feed_id = request.args.get("feed_id", type=int)
async with async_session() as session:
result = await session.execute(
_text("""
SELECT
i.id, i.title, i.url, i.content, i.published_at,
i.topics, f.title AS feed_title,
r.reaction
FROM rss_items i
JOIN rss_feeds f ON f.id = i.feed_id
LEFT JOIN rss_item_reactions r
ON r.rss_item_id = i.id AND r.user_id = :uid
WHERE f.user_id = :uid
AND (CAST(:feed_id AS integer) IS NULL OR f.id = CAST(:feed_id AS integer))
AND COALESCE(i.published_at, i.fetched_at) >= NOW() - make_interval(days => :days)
ORDER BY COALESCE(i.published_at, i.fetched_at) DESC
LIMIT :limit OFFSET :offset
""").bindparams(uid=g.user.id, days=days, limit=limit,
offset=offset, feed_id=feed_id)
)
rows = result.mappings().all()
items = [
{
"id": r["id"],
"title": r["title"],
"url": r["url"],
"snippet": (r["content"] or "")[:300],
"published_at": r["published_at"].isoformat() if r["published_at"] else None,
"topics": r["topics"] or [],
"source": r["feed_title"],
"reaction": r["reaction"],
}
for r in rows
]
return jsonify({"items": items, "offset": offset, "limit": limit})
+3 -3
View File
@@ -5,7 +5,7 @@ import logging
import httpx
from quart import Blueprint, Response, jsonify, request
from fabledassistant.auth import login_required, get_current_user_id
from fabledassistant.auth import admin_required, login_required, get_current_user_id
from fabledassistant.routes.utils import not_found, parse_pagination
from fabledassistant.config import Config
from fabledassistant.services.chat import (
@@ -439,7 +439,7 @@ async def list_models_route():
@chat_bp.route("/models/pull", methods=["POST"])
@login_required
@admin_required
async def pull_model_route():
"""Pull a model from Ollama, streaming progress via SSE."""
data = await request.get_json()
@@ -478,7 +478,7 @@ async def pull_model_route():
@chat_bp.route("/models/delete", methods=["POST"])
@login_required
@admin_required
async def delete_model_route():
"""Delete a model from Ollama."""
data = await request.get_json()
+3 -6
View File
@@ -1,14 +1,10 @@
"""Serve locally-cached images.
No authentication required — image IDs are opaque and unguessable (based
on the SHA-256 of the original URL). Images are served with a 1-day
Cache-Control header so the browser doesn't re-request on every page load.
"""
"""Serve locally-cached images."""
import logging
from quart import Blueprint, jsonify, send_file
from fabledassistant.auth import login_required
from fabledassistant.services.images import get_image_path, get_image_record
logger = logging.getLogger(__name__)
@@ -17,6 +13,7 @@ images_bp = Blueprint("images", __name__, url_prefix="/api/images")
@images_bp.route("/<int:image_id>", methods=["GET"])
@login_required
async def serve_image(image_id: int):
"""Serve a locally-cached image by its DB ID."""
record = await get_image_record(image_id)
+4
View File
@@ -50,12 +50,16 @@ async def create_milestone_route(project_id: int):
data = await request.get_json()
if not data.get("title"):
return jsonify({"error": "title is required"}), 400
status = data.get("status", "active")
if status not in ("active", "done"):
return jsonify({"error": "status must be 'active' or 'done'"}), 400
milestone = await create_milestone(
uid,
project_id,
title=data["title"],
description=data.get("description"),
order_index=data.get("order_index", 0),
status=status,
)
return jsonify(await _milestone_dict(milestone)), 201
+4 -1
View File
@@ -70,11 +70,14 @@ async def list_notes_route():
elif type_param == "note":
is_task = False
status = request.args.getlist("status") or None
priority = request.args.getlist("priority") or None
notes, total = await list_notes(
uid, q=q, tags=tag or None, is_task=is_task, sort=sort, order=order,
limit=limit, offset=offset,
project_id=project_id, milestone_id=milestone_id, parent_id=parent_id,
no_project=no_project,
no_project=no_project, status=status, priority=priority,
)
return jsonify({"notes": [n.to_dict() for n in notes], "total": total})
+4
View File
@@ -38,12 +38,16 @@ async def create_project_route():
data = await request.get_json()
if not data.get("title"):
return jsonify({"error": "title is required"}), 400
status = data.get("status", "active")
if status not in ("active", "archived"):
return jsonify({"error": "status must be 'active' or 'archived'"}), 400
project = await create_project(
uid,
title=data["title"],
description=data.get("description", ""),
goal=data.get("goal", ""),
color=data.get("color"),
status=status,
)
return jsonify(project.to_dict()), 201
+11 -1
View File
@@ -3,7 +3,7 @@ from quart import Blueprint, jsonify, request
from fabledassistant.auth import login_required, get_current_user_id
from fabledassistant.config import Config
from fabledassistant.services.caldav import CALDAV_SETTING_KEYS, get_caldav_config, test_connection
from fabledassistant.services.llm import get_installed_models
from fabledassistant.services.llm import get_installed_models, _is_private_url
from fabledassistant.services.settings import delete_setting, get_all_settings, set_settings_batch
settings_bp = Blueprint("settings", __name__, url_prefix="/api/settings")
@@ -77,6 +77,16 @@ async def update_caldav():
uid = get_current_user_id()
data = await request.get_json()
# Validate CalDAV URL before saving — block internal/private addresses
if "caldav_url" in data:
url = str(data.get("caldav_url") or "").strip()
if url:
parsed_scheme = url.split("://")[0].lower() if "://" in url else ""
if parsed_scheme not in ("http", "https"):
return jsonify({"error": "CalDAV URL must use http or https"}), 400
if _is_private_url(url):
return jsonify({"error": "CalDAV URL must not point to an internal or private address"}), 400
settings_to_save = {}
for key in CALDAV_SETTING_KEYS:
if key in data:
+77 -7
View File
@@ -1,4 +1,5 @@
import asyncio
from datetime import date
from quart import Blueprint, jsonify, request
@@ -14,9 +15,32 @@ from fabledassistant.services.notes import (
list_notes,
update_note,
)
from fabledassistant.services.recurrence import calculate_next_due, validate_recurrence_rule
tasks_bp = Blueprint("tasks", __name__, url_prefix="/api/tasks")
_UNSET = object()
def _parse_recurrence_rule(data: dict) -> tuple[object, tuple | None]:
"""Extract and validate recurrence_rule from request data.
Returns (rule_or_UNSET, error_response_or_None):
_UNSET → key not present, do nothing
None → key present and null, clear the rule
dict → key present and valid, set the rule
"""
if "recurrence_rule" not in data:
return _UNSET, None
rule = data["recurrence_rule"]
if rule is None:
return None, None
try:
validate_recurrence_rule(rule)
except ValueError as exc:
return _UNSET, (jsonify({"error": str(exc)}), 400)
return rule, None
@tasks_bp.route("", methods=["GET"])
@login_required
@@ -24,8 +48,8 @@ async def list_tasks_route():
uid = get_current_user_id()
q = request.args.get("q")
tag = request.args.getlist("tag")
status = request.args.get("status")
priority = request.args.get("priority")
status = request.args.getlist("status") or None
priority = request.args.getlist("priority") or None
sort = request.args.get("sort", "updated_at")
order = request.args.get("order", "desc")
limit, offset = parse_pagination()
@@ -71,10 +95,18 @@ async def create_task_route():
if isinstance(due_date, tuple):
return due_date
status = TaskStatus(data["status"]).value if "status" in data else TaskStatus.todo.value
priority = (
TaskPriority(data["priority"]).value if "priority" in data else TaskPriority.none.value
)
try:
status = TaskStatus(data["status"]).value if "status" in data else TaskStatus.todo.value
except ValueError:
return jsonify({"error": f"Invalid status: {data['status']}"}), 400
try:
priority = TaskPriority(data["priority"]).value if "priority" in data else TaskPriority.none.value
except ValueError:
return jsonify({"error": f"Invalid priority: {data['priority']}"}), 400
recurrence_rule, recurrence_err = _parse_recurrence_rule(data)
if recurrence_err:
return recurrence_err
project_id = data.get("project_id")
if project_id is None and data.get("project"):
@@ -94,6 +126,7 @@ async def create_task_route():
project_id=project_id,
milestone_id=data.get("milestone_id"),
parent_id=data.get("parent_id"),
recurrence_rule=recurrence_rule if recurrence_rule is not _UNSET else None,
)
text = f"{task.title}\n{task.body}".strip() if task.body else (task.title or "")
if text:
@@ -123,9 +156,19 @@ async def update_task_route(task_id: int):
uid = get_current_user_id()
data = await request.get_json()
fields = {}
for key in ("title", "status", "priority"):
for key in ("title",):
if key in data:
fields[key] = data[key]
if "status" in data:
try:
fields["status"] = TaskStatus(data["status"]).value
except ValueError:
return jsonify({"error": f"Invalid status: {data['status']}"}), 400
if "priority" in data:
try:
fields["priority"] = TaskPriority(data["priority"]).value
except ValueError:
return jsonify({"error": f"Invalid priority: {data['priority']}"}), 400
# Accept both "body" and "description" (prefer body)
if "body" in data:
@@ -149,6 +192,12 @@ async def update_task_route(task_id: int):
if key in data:
fields[key] = data[key]
recurrence_rule, recurrence_err = _parse_recurrence_rule(data)
if recurrence_err:
return recurrence_err
if recurrence_rule is not _UNSET:
fields["recurrence_rule"] = recurrence_rule
task = await update_note(uid, task_id, **fields)
if task is None:
return not_found("Task")
@@ -178,6 +227,27 @@ async def patch_task_status(task_id: int):
return jsonify(task.to_dict())
@tasks_bp.route("/<int:task_id>/recurrence-preview", methods=["GET"])
@login_required
async def recurrence_preview_route(task_id: int):
uid = get_current_user_id()
result = await get_note_for_user(uid, task_id)
if result is None:
return not_found("Task")
task, _ = result
if not task.recurrence_rule:
return jsonify({"error": "Task has no recurrence rule"}), 400
count = min(request.args.get("count", 5, type=int), 10)
base = task.due_date or date.today()
dates = []
current = base
for _ in range(count):
current = calculate_next_due(task.recurrence_rule, current)
dates.append(current.isoformat())
return jsonify({"dates": dates})
@tasks_bp.route("/<int:task_id>", methods=["DELETE"])
@login_required
async def delete_task_route(task_id: int):
+1 -1
View File
@@ -24,6 +24,6 @@ async def search_users():
).limit(10)
)).scalars().all()
return jsonify({"users": [
{"id": u.id, "username": u.username, "email": u.email}
{"id": u.id, "username": u.username}
for u in users
]})
@@ -326,6 +326,23 @@ async def start_briefing_scheduler(loop: asyncio.AbstractEventLoop) -> None:
for user_id, tz in users:
_add_user_jobs(user_id, tz)
from fabledassistant.services.recurrence import spawn_recurring_tasks as _spawn_recurring
def _run_recurrence_spawn() -> None:
future = asyncio.run_coroutine_threadsafe(_spawn_recurring(), _loop)
try:
count = future.result(timeout=300)
logger.info("Recurrence spawn: %d task(s) created", count)
except Exception as exc:
logger.error("Recurrence spawn failed: %s", exc)
_scheduler.add_job(
_run_recurrence_spawn,
CronTrigger(hour=0, minute=0, timezone="UTC"),
id="recurrence_daily",
replace_existing=True,
)
_scheduler.start()
logger.info(
"Briefing scheduler started with %d user(s) across %d job(s)",
@@ -185,6 +185,7 @@ async def run_generation(
rag_project_id=rag_project_id,
workspace_project_id=workspace_project_id,
user_timezone=user_timezone,
conv_id=conv_id,
))
messages, context_meta = await context_task
+27
View File
@@ -7,7 +7,9 @@ twice shares one file and one DB row.
"""
import hashlib
import ipaddress
import logging
import socket
from pathlib import Path
from urllib.parse import urlparse
@@ -21,6 +23,27 @@ from fabledassistant.models.image_cache import ImageCache
logger = logging.getLogger(__name__)
def _is_safe_image_url(url: str) -> bool:
"""Return True only if the URL is a public http/https address (SSRF guard)."""
try:
parsed = urlparse(url)
if parsed.scheme not in ("http", "https"):
return False
host = parsed.hostname
if not host:
return False
if host.lower() in ("localhost", "::1"):
return False
addr_info = socket.getaddrinfo(host, None, proto=socket.IPPROTO_TCP)
for entry in addr_info:
ip = ipaddress.ip_address(entry[4][0])
if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved or ip.is_multicast:
return False
return True
except Exception:
return False # Block on resolution failure
_ALLOWED_TYPES = {
"image/jpeg",
"image/png",
@@ -59,6 +82,10 @@ async def fetch_and_store_image(
Returns None if the URL is unreachable, not a valid image type, or
exceeds IMAGE_MAX_BYTES.
"""
if not _is_safe_image_url(url):
logger.warning("Blocked image fetch of private/internal URL: %s", url[:80])
return None
url_hash = _url_hash(url)
# Return existing record if already cached
+82
View File
@@ -453,6 +453,7 @@ async def build_context(
rag_project_id: int | None = None,
workspace_project_id: int | None = None,
user_timezone: str | None = None,
conv_id: int | None = None,
) -> tuple[list[dict], dict]:
"""Build messages array for Ollama with system prompt and context.
@@ -676,7 +677,88 @@ async def build_context(
f"\n\n--- Earlier Conversation ---\n{history_summary}\n--- End Earlier Conversation ---"
)
# Inject briefing article content for follow-up Q&A
if conv_id is not None:
from fabledassistant.models import async_session as _async_session
from fabledassistant.models.conversation import Conversation
async with _async_session() as _sess:
_conv = await _sess.get(Conversation, conv_id)
if _conv and getattr(_conv, "conversation_type", None) == "briefing":
article_context = await _build_briefing_article_context(conv_id)
if article_context:
system_parts.append(article_context)
messages = [{"role": "system", "content": "".join(system_parts)}]
messages.extend(history)
messages.append({"role": "user", "content": user_message})
return messages, context_meta
async def _build_briefing_article_context(conv_id: int) -> str:
"""Fetch article content from today's briefing message and return a
formatted context block for injection into the system prompt.
Looks at the most recent assistant briefing messages for rss_item_ids
in their metadata, then loads those items from the DB.
Capped at 10 articles × 500 chars to keep token use reasonable.
"""
import json as _json
from sqlalchemy import select, text as _text
from fabledassistant.models import async_session as _async_session
from fabledassistant.models.conversation import Message
async with _async_session() as session:
result = await session.execute(
select(Message)
.where(
Message.conversation_id == conv_id,
Message.role == "assistant",
)
.order_by(Message.created_at.desc())
.limit(10)
)
messages = result.scalars().all()
rss_item_ids: list[int] = []
for msg in messages:
meta = msg.msg_metadata or {}
if isinstance(meta, str):
try:
meta = _json.loads(meta)
except Exception:
continue
ids = meta.get("rss_item_ids") or []
if ids:
rss_item_ids = ids
break
if not rss_item_ids:
return ""
async with _async_session() as session:
result = await session.execute(
_text("""
SELECT i.title, i.url, i.content, f.title AS feed_title
FROM rss_items i
JOIN rss_feeds f ON f.id = i.feed_id
WHERE i.id = ANY(:ids)
ORDER BY i.published_at DESC NULLS LAST
LIMIT 10
""").bindparams(ids=rss_item_ids[:10])
)
rows = result.mappings().all()
if not rows:
return ""
lines = ["\n\nARTICLE CONTEXT (source articles from today's briefing):"]
for row in rows:
lines.append(f"\n[{row['feed_title']}] {row['title']}")
if row["url"]:
lines.append(f"URL: {row['url']}")
if row["content"]:
lines.append(row["content"][:500])
return "\n".join(lines)
+2 -1
View File
@@ -17,6 +17,7 @@ async def create_milestone(
title: str,
description: str | None = None,
order_index: int = 0,
status: str = "active",
) -> Milestone:
async with async_session() as session:
milestone = Milestone(
@@ -24,7 +25,7 @@ async def create_milestone(
project_id=project_id,
title=title,
description=description,
status="active",
status=status,
order_index=order_index,
)
session.add(milestone)
+29 -6
View File
@@ -56,6 +56,7 @@ async def create_note(
status: str | None = None,
priority: str | None = None,
due_date: date | None = None,
recurrence_rule: dict | None = None,
) -> Note:
# Auto-populate project_id from milestone when not explicitly provided
if milestone_id is not None and project_id is None:
@@ -80,6 +81,7 @@ async def create_note(
status=status,
priority=priority,
due_date=due_date,
recurrence_rule=recurrence_rule,
)
session.add(note)
await session.commit()
@@ -104,8 +106,8 @@ async def list_notes(
q: str | None = None,
tags: list[str] | None = None,
is_task: bool | None = None,
status: str | None = None,
priority: str | None = None,
status: str | list[str] | None = None,
priority: str | list[str] | None = None,
due_before: date | None = None,
due_after: date | None = None,
project_id: int | None = None,
@@ -151,12 +153,14 @@ async def list_notes(
count_query = count_query.where(tag_filter)
if status:
query = query.where(Note.status == status)
count_query = count_query.where(Note.status == status)
statuses = [status] if isinstance(status, str) else status
query = query.where(Note.status.in_(statuses))
count_query = count_query.where(Note.status.in_(statuses))
if priority:
query = query.where(Note.priority == priority)
count_query = count_query.where(Note.priority == priority)
priorities = [priority] if isinstance(priority, str) else priority
query = query.where(Note.priority.in_(priorities))
count_query = count_query.where(Note.priority.in_(priorities))
if due_before is not None:
query = query.where(Note.due_date < due_before)
@@ -240,6 +244,25 @@ async def update_note(user_id: int, note_id: int, **fields: object) -> Note | No
elif key == "tags" and isinstance(value, list):
value = _normalize_tags(value)
setattr(note, key, value)
# Auto-set lifecycle timestamps on status transitions
if "status" in fields:
_now = datetime.now(timezone.utc)
if note.status == TaskStatus.in_progress.value:
if note.started_at is None:
note.started_at = _now
elif note.status in (TaskStatus.done.value, TaskStatus.cancelled.value):
note.completed_at = _now
if note.recurrence_rule:
from fabledassistant.services.recurrence import calculate_next_due
base = note.due_date or _now.date()
next_due = calculate_next_due(note.recurrence_rule, base)
note.recurrence_next_spawn_at = datetime(
next_due.year, next_due.month, next_due.day, tzinfo=timezone.utc
)
elif note.status == TaskStatus.todo.value:
note.started_at = None
note.completed_at = None
note.recurrence_next_spawn_at = None
note.updated_at = datetime.now(timezone.utc)
await session.commit()
await session.refresh(note)
+2 -1
View File
@@ -17,6 +17,7 @@ async def create_project(
description: str = "",
goal: str = "",
color: str | None = None,
status: str = "active",
) -> Project:
async with async_session() as session:
project = Project(
@@ -25,7 +26,7 @@ async def create_project(
description=description,
goal=goal,
color=color,
status="active",
status=status,
)
session.add(project)
await session.commit()
+155
View File
@@ -0,0 +1,155 @@
"""Recurring task rule validation, date calculation, and scheduled spawning."""
import asyncio
import calendar
import logging
from datetime import date, datetime, timedelta, timezone
from sqlalchemy import and_, select
from fabledassistant.models import async_session
from fabledassistant.models.note import Note, TaskStatus
logger = logging.getLogger(__name__)
_VALID_UNITS = {"day", "week", "month", "year"}
def validate_recurrence_rule(rule: dict) -> None:
"""Raise ValueError if rule is structurally invalid."""
if not isinstance(rule, dict):
raise ValueError("recurrence_rule must be an object")
rule_type = rule.get("type")
if rule_type not in ("interval", "calendar"):
raise ValueError("recurrence_rule.type must be 'interval' or 'calendar'")
unit = rule.get("unit")
if unit not in _VALID_UNITS:
raise ValueError(f"recurrence_rule.unit must be one of {_VALID_UNITS}")
if rule_type == "interval":
every = rule.get("every")
if not isinstance(every, int) or every < 1:
raise ValueError("recurrence_rule.every must be a positive integer")
elif rule_type == "calendar":
day = rule.get("day_of_month")
if not isinstance(day, int) or not 1 <= day <= 31:
raise ValueError("recurrence_rule.day_of_month must be an integer between 1 and 31")
if unit == "year":
month = rule.get("month")
if not isinstance(month, int) or not 1 <= month <= 12:
raise ValueError("recurrence_rule.month must be an integer between 1 and 12")
elif unit != "month":
raise ValueError("calendar recurrence only supports unit 'month' or 'year'")
def _add_months(d: date, months: int) -> date:
month = d.month - 1 + months
year = d.year + month // 12
month = month % 12 + 1
day = min(d.day, calendar.monthrange(year, month)[1])
return d.replace(year=year, month=month, day=day)
def _next_day_of_month(from_date: date, day: int) -> date:
"""Return the next occurrence of day-of-month strictly after from_date."""
year, month = from_date.year, from_date.month
max_day = calendar.monthrange(year, month)[1]
clamped = min(day, max_day)
if clamped > from_date.day:
return from_date.replace(day=clamped)
# Advance one month
if month == 12:
year, month = year + 1, 1
else:
month += 1
return date(year, month, min(day, calendar.monthrange(year, month)[1]))
def _next_annual_date(from_date: date, month: int, day: int) -> date:
"""Return the next occurrence of month/day after from_date."""
max_day = calendar.monthrange(from_date.year, month)[1]
candidate = date(from_date.year, month, min(day, max_day))
if candidate > from_date:
return candidate
return date(
from_date.year + 1,
month,
min(day, calendar.monthrange(from_date.year + 1, month)[1]),
)
def calculate_next_due(rule: dict, from_date: date) -> date:
"""Return the next due date after from_date according to rule."""
rule_type = rule["type"]
unit = rule["unit"]
if rule_type == "interval":
every = rule["every"]
if unit == "day":
return from_date + timedelta(days=every)
if unit == "week":
return from_date + timedelta(weeks=every)
if unit == "month":
return _add_months(from_date, every)
if unit == "year":
return _add_months(from_date, every * 12)
elif rule_type == "calendar":
if unit == "month":
return _next_day_of_month(from_date, rule["day_of_month"])
if unit == "year":
return _next_annual_date(from_date, rule["month"], rule["day_of_month"])
raise ValueError(f"Unhandled recurrence rule: {rule!r}")
async def spawn_recurring_tasks() -> int:
"""Create child tasks for all recurring tasks whose spawn date has arrived.
Returns the number of tasks spawned.
"""
from fabledassistant.services.embeddings import upsert_note_embedding
from fabledassistant.services.notes import create_note
now = datetime.now(timezone.utc)
spawned = 0
async with async_session() as session:
result = await session.execute(
select(Note).where(
and_(
Note.recurrence_rule.isnot(None),
Note.recurrence_next_spawn_at <= now,
)
)
)
tasks = result.scalars().all()
for task in tasks:
base_date = task.due_date or now.date()
next_due = calculate_next_due(task.recurrence_rule, base_date)
try:
child = await create_note(
task.user_id,
title=task.title,
body=task.body or "",
status=TaskStatus.todo.value,
priority=task.priority or "none",
due_date=next_due,
tags=list(task.tags or []),
project_id=task.project_id,
milestone_id=task.milestone_id,
recurrence_rule=task.recurrence_rule,
)
text = f"{child.title}\n{child.body}".strip() if child.body else (child.title or "")
if text:
asyncio.create_task(upsert_note_embedding(child.id, task.user_id, text))
except Exception:
logger.exception("Failed to spawn recurring task %d", task.id)
continue
async with async_session() as session:
parent = await session.get(Note, task.id)
if parent:
parent.recurrence_next_spawn_at = None
await session.commit()
spawned += 1
logger.info("Spawned recurring task %d → child due %s", task.id, next_due)
return spawned
+6 -2
View File
@@ -14,7 +14,7 @@ logger = logging.getLogger(__name__)
CONTENT_MAX_CHARS = 2000
# Keep only items from the last N days to avoid unbounded growth
ITEM_MAX_AGE_DAYS = 14
ITEM_MAX_AGE_DAYS = 90
def extract_item(entry) -> dict:
@@ -55,6 +55,10 @@ async def fetch_and_cache_feed(feed_id: int, url: str) -> int:
Fetch a feed URL, parse it, and upsert new items into rss_items.
Returns the number of new items stored.
"""
scheme = url.split("://")[0].lower() if "://" in url else ""
if scheme not in ("http", "https"):
logger.warning("Blocked RSS fetch with non-http(s) scheme: %s", url[:80])
return 0
try:
parsed = await _parse_feed(url)
except Exception:
@@ -135,7 +139,7 @@ async def _prune_old_items(feed_id: int) -> None:
text("""
DELETE FROM rss_items
WHERE feed_id = :feed_id
AND published_at < NOW() - INTERVAL '14 days'
AND published_at < NOW() - INTERVAL '90 days'
""").bindparams(feed_id=feed_id)
)
await session.commit()
+81 -5
View File
@@ -105,6 +105,19 @@ _CORE_TOOLS = [
"type": "boolean",
"description": "Set to true only when the user has explicitly confirmed creation after being warned about a similar existing task.",
},
"recurrence_rule": {
"type": "object",
"description": (
"Optional recurrence schedule so the task repeats automatically. Two forms:\n"
" Interval — repeat every N units from the due date:\n"
' {"type": "interval", "every": 3, "unit": "month"}\n'
" unit options: day | week | month | year\n"
" Calendar/monthly — specific day each month:\n"
' {"type": "calendar", "unit": "month", "day_of_month": 1}\n'
" Calendar/annual — specific day each year:\n"
' {"type": "calendar", "unit": "year", "day_of_month": 15, "month": 6}'
),
},
},
"required": ["title"],
},
@@ -179,8 +192,8 @@ _CORE_TOOLS = [
},
"status": {
"type": "string",
"enum": ["todo", "in_progress", "done"],
"description": "New task status. Use to mark a task done, start it, etc.",
"enum": ["todo", "in_progress", "done", "cancelled"],
"description": "New task status. Use to mark a task done, start it, cancel it, etc.",
},
"priority": {
"type": "string",
@@ -205,6 +218,13 @@ _CORE_TOOLS = [
"type": "string",
"description": "Optional project name to assign this note/task to (set to empty string to remove project)",
},
"recurrence_rule": {
"type": "object",
"description": (
"Set or update the recurrence schedule. Pass null to remove recurrence. "
"Same format as create_task recurrence_rule."
),
},
},
"required": ["query"],
},
@@ -353,9 +373,16 @@ _CORE_TOOLS = [
"type": "object",
"properties": {
"status": {
"type": "string",
"enum": ["todo", "in_progress", "done"],
"description": "Filter by task status",
"type": "array",
"items": {
"type": "string",
"enum": ["todo", "in_progress", "done", "cancelled"],
},
"description": (
"Filter by one or more task statuses. "
'Example: ["todo", "in_progress"] to list all active tasks. '
"Omit to return tasks of any status."
),
},
"priority": {
"type": "string",
@@ -576,6 +603,31 @@ _CORE_TOOLS = [
},
},
},
{
"type": "function",
"function": {
"name": "add_rss_feed",
"description": (
"Add a new RSS feed to the user's briefing. Use this when the user asks to "
"subscribe to, follow, add, or track a feed, blog, news source, subreddit, or podcast URL. "
"The feed will be fetched immediately after adding."
),
"parameters": {
"type": "object",
"properties": {
"url": {
"type": "string",
"description": "The RSS/Atom feed URL to add.",
},
"category": {
"type": "string",
"description": "Optional category label (e.g. 'news', 'tech', 'reddit'). Omit if unsure.",
},
},
"required": ["url"],
},
},
},
{
"type": "function",
"function": {
@@ -1780,6 +1832,30 @@ async def execute_tool(
items = await get_recent_items(user_id, limit=limit)
return {"data": {"items": items, "count": len(items)}}
elif tool_name == "add_rss_feed":
import asyncio as _asyncio
from fabledassistant.models import async_session as _async_session
from fabledassistant.models.rss_feed import RssFeed
from fabledassistant.services.rss import fetch_and_cache_feed
from sqlalchemy import select as _select
url = str(arguments.get("url", "")).strip()
if not url:
return {"error": "url is required"}
category = arguments.get("category") or None
async with _async_session() as session:
existing = await session.execute(
_select(RssFeed).where(RssFeed.user_id == user_id, RssFeed.url == url)
)
if existing.scalars().first():
return {"error": "Feed already added", "url": url}
feed = RssFeed(user_id=user_id, url=url, title="", category=category)
session.add(feed)
await session.commit()
await session.refresh(feed)
feed_id = feed.id
_asyncio.create_task(fetch_and_cache_feed(feed_id, url))
return {"data": {"id": feed_id, "url": url, "message": "Feed added and fetching started."}}
elif tool_name == "search_projects":
from difflib import SequenceMatcher
query = str(arguments.get("query", "")).lower()
+108
View File
@@ -0,0 +1,108 @@
"""Tests for news API — retention constant and endpoint formatting."""
import pytest
from unittest.mock import AsyncMock, MagicMock
from datetime import datetime, timezone
def test_rss_item_max_age_is_90_days():
"""Retention window should be 90 days."""
from fabledassistant.services.rss import ITEM_MAX_AGE_DAYS
assert ITEM_MAX_AGE_DAYS == 90
def _make_mock_session(rows):
"""Return a mock async context manager whose session.execute returns rows."""
mock_result = MagicMock()
mock_result.mappings.return_value.all.return_value = rows
mock_session = AsyncMock()
mock_session.execute = AsyncMock(return_value=mock_result)
mock_cm = MagicMock()
mock_cm.__aenter__ = AsyncMock(return_value=mock_session)
mock_cm.__aexit__ = AsyncMock(return_value=False)
return mock_cm
def test_list_news_item_serialisation():
"""News item serialisation should truncate content to 300 chars and include reaction."""
pub = datetime(2026, 3, 28, 8, 0, tzinfo=timezone.utc)
item = {
"id": 1,
"title": "EU AI Act deadline",
"url": "https://example.com/ai",
"content": "x" * 500,
"published_at": pub,
"topics": ["tech"],
"feed_title": "TechCrunch",
"reaction": "up",
}
result = {
"id": item["id"],
"title": item["title"],
"url": item["url"],
"snippet": (item["content"] or "")[:300],
"published_at": item["published_at"].isoformat() if item["published_at"] else None,
"topics": item["topics"] or [],
"source": item["feed_title"],
"reaction": item["reaction"],
}
assert result["snippet"] == "x" * 300
assert result["source"] == "TechCrunch"
assert result["reaction"] == "up"
assert result["published_at"] == pub.isoformat()
def test_build_briefing_article_context_metadata_extraction():
"""Verify the rss_item_ids extraction logic from message metadata."""
import json
# Simulates the logic in _build_briefing_article_context
def extract_ids(msg_metadata):
meta = msg_metadata or {}
if isinstance(meta, str):
try:
meta = json.loads(meta)
except Exception:
return []
return meta.get("rss_item_ids") or []
assert extract_ids({}) == []
assert extract_ids(None) == []
assert extract_ids({"rss_item_ids": [1, 2, 3]}) == [1, 2, 3]
assert extract_ids(json.dumps({"rss_item_ids": [4, 5]})) == [4, 5]
assert extract_ids("not json") == []
def test_list_news_null_content_serialisation():
"""Null content should produce empty snippet without error."""
item = {
"id": 2,
"title": "No content article",
"url": "https://example.com/b",
"content": None,
"published_at": None,
"topics": None,
"feed_title": "Hacker News",
"reaction": None,
}
result = {
"id": item["id"],
"title": item["title"],
"url": item["url"],
"snippet": (item["content"] or "")[:300],
"published_at": item["published_at"].isoformat() if item["published_at"] else None,
"topics": item["topics"] or [],
"source": item["feed_title"],
"reaction": item["reaction"],
}
assert result["snippet"] == ""
assert result["topics"] == []
assert result["published_at"] is None
assert result["reaction"] is None
+292
View File
@@ -0,0 +1,292 @@
"""Tests for task lifecycle timestamps and recurrence logic."""
from datetime import date, datetime, timezone
from unittest.mock import AsyncMock, MagicMock, patch
# ── Timestamp side-effect tests ──────────────────────────────────────────────
async def test_update_note_sets_started_at_on_in_progress():
"""started_at is set when status transitions to in_progress."""
mock_note = MagicMock()
mock_note.status = "in_progress"
mock_note.started_at = None
mock_note.recurrence_rule = None
mock_session = AsyncMock()
mock_result = MagicMock()
mock_result.scalars.return_value.first.return_value = mock_note
mock_session.execute = AsyncMock(return_value=mock_result)
mock_session.commit = AsyncMock()
mock_session.refresh = AsyncMock()
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
mock_session.__aexit__ = AsyncMock(return_value=False)
with patch("fabledassistant.services.notes.async_session", return_value=mock_session):
from fabledassistant.services.notes import update_note
await update_note(1, 1, status="in_progress")
assert mock_note.started_at is not None
async def test_update_note_sets_completed_at_on_done():
"""completed_at is set when status transitions to done."""
mock_note = MagicMock()
mock_note.status = "done"
mock_note.started_at = datetime(2026, 3, 1, tzinfo=timezone.utc)
mock_note.completed_at = None
mock_note.recurrence_rule = None
mock_session = AsyncMock()
mock_result = MagicMock()
mock_result.scalars.return_value.first.return_value = mock_note
mock_session.execute = AsyncMock(return_value=mock_result)
mock_session.commit = AsyncMock()
mock_session.refresh = AsyncMock()
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
mock_session.__aexit__ = AsyncMock(return_value=False)
with patch("fabledassistant.services.notes.async_session", return_value=mock_session):
from fabledassistant.services.notes import update_note
await update_note(1, 1, status="done")
assert mock_note.completed_at is not None
async def test_update_note_clears_timestamps_on_todo():
"""started_at and completed_at are cleared when status resets to todo."""
mock_note = MagicMock()
mock_note.status = "todo"
mock_note.started_at = datetime(2026, 3, 1, tzinfo=timezone.utc)
mock_note.completed_at = datetime(2026, 3, 15, tzinfo=timezone.utc)
mock_note.recurrence_rule = None
mock_session = AsyncMock()
mock_result = MagicMock()
mock_result.scalars.return_value.first.return_value = mock_note
mock_session.execute = AsyncMock(return_value=mock_result)
mock_session.commit = AsyncMock()
mock_session.refresh = AsyncMock()
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
mock_session.__aexit__ = AsyncMock(return_value=False)
with patch("fabledassistant.services.notes.async_session", return_value=mock_session):
from fabledassistant.services.notes import update_note
await update_note(1, 1, status="todo")
assert mock_note.started_at is None
assert mock_note.completed_at is None
async def test_update_note_preserves_started_at_if_already_set():
"""started_at is not overwritten on a second transition to in_progress."""
original_start = datetime(2026, 3, 1, tzinfo=timezone.utc)
mock_note = MagicMock()
mock_note.status = "in_progress"
mock_note.started_at = original_start
mock_note.recurrence_rule = None
mock_session = AsyncMock()
mock_result = MagicMock()
mock_result.scalars.return_value.first.return_value = mock_note
mock_session.execute = AsyncMock(return_value=mock_result)
mock_session.commit = AsyncMock()
mock_session.refresh = AsyncMock()
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
mock_session.__aexit__ = AsyncMock(return_value=False)
with patch("fabledassistant.services.notes.async_session", return_value=mock_session):
from fabledassistant.services.notes import update_note
await update_note(1, 1, status="in_progress")
assert mock_note.started_at == original_start
# ── Recurrence rule validation ────────────────────────────────────────────────
import pytest
def test_validate_interval_rule_valid():
from fabledassistant.services.recurrence import validate_recurrence_rule
validate_recurrence_rule({"type": "interval", "every": 3, "unit": "month"}) # no error
def test_validate_interval_rule_bad_unit():
from fabledassistant.services.recurrence import validate_recurrence_rule
with pytest.raises(ValueError, match="unit"):
validate_recurrence_rule({"type": "interval", "every": 3, "unit": "fortnight"})
def test_validate_interval_rule_bad_every():
from fabledassistant.services.recurrence import validate_recurrence_rule
with pytest.raises(ValueError, match="every"):
validate_recurrence_rule({"type": "interval", "every": 0, "unit": "week"})
def test_validate_calendar_monthly_valid():
from fabledassistant.services.recurrence import validate_recurrence_rule
validate_recurrence_rule({"type": "calendar", "unit": "month", "day_of_month": 1})
def test_validate_calendar_annual_valid():
from fabledassistant.services.recurrence import validate_recurrence_rule
validate_recurrence_rule(
{"type": "calendar", "unit": "year", "day_of_month": 15, "month": 6}
)
def test_validate_calendar_bad_day():
from fabledassistant.services.recurrence import validate_recurrence_rule
with pytest.raises(ValueError, match="day_of_month"):
validate_recurrence_rule({"type": "calendar", "unit": "month", "day_of_month": 32})
def test_validate_calendar_annual_missing_month():
from fabledassistant.services.recurrence import validate_recurrence_rule
with pytest.raises(ValueError, match="month"):
validate_recurrence_rule(
{"type": "calendar", "unit": "year", "day_of_month": 15}
)
def test_validate_unknown_type():
from fabledassistant.services.recurrence import validate_recurrence_rule
with pytest.raises(ValueError, match="type"):
validate_recurrence_rule({"type": "cron", "every": 1, "unit": "day"})
# ── calculate_next_due ────────────────────────────────────────────────────────
def test_calculate_next_due_interval_days():
from fabledassistant.services.recurrence import calculate_next_due
rule = {"type": "interval", "every": 7, "unit": "day"}
assert calculate_next_due(rule, date(2026, 3, 27)) == date(2026, 4, 3)
def test_calculate_next_due_interval_weeks():
from fabledassistant.services.recurrence import calculate_next_due
rule = {"type": "interval", "every": 2, "unit": "week"}
assert calculate_next_due(rule, date(2026, 3, 27)) == date(2026, 4, 10)
def test_calculate_next_due_interval_months():
from fabledassistant.services.recurrence import calculate_next_due
rule = {"type": "interval", "every": 3, "unit": "month"}
assert calculate_next_due(rule, date(2026, 3, 1)) == date(2026, 6, 1)
def test_calculate_next_due_interval_months_end_of_month():
"""Month addition clamps to last day when target month is shorter."""
from fabledassistant.services.recurrence import calculate_next_due
rule = {"type": "interval", "every": 1, "unit": "month"}
assert calculate_next_due(rule, date(2026, 1, 31)) == date(2026, 2, 28)
def test_calculate_next_due_interval_years():
from fabledassistant.services.recurrence import calculate_next_due
rule = {"type": "interval", "every": 1, "unit": "year"}
assert calculate_next_due(rule, date(2026, 3, 27)) == date(2027, 3, 27)
def test_calculate_next_due_calendar_monthly_future_day():
"""If day_of_month is later this month, return that date."""
from fabledassistant.services.recurrence import calculate_next_due
rule = {"type": "calendar", "unit": "month", "day_of_month": 28}
assert calculate_next_due(rule, date(2026, 3, 1)) == date(2026, 3, 28)
def test_calculate_next_due_calendar_monthly_same_or_past_day():
"""If day_of_month is today or earlier, advance to next month."""
from fabledassistant.services.recurrence import calculate_next_due
rule = {"type": "calendar", "unit": "month", "day_of_month": 1}
assert calculate_next_due(rule, date(2026, 3, 1)) == date(2026, 4, 1)
def test_calculate_next_due_calendar_annual_future():
"""Annual date later this year is returned."""
from fabledassistant.services.recurrence import calculate_next_due
rule = {"type": "calendar", "unit": "year", "day_of_month": 15, "month": 6}
assert calculate_next_due(rule, date(2026, 3, 27)) == date(2026, 6, 15)
def test_calculate_next_due_calendar_annual_past():
"""Annual date already passed this year → next year."""
from fabledassistant.services.recurrence import calculate_next_due
rule = {"type": "calendar", "unit": "year", "day_of_month": 15, "month": 1}
assert calculate_next_due(rule, date(2026, 3, 27)) == date(2027, 1, 15)
# ── spawn_recurring_tasks ─────────────────────────────────────────────────────
async def test_spawn_recurring_tasks_creates_child():
from unittest.mock import AsyncMock, MagicMock, patch
mock_task = MagicMock()
mock_task.id = 1
mock_task.user_id = 42
mock_task.title = "Change air filter"
mock_task.body = ""
mock_task.priority = "medium"
mock_task.tags = []
mock_task.project_id = None
mock_task.milestone_id = None
mock_task.due_date = date(2026, 3, 1)
mock_task.recurrence_rule = {"type": "interval", "every": 3, "unit": "month"}
mock_session = AsyncMock()
mock_result = MagicMock()
mock_result.scalars.return_value.all.return_value = [mock_task]
mock_session.execute = AsyncMock(return_value=mock_result)
mock_session.get = AsyncMock(return_value=mock_task)
mock_session.commit = AsyncMock()
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
mock_session.__aexit__ = AsyncMock(return_value=False)
mock_child = MagicMock()
mock_child.id = 2
mock_child.title = "Change air filter"
mock_child.body = ""
with (
patch("fabledassistant.services.recurrence.async_session", return_value=mock_session),
patch(
"fabledassistant.services.notes.create_note",
new_callable=AsyncMock,
return_value=mock_child,
) as mock_create,
patch("fabledassistant.services.embeddings.upsert_note_embedding"),
):
from fabledassistant.services.recurrence import spawn_recurring_tasks
count = await spawn_recurring_tasks()
assert count == 1
mock_create.assert_called_once()
kwargs = mock_create.call_args[1]
assert kwargs["title"] == "Change air filter"
assert kwargs["due_date"] == date(2026, 6, 1)
assert kwargs["recurrence_rule"] == {"type": "interval", "every": 3, "unit": "month"}
assert kwargs["status"] == "todo"
# ── Multi-status filtering ────────────────────────────────────────────────────
async def test_list_notes_multi_status_builds_in_clause():
"""list_notes with a list of statuses executes without error."""
from unittest.mock import AsyncMock, MagicMock, patch
mock_session = AsyncMock()
mock_result = MagicMock()
mock_result.scalars.return_value.all.return_value = []
mock_session.execute = AsyncMock(return_value=mock_result)
mock_session.scalar = AsyncMock(return_value=0)
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
mock_session.__aexit__ = AsyncMock(return_value=False)
with patch("fabledassistant.services.notes.async_session", return_value=mock_session):
from fabledassistant.services.notes import list_notes
notes, total = await list_notes(1, status=["todo", "in_progress"], is_task=True)
assert total == 0
assert notes == []
mock_session.scalar.assert_called_once()
mock_session.execute.assert_called_once()