diff --git a/routers/capture.py b/routers/capture.py index d9080ae..98733ee 100644 --- a/routers/capture.py +++ b/routers/capture.py @@ -1,11 +1,14 @@ -"""Capture: quick text capture queue with conversion.""" +"""Capture: quick text capture queue with conversion to any entity type.""" + +import re +from uuid import uuid4 +from typing import Optional from fastapi import APIRouter, Request, Form, Depends from fastapi.templating import Jinja2Templates from fastapi.responses import RedirectResponse from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import text -from typing import Optional from core.database import get_db from core.base_repository import BaseRepository @@ -14,25 +17,58 @@ from core.sidebar import get_sidebar_data router = APIRouter(prefix="/capture", tags=["capture"]) templates = Jinja2Templates(directory="templates") +CONVERT_TYPES = { + "task": "Task", + "note": "Note", + "project": "Project", + "list_item": "List Item", + "contact": "Contact", + "decision": "Decision", + "weblink": "Weblink", +} + @router.get("/") -async def list_capture(request: Request, show: str = "unprocessed", db: AsyncSession = Depends(get_db)): +async def list_capture(request: Request, show: str = "inbox", db: AsyncSession = Depends(get_db)): sidebar = await get_sidebar_data(db) - if show == "all": - filters = {} - else: - filters = {"processed": False} - result = await db.execute(text(""" - SELECT * FROM capture WHERE is_deleted = false - AND (:show_all OR processed = false) - ORDER BY created_at DESC - """), {"show_all": show == "all"}) + if show == "processed": + where = "is_deleted = false AND processed = true" + elif show == "all": + where = "is_deleted = false" + else: # inbox + where = "is_deleted = false AND processed = false" + + result = await db.execute(text(f""" + SELECT * FROM capture WHERE {where} ORDER BY created_at DESC + """)) items = [dict(r._mapping) for r in result] + # Mark first item per batch for batch-undo display + batches = {} + for item in items: + bid = item.get("import_batch_id") + if bid: + bid_str = str(bid) + if bid_str not in batches: + batches[bid_str] = 0 + item["_batch_first"] = True + else: + item["_batch_first"] = False + batches[bid_str] += 1 + else: + item["_batch_first"] = False + + # Get lists for list_item conversion + result = await db.execute(text( + "SELECT id, name FROM lists WHERE is_deleted = false ORDER BY name" + )) + all_lists = [dict(r._mapping) for r in result] + return templates.TemplateResponse("capture.html", { "request": request, "sidebar": sidebar, "items": items, - "show": show, + "show": show, "batches": batches, "all_lists": all_lists, + "convert_types": CONVERT_TYPES, "page_title": "Capture", "active_nav": "capture", }) @@ -41,22 +77,91 @@ async def list_capture(request: Request, show: str = "unprocessed", db: AsyncSes async def add_capture( request: Request, raw_text: str = Form(...), + area_id: Optional[str] = Form(None), + project_id: Optional[str] = Form(None), db: AsyncSession = Depends(get_db), ): repo = BaseRepository("capture", db) - # Multi-line: split into individual items lines = [l.strip() for l in raw_text.strip().split("\n") if l.strip()] + batch_id = str(uuid4()) if len(lines) > 1 else None + for line in lines: - await repo.create({"raw_text": line, "processed": False}) + data = {"raw_text": line, "processed": False} + if batch_id: + data["import_batch_id"] = batch_id + if area_id and area_id.strip(): + data["area_id"] = area_id + if project_id and project_id.strip(): + data["project_id"] = project_id + await repo.create(data) + return RedirectResponse(url="/capture", status_code=303) +# ---- Batch undo (must be before /{capture_id} routes) ---- + +@router.post("/batch/{batch_id}/undo") +async def batch_undo(batch_id: str, db: AsyncSession = Depends(get_db)): + """Delete all items from a batch.""" + await db.execute(text(""" + UPDATE capture SET is_deleted = true, deleted_at = now(), updated_at = now() + WHERE import_batch_id = :bid AND is_deleted = false + """), {"bid": batch_id}) + await db.commit() + return RedirectResponse(url="/capture", status_code=303) + + +# ---- Conversion form page ---- + +@router.get("/{capture_id}/convert/{convert_type}") +async def convert_form( + capture_id: str, convert_type: str, + request: Request, db: AsyncSession = Depends(get_db), +): + """Show conversion form for a specific capture item.""" + repo = BaseRepository("capture", db) + item = await repo.get(capture_id) + if not item or convert_type not in CONVERT_TYPES: + return RedirectResponse(url="/capture", status_code=303) + + sidebar = await get_sidebar_data(db) + + result = await db.execute(text( + "SELECT id, name FROM lists WHERE is_deleted = false ORDER BY name" + )) + all_lists = [dict(r._mapping) for r in result] + + # Parse name for contact pre-fill + parts = item["raw_text"].strip().split(None, 1) + first_name = parts[0] if parts else item["raw_text"] + last_name = parts[1] if len(parts) > 1 else "" + + # Extract URL for weblink pre-fill + url_match = re.search(r'https?://\S+', item["raw_text"]) + prefill_url = url_match.group(0) if url_match else "" + prefill_label = item["raw_text"].replace(prefill_url, "").strip() if url_match else item["raw_text"] + + return templates.TemplateResponse("capture_convert.html", { + "request": request, "sidebar": sidebar, + "item": item, "convert_type": convert_type, + "type_label": CONVERT_TYPES[convert_type], + "all_lists": all_lists, + "first_name": first_name, "last_name": last_name, + "prefill_url": prefill_url, "prefill_label": prefill_label or item["raw_text"], + "page_title": f"Convert to {CONVERT_TYPES[convert_type]}", + "active_nav": "capture", + }) + + +# ---- Conversion handlers ---- + @router.post("/{capture_id}/to-task") async def convert_to_task( - capture_id: str, - request: Request, + capture_id: str, request: Request, domain_id: str = Form(...), project_id: Optional[str] = Form(None), + priority: int = Form(3), + title: Optional[str] = Form(None), db: AsyncSession = Depends(get_db), ): capture_repo = BaseRepository("capture", db) @@ -65,23 +170,177 @@ async def convert_to_task( return RedirectResponse(url="/capture", status_code=303) task_repo = BaseRepository("tasks", db) - data = {"title": item["raw_text"], "domain_id": domain_id, "status": "open", "priority": 3} + data = { + "title": (title or item["raw_text"]).strip(), + "domain_id": domain_id, "status": "open", "priority": priority, + } if project_id and project_id.strip(): data["project_id"] = project_id task = await task_repo.create(data) await capture_repo.update(capture_id, { - "processed": True, - "converted_to_type": "task", + "processed": True, "converted_to_type": "task", "converted_to_id": str(task["id"]), }) - return RedirectResponse(url="/capture", status_code=303) + return RedirectResponse(url=f"/tasks/{task['id']}", status_code=303) + + +@router.post("/{capture_id}/to-note") +async def convert_to_note( + capture_id: str, request: Request, + title: Optional[str] = Form(None), + domain_id: Optional[str] = Form(None), + project_id: Optional[str] = Form(None), + db: AsyncSession = Depends(get_db), +): + capture_repo = BaseRepository("capture", db) + item = await capture_repo.get(capture_id) + if not item: + return RedirectResponse(url="/capture", status_code=303) + + note_repo = BaseRepository("notes", db) + raw = item["raw_text"] + data = {"title": (title or raw[:100]).strip(), "body": raw} + if domain_id and domain_id.strip(): + data["domain_id"] = domain_id + if project_id and project_id.strip(): + data["project_id"] = project_id + note = await note_repo.create(data) + + await capture_repo.update(capture_id, { + "processed": True, "converted_to_type": "note", + "converted_to_id": str(note["id"]), + }) + return RedirectResponse(url=f"/notes/{note['id']}", status_code=303) + + +@router.post("/{capture_id}/to-project") +async def convert_to_project( + capture_id: str, request: Request, + domain_id: str = Form(...), + name: Optional[str] = Form(None), + db: AsyncSession = Depends(get_db), +): + capture_repo = BaseRepository("capture", db) + item = await capture_repo.get(capture_id) + if not item: + return RedirectResponse(url="/capture", status_code=303) + + project_repo = BaseRepository("projects", db) + data = {"name": (name or item["raw_text"]).strip(), "domain_id": domain_id, "status": "active"} + project = await project_repo.create(data) + + await capture_repo.update(capture_id, { + "processed": True, "converted_to_type": "project", + "converted_to_id": str(project["id"]), + }) + return RedirectResponse(url=f"/projects/{project['id']}", status_code=303) + + +@router.post("/{capture_id}/to-list_item") +async def convert_to_list_item( + capture_id: str, request: Request, + list_id: str = Form(...), + content: Optional[str] = Form(None), + db: AsyncSession = Depends(get_db), +): + capture_repo = BaseRepository("capture", db) + item = await capture_repo.get(capture_id) + if not item: + return RedirectResponse(url="/capture", status_code=303) + + li_repo = BaseRepository("list_items", db) + data = {"list_id": list_id, "content": (content or item["raw_text"]).strip()} + li = await li_repo.create(data) + + await capture_repo.update(capture_id, { + "processed": True, "converted_to_type": "list_item", + "converted_to_id": str(li["id"]), "list_id": list_id, + }) + return RedirectResponse(url=f"/lists/{list_id}", status_code=303) + + +@router.post("/{capture_id}/to-contact") +async def convert_to_contact( + capture_id: str, request: Request, + first_name: str = Form(...), + last_name: Optional[str] = Form(None), + db: AsyncSession = Depends(get_db), +): + capture_repo = BaseRepository("capture", db) + item = await capture_repo.get(capture_id) + if not item: + return RedirectResponse(url="/capture", status_code=303) + + contact_repo = BaseRepository("contacts", db) + data = {"first_name": first_name.strip()} + if last_name and last_name.strip(): + data["last_name"] = last_name.strip() + contact = await contact_repo.create(data) + + await capture_repo.update(capture_id, { + "processed": True, "converted_to_type": "contact", + "converted_to_id": str(contact["id"]), + }) + return RedirectResponse(url=f"/contacts/{contact['id']}", status_code=303) + + +@router.post("/{capture_id}/to-decision") +async def convert_to_decision( + capture_id: str, request: Request, + title: Optional[str] = Form(None), + db: AsyncSession = Depends(get_db), +): + capture_repo = BaseRepository("capture", db) + item = await capture_repo.get(capture_id) + if not item: + return RedirectResponse(url="/capture", status_code=303) + + decision_repo = BaseRepository("decisions", db) + data = {"title": (title or item["raw_text"]).strip(), "status": "proposed", "impact": "medium"} + decision = await decision_repo.create(data) + + await capture_repo.update(capture_id, { + "processed": True, "converted_to_type": "decision", + "converted_to_id": str(decision["id"]), + }) + return RedirectResponse(url=f"/decisions/{decision['id']}", status_code=303) + + +@router.post("/{capture_id}/to-weblink") +async def convert_to_weblink( + capture_id: str, request: Request, + label: Optional[str] = Form(None), + url: Optional[str] = Form(None), + db: AsyncSession = Depends(get_db), +): + capture_repo = BaseRepository("capture", db) + item = await capture_repo.get(capture_id) + if not item: + return RedirectResponse(url="/capture", status_code=303) + + weblink_repo = BaseRepository("weblinks", db) + raw = item["raw_text"] + url_match = re.search(r'https?://\S+', raw) + link_url = (url.strip() if url and url.strip() else None) or (url_match.group(0) if url_match else raw) + link_label = (label.strip() if label and label.strip() else None) or (raw.replace(link_url, "").strip() if url_match else raw[:100]) + if not link_label: + link_label = link_url + + data = {"label": link_label, "url": link_url} + weblink = await weblink_repo.create(data) + + await capture_repo.update(capture_id, { + "processed": True, "converted_to_type": "weblink", + "converted_to_id": str(weblink["id"]), + }) + return RedirectResponse(url="/weblinks", status_code=303) @router.post("/{capture_id}/dismiss") async def dismiss_capture(capture_id: str, db: AsyncSession = Depends(get_db)): repo = BaseRepository("capture", db) - await repo.update(capture_id, {"processed": True}) + await repo.update(capture_id, {"processed": True, "converted_to_type": "dismissed"}) return RedirectResponse(url="/capture", status_code=303) diff --git a/templates/capture.html b/templates/capture.html index 57b3041..538dea2 100644 --- a/templates/capture.html +++ b/templates/capture.html @@ -8,28 +8,91 @@
- + +
+ Context (optional) +
+
+ + +
+
+ + +
+
+
-
- Unprocessed - All + +
+ Inbox + Processed + All
{% if items %} {% for item in items %} + +{# Batch header #} +{% if item._batch_first and item.import_batch_id %} +
+ Batch · {{ batches[item.import_batch_id|string] }} items +
+ +
+
+{% endif %} +
{{ item.raw_text }}
- {% if not item.processed %} + + {% if item.processed %}
-
- - -
+ {% if item.converted_to_type and item.converted_to_type != 'dismissed' %} + {{ item.converted_to_type|replace('_', ' ') }} + {% if item.converted_to_id %} + {% if item.converted_to_type == 'list_item' and item.list_id %} + View → + {% elif item.converted_to_type == 'weblink' %} + View → + {% elif item.converted_to_type in ('task', 'note', 'project', 'contact', 'decision') %} + View → + {% endif %} + {% endif %} + {% elif item.converted_to_type == 'dismissed' %} + dismissed + {% endif %} +
+ {% else %} +
+
@@ -37,6 +100,11 @@
{% endfor %} {% else %} -
📥
Capture queue is empty
+
+
📥
+
+ {% if show == 'processed' %}No processed items{% elif show == 'all' %}No capture items{% else %}Inbox is empty{% endif %} +
+
{% endif %} {% endblock %} diff --git a/templates/capture_convert.html b/templates/capture_convert.html new file mode 100644 index 0000000..404dbf6 --- /dev/null +++ b/templates/capture_convert.html @@ -0,0 +1,153 @@ +{% extends "base.html" %} +{% block content %} + + + + +
+ +
+ +
{{ item.raw_text }}
+
+ +
+
+ + {% if convert_type == 'task' %} +
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + {% elif convert_type == 'note' %} +
+ + +
+
+ + +
+
+ + +
+ + {% elif convert_type == 'project' %} +
+ + +
+
+ + +
+ + {% elif convert_type == 'list_item' %} +
+ + +
+
+ + +
+ + {% elif convert_type == 'contact' %} +
+ + +
+
+ + +
+ + {% elif convert_type == 'decision' %} +
+ + +
+ + {% elif convert_type == 'weblink' %} +
+ + +
+
+ + +
+ {% endif %} + +
+ + Cancel +
+
+
+
+{% endblock %}