DRY refactoring pass: shared mixins, route helpers, composables, CSS

Backend:
- models/base.py: TimestampMixin + CreatedAtMixin; applied to all 10+ models
- routes/utils.py: not_found() + parse_iso_date() helpers; used across all route files
- routes/milestones.py: _milestone_dict() helper replaces 5 repeated to_dict + progress blocks

Frontend:
- 5 new composables: useAutoSave, useEditorGuards, useTagSuggestions,
  useFloatingAssist, useListKeyboardNavigation
- ConfirmDialog.vue: reusable confirm modal replacing inline <teleport> blocks
- editor-shared.css: sidebar CSS consolidated from both editor views
- viewer-shared.css: context-bar/breadcrumb CSS consolidated from both viewer views
- NoteEditorView + TaskEditorView: ~120 lines each replaced with composable calls;
  duplicate scoped sidebar CSS removed
- NotesListView + TasksListView: inline keyboard-nav replaced with composable
- NoteViewerView + TaskViewerView: duplicate context-bar CSS removed

No behaviour changes. Net: -634 lines, +237 lines across 31 files.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-06 15:26:34 -05:00
parent 48f070f773
commit 16ecd6bbeb
32 changed files with 563 additions and 634 deletions
+9 -8
View File
@@ -6,6 +6,7 @@ import httpx
from quart import Blueprint, Response, jsonify, request
from fabledassistant.auth import login_required, get_current_user_id
from fabledassistant.routes.utils import not_found
from fabledassistant.config import Config
from fabledassistant.services.chat import (
add_message,
@@ -61,7 +62,7 @@ async def get_conversation_route(conv_id: int):
uid = get_current_user_id()
conv = await get_conversation(uid, conv_id)
if conv is None:
return jsonify({"error": "Conversation not found"}), 404
return not_found("Conversation")
result = conv.to_dict()
note_ids = [m.context_note_id for m in conv.messages if m.context_note_id]
note_map = await get_notes_by_ids(uid, note_ids) if note_ids else {}
@@ -80,7 +81,7 @@ async def delete_conversation_route(conv_id: int):
uid = get_current_user_id()
deleted = await delete_conversation(uid, conv_id)
if not deleted:
return jsonify({"error": "Conversation not found"}), 404
return not_found("Conversation")
return "", 204
@@ -95,7 +96,7 @@ async def update_conversation_route(conv_id: int):
return jsonify({"error": "title or model is required"}), 400
conv = await update_conversation(uid, conv_id, title=title, model=model)
if conv is None:
return jsonify({"error": "Conversation not found"}), 404
return not_found("Conversation")
return jsonify(conv.to_dict())
@@ -106,7 +107,7 @@ async def send_message_route(conv_id: int):
uid = get_current_user_id()
conv = await get_conversation(uid, conv_id)
if conv is None:
return jsonify({"error": "Conversation not found"}), 404
return not_found("Conversation")
data = await request.get_json()
content = data.get("content", "").strip()
@@ -166,7 +167,7 @@ async def generation_stream_route(conv_id: int):
uid = get_current_user_id()
conv = await get_conversation(uid, conv_id)
if conv is None:
return jsonify({"error": "Conversation not found"}), 404
return not_found("Conversation")
buf = get_buffer(conv_id)
if buf is None:
@@ -219,7 +220,7 @@ async def confirm_generation_route(conv_id: int):
uid = get_current_user_id()
conv = await get_conversation(uid, conv_id)
if conv is None:
return jsonify({"error": "Conversation not found"}), 404
return not_found("Conversation")
buf = get_buffer(conv_id)
if buf is None or buf.state != GenerationState.RUNNING:
@@ -241,7 +242,7 @@ async def cancel_generation_route(conv_id: int):
uid = get_current_user_id()
conv = await get_conversation(uid, conv_id)
if conv is None:
return jsonify({"error": "Conversation not found"}), 404
return not_found("Conversation")
buf = get_buffer(conv_id)
if buf is None or buf.state != GenerationState.RUNNING:
@@ -268,7 +269,7 @@ async def summarize_conversation_route(conv_id: int):
uid = get_current_user_id()
conv = await get_conversation(uid, conv_id)
if conv is None:
return jsonify({"error": "Conversation not found"}), 404
return not_found("Conversation")
model = await get_setting(uid, "default_model", Config.OLLAMA_MODEL) or Config.OLLAMA_MODEL
try:
note = await summarize_conversation_as_note(uid, conv_id, model)
+19 -23
View File
@@ -4,6 +4,7 @@ import logging
from quart import Blueprint, jsonify, request
from fabledassistant.auth import login_required, get_current_user_id
from fabledassistant.routes.utils import not_found
from fabledassistant.services.milestones import (
create_milestone,
delete_milestone,
@@ -20,21 +21,22 @@ logger = logging.getLogger(__name__)
milestones_bp = Blueprint("milestones", __name__, url_prefix="/api/projects")
async def _milestone_dict(m) -> dict:
d = m.to_dict()
d.update(await get_milestone_progress(m.id))
return d
@milestones_bp.route("/<int:project_id>/milestones", methods=["GET"])
@login_required
async def list_milestones_route(project_id: int):
uid = get_current_user_id()
project = await get_project(uid, project_id)
if project is None:
return jsonify({"error": "Project not found"}), 404
return not_found("Project")
status = request.args.get("status")
milestones = await list_milestones(uid, project_id, status=status)
result = []
for m in milestones:
entry = m.to_dict()
progress = await get_milestone_progress(m.id)
entry.update(progress)
result.append(entry)
result = [await _milestone_dict(m) for m in milestones]
return jsonify({"milestones": result})
@@ -44,7 +46,7 @@ async def create_milestone_route(project_id: int):
uid = get_current_user_id()
project = await get_project(uid, project_id)
if project is None:
return jsonify({"error": "Project not found"}), 404
return not_found("Project")
data = await request.get_json()
if not data.get("title"):
return jsonify({"error": "title is required"}), 400
@@ -55,9 +57,7 @@ async def create_milestone_route(project_id: int):
description=data.get("description"),
order_index=data.get("order_index", 0),
)
entry = milestone.to_dict()
entry.update(await get_milestone_progress(milestone.id))
return jsonify(entry), 201
return jsonify(await _milestone_dict(milestone)), 201
@milestones_bp.route("/<int:project_id>/milestones/<int:milestone_id>", methods=["GET"])
@@ -66,10 +66,8 @@ async def get_milestone_route(project_id: int, milestone_id: int):
uid = get_current_user_id()
milestone = await get_milestone(uid, milestone_id)
if milestone is None or milestone.project_id != project_id:
return jsonify({"error": "Milestone not found"}), 404
entry = milestone.to_dict()
entry.update(await get_milestone_progress(milestone.id))
return jsonify(entry)
return not_found("Milestone")
return jsonify(await _milestone_dict(milestone))
@milestones_bp.route("/<int:project_id>/milestones/<int:milestone_id>", methods=["PATCH"])
@@ -78,16 +76,14 @@ async def update_milestone_route(project_id: int, milestone_id: int):
uid = get_current_user_id()
milestone = await get_milestone(uid, milestone_id)
if milestone is None or milestone.project_id != project_id:
return jsonify({"error": "Milestone not found"}), 404
return not_found("Milestone")
data = await request.get_json()
allowed = {"title", "description", "status", "order_index"}
fields = {k: v for k, v in data.items() if k in allowed}
updated = await update_milestone(uid, milestone_id, **fields)
if updated is None:
return jsonify({"error": "Milestone not found"}), 404
entry = updated.to_dict()
entry.update(await get_milestone_progress(updated.id))
return jsonify(entry)
return not_found("Milestone")
return jsonify(await _milestone_dict(updated))
@milestones_bp.route("/<int:project_id>/milestones/<int:milestone_id>", methods=["DELETE"])
@@ -96,10 +92,10 @@ async def delete_milestone_route(project_id: int, milestone_id: int):
uid = get_current_user_id()
milestone = await get_milestone(uid, milestone_id)
if milestone is None or milestone.project_id != project_id:
return jsonify({"error": "Milestone not found"}), 404
return not_found("Milestone")
deleted = await delete_milestone(uid, milestone_id)
if not deleted:
return jsonify({"error": "Milestone not found"}), 404
return not_found("Milestone")
return "", 204
@@ -109,7 +105,7 @@ async def get_milestone_tasks_route(project_id: int, milestone_id: int):
uid = get_current_user_id()
milestone = await get_milestone(uid, milestone_id)
if milestone is None or milestone.project_id != project_id:
return jsonify({"error": "Milestone not found"}), 404
return not_found("Milestone")
status_filter = request.args.get("status")
limit = min(request.args.get("limit", 100, type=int), 500)
offset = request.args.get("offset", 0, type=int)
+20 -23
View File
@@ -1,13 +1,13 @@
import asyncio
import logging
import re
from datetime import date
from fabledassistant.services.embeddings import upsert_note_embedding
from quart import Blueprint, Response, jsonify, request
from fabledassistant.auth import login_required, get_current_user_id
from fabledassistant.routes.utils import not_found, parse_iso_date
from fabledassistant.config import Config
from fabledassistant.services.assist import build_assist_messages
from fabledassistant.services.generation_buffer import (
@@ -87,12 +87,9 @@ async def create_note_route():
# Optional task fields
status = data.get("status")
priority = data.get("priority")
due_date = None
if data.get("due_date"):
try:
due_date = date.fromisoformat(data["due_date"])
except ValueError:
return jsonify({"error": "Invalid date format. Use YYYY-MM-DD."}), 400
due_date = parse_iso_date(data.get("due_date"), "due_date")
if isinstance(due_date, tuple):
return due_date
note = await create_note(
uid,
@@ -144,7 +141,7 @@ async def append_tag_route(note_id: int):
note = await get_note(uid, note_id)
if note is None:
return jsonify({"error": "Note not found"}), 404
return not_found("Note")
existing = list(note.tags or [])
if tag not in existing:
@@ -162,7 +159,7 @@ async def get_note_by_title_route():
return jsonify({"error": "title parameter is required"}), 400
note = await get_note_by_title(uid, title)
if note is None:
return jsonify({"error": "Note not found"}), 404
return not_found("Note")
return jsonify(note.to_dict())
@@ -184,7 +181,7 @@ async def get_note_route(note_id: int):
uid = get_current_user_id()
note = await get_note(uid, note_id)
if note is None:
return jsonify({"error": "Note not found"}), 404
return not_found("Note")
return jsonify(note.to_dict())
@@ -200,10 +197,10 @@ async def update_note_route(note_id: int):
if "due_date" in data:
if data["due_date"]:
try:
fields["due_date"] = date.fromisoformat(data["due_date"])
except ValueError:
return jsonify({"error": "Invalid date format. Use YYYY-MM-DD."}), 400
result = parse_iso_date(data["due_date"], "due_date")
if isinstance(result, tuple):
return result
fields["due_date"] = result
else:
fields["due_date"] = None
@@ -211,7 +208,7 @@ async def update_note_route(note_id: int):
fields["tags"] = data["tags"]
note = await update_note(uid, note_id, **fields)
if note is None:
return jsonify({"error": "Note not found"}), 404
return not_found("Note")
text = f"{note.title}\n{note.body}".strip() if note.body else (note.title or "")
if text:
asyncio.create_task(upsert_note_embedding(note.id, uid, text))
@@ -228,15 +225,15 @@ async def patch_note_route(note_id: int):
if key in data:
fields[key] = data[key]
if "due_date" in data:
try:
fields["due_date"] = date.fromisoformat(data["due_date"]) if data["due_date"] else None
except (ValueError, TypeError):
return jsonify({"error": "Invalid due_date format, expected YYYY-MM-DD"}), 400
result = parse_iso_date(data.get("due_date"), "due_date")
if isinstance(result, tuple):
return result
fields["due_date"] = result
if "tags" in data:
fields["tags"] = data["tags"]
note = await update_note(uid, note_id, **fields)
if note is None:
return jsonify({"error": "Note not found"}), 404
return not_found("Note")
return jsonify(note.to_dict())
@@ -246,7 +243,7 @@ async def delete_note_route(note_id: int):
uid = get_current_user_id()
deleted = await delete_note(uid, note_id)
if not deleted:
return jsonify({"error": "Note not found"}), 404
return not_found("Note")
return "", 204
@@ -474,7 +471,7 @@ async def upsert_draft_route(note_id: int):
# Verify note ownership
note = await get_note(uid, note_id)
if note is None:
return jsonify({"error": "Note not found"}), 404
return not_found("Note")
data = await request.get_json()
draft = await upsert_draft(
user_id=uid,
@@ -511,5 +508,5 @@ async def get_version_route(note_id: int, version_id: int):
uid = get_current_user_id()
version = await get_version(uid, note_id, version_id)
if version is None:
return jsonify({"error": "Version not found"}), 404
return not_found("Version")
return jsonify(version.to_dict(include_body=True))
+5 -4
View File
@@ -4,6 +4,7 @@ import logging
from quart import Blueprint, jsonify, request
from fabledassistant.auth import login_required, get_current_user_id
from fabledassistant.routes.utils import not_found
from fabledassistant.services.milestones import list_milestones
from fabledassistant.services.notes import list_notes
from fabledassistant.services.projects import (
@@ -52,7 +53,7 @@ async def get_project_route(project_id: int):
uid = get_current_user_id()
project = await get_project(uid, project_id)
if project is None:
return jsonify({"error": "Project not found"}), 404
return not_found("Project")
summary = await get_project_summary(uid, project_id)
data = project.to_dict()
data["summary"] = summary
@@ -68,7 +69,7 @@ async def update_project_route(project_id: int):
fields = {k: v for k, v in data.items() if k in allowed}
project = await update_project(uid, project_id, **fields)
if project is None:
return jsonify({"error": "Project not found"}), 404
return not_found("Project")
return jsonify(project.to_dict())
@@ -78,7 +79,7 @@ async def delete_project_route(project_id: int):
uid = get_current_user_id()
deleted = await delete_project(uid, project_id)
if not deleted:
return jsonify({"error": "Project not found"}), 404
return not_found("Project")
return "", 204
@@ -88,7 +89,7 @@ async def get_project_notes_route(project_id: int):
uid = get_current_user_id()
project = await get_project(uid, project_id)
if project is None:
return jsonify({"error": "Project not found"}), 404
return not_found("Project")
# type filter: "note", "task", or None (both)
type_filter = request.args.get("type")
+18 -23
View File
@@ -1,9 +1,8 @@
from datetime import date
from quart import Blueprint, jsonify, request
from fabledassistant.auth import login_required, get_current_user_id
from fabledassistant.models.note import TaskPriority, TaskStatus
from fabledassistant.routes.utils import not_found, parse_iso_date
from fabledassistant.services.notes import (
create_note,
delete_note,
@@ -28,13 +27,12 @@ async def list_tasks_route():
limit = min(request.args.get("limit", 50, type=int), 500)
offset = request.args.get("offset", 0, type=int)
due_before_str = request.args.get("due_before")
due_after_str = request.args.get("due_after")
try:
due_before = date.fromisoformat(due_before_str) if due_before_str else None
due_after = date.fromisoformat(due_after_str) if due_after_str else None
except ValueError:
return jsonify({"error": "Invalid date format. Use YYYY-MM-DD."}), 400
due_before = parse_iso_date(request.args.get("due_before"), "due_before")
if isinstance(due_before, tuple):
return due_before
due_after = parse_iso_date(request.args.get("due_after"), "due_after")
if isinstance(due_after, tuple):
return due_after
tasks, total = await list_notes(
uid,
@@ -61,12 +59,9 @@ async def create_task_route():
body = data.get("body", "") or data.get("description", "")
tags = data.get("tags", [])
due_date = None
if data.get("due_date"):
try:
due_date = date.fromisoformat(data["due_date"])
except ValueError:
return jsonify({"error": "Invalid date format. Use YYYY-MM-DD."}), 400
due_date = parse_iso_date(data.get("due_date"), "due_date")
if isinstance(due_date, tuple):
return due_date
status = TaskStatus(data["status"]).value if "status" in data else TaskStatus.todo.value
priority = (
@@ -94,7 +89,7 @@ async def get_task_route(task_id: int):
uid = get_current_user_id()
task = await get_note(uid, task_id)
if task is None:
return jsonify({"error": "Task not found"}), 404
return not_found("Task")
data = task.to_dict()
if task.parent_id:
parent = await get_note(uid, task.parent_id)
@@ -120,10 +115,10 @@ async def update_task_route(task_id: int):
if "due_date" in data:
if data["due_date"]:
try:
fields["due_date"] = date.fromisoformat(data["due_date"])
except ValueError:
return jsonify({"error": "Invalid date format. Use YYYY-MM-DD."}), 400
result = parse_iso_date(data["due_date"], "due_date")
if isinstance(result, tuple):
return result
fields["due_date"] = result
else:
fields["due_date"] = None
@@ -136,7 +131,7 @@ async def update_task_route(task_id: int):
task = await update_note(uid, task_id, **fields)
if task is None:
return jsonify({"error": "Task not found"}), 404
return not_found("Task")
return jsonify(task.to_dict())
@@ -156,7 +151,7 @@ async def patch_task_status(task_id: int):
task = await update_note(uid, task_id, status=status_val)
if task is None:
return jsonify({"error": "Task not found"}), 404
return not_found("Task")
return jsonify(task.to_dict())
@@ -166,5 +161,5 @@ async def delete_task_route(task_id: int):
uid = get_current_user_id()
deleted = await delete_note(uid, task_id)
if not deleted:
return jsonify({"error": "Task not found"}), 404
return not_found("Task")
return "", 204
+17
View File
@@ -0,0 +1,17 @@
from datetime import date
from quart import jsonify
def not_found(resource: str = "Item"):
return jsonify({"error": f"{resource} not found"}), 404
def parse_iso_date(value: str | None, field: str = "date"):
"""Parse an ISO date string. Returns a date, None, or a (response, 400) tuple."""
if not value:
return None
try:
return date.fromisoformat(value)
except ValueError:
return jsonify({"error": f"Invalid {field} format. Use YYYY-MM-DD."}), 400