feat: enhanced capture queue with full conversion, batching, and filtering

- Convert to 7 entity types: task, note, project, list item, contact, decision, weblink
- Each conversion has a dedicated form page with pre-filled fields and context selectors
- Multi-line paste creates batch with shared import_batch_id and undo button
- 3-tab filtering: Inbox (unprocessed), Processed (with conversion links), All
- Context pre-fill: optional area/project selectors on capture form
- Processed items show type badge and link to converted entity
- Sidebar badge count for unprocessed items already working

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-01 21:47:23 +00:00
parent 5092e7e015
commit a21e00d0e0
3 changed files with 514 additions and 34 deletions

View File

@@ -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=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)

View File

@@ -8,28 +8,91 @@
<div class="card mb-4">
<form action="/capture/add" method="post">
<label class="form-label mb-2">Quick Capture (one item per line)</label>
<textarea name="raw_text" class="form-textarea" rows="3" placeholder="Type or paste items here...&#10;Each line becomes a separate capture item"></textarea>
<textarea name="raw_text" class="form-textarea" rows="3" placeholder="Type or paste items here...&#10;Each line becomes a separate capture item" required></textarea>
<details class="mt-2">
<summary class="text-sm text-muted" style="cursor:pointer">Context (optional)</summary>
<div class="form-grid mt-2" style="grid-template-columns:1fr 1fr">
<div class="form-group">
<label class="form-label">Project</label>
<select name="project_id" class="form-select">
<option value="">None</option>
{% for d in sidebar.domain_tree %}
<optgroup label="{{ d.name }}">
{% for a in d.areas %}{% for p in a.projects %}
<option value="{{ p.id }}">{{ p.name }}</option>
{% endfor %}{% endfor %}
{% for p in d.standalone_projects %}
<option value="{{ p.id }}">{{ p.name }}</option>
{% endfor %}
</optgroup>
{% endfor %}
</select>
</div>
<div class="form-group">
<label class="form-label">Area</label>
<select name="area_id" class="form-select">
<option value="">None</option>
{% for d in sidebar.domain_tree %}
{% for a in d.areas %}
<option value="{{ a.id }}">{{ d.name }} / {{ a.name }}</option>
{% endfor %}
{% endfor %}
</select>
</div>
</div>
</details>
<div class="mt-2"><button type="submit" class="btn btn-primary">Capture</button></div>
</form>
</div>
<div class="flex items-center gap-2 mb-3">
<a href="/capture?show=unprocessed" class="btn {{ 'btn-primary' if show != 'all' else 'btn-secondary' }} btn-sm">Unprocessed</a>
<a href="/capture?show=all" class="btn {{ 'btn-primary' if show == 'all' else 'btn-secondary' }} btn-sm">All</a>
<!-- Filter tabs -->
<div class="tab-strip mb-3">
<a href="/capture?show=inbox" class="tab-item {{ 'active' if show == 'inbox' or show not in ('processed', 'all') }}">Inbox</a>
<a href="/capture?show=processed" class="tab-item {{ 'active' if show == 'processed' }}">Processed</a>
<a href="/capture?show=all" class="tab-item {{ 'active' if show == 'all' }}">All</a>
</div>
{% if items %}
{% for item in items %}
{# Batch header #}
{% if item._batch_first and item.import_batch_id %}
<div class="flex items-center justify-between mb-2 mt-3" style="padding:6px 12px;background:var(--surface2);border-radius:var(--radius-sm)">
<span class="text-xs text-muted">Batch &middot; {{ batches[item.import_batch_id|string] }} items</span>
<form action="/capture/batch/{{ item.import_batch_id }}/undo" method="post" style="display:inline" data-confirm="Delete all items in this batch?">
<button class="btn btn-ghost btn-xs" style="color:var(--red)">Undo batch</button>
</form>
</div>
{% endif %}
<div class="capture-item {{ 'completed' if item.processed }}">
<div class="capture-text {{ 'text-muted' if item.processed }}" style="{{ 'text-decoration:line-through' if item.processed }}">{{ item.raw_text }}</div>
{% if not item.processed %}
{% if item.processed %}
<div class="capture-actions">
<form action="/capture/{{ item.id }}/to-task" method="post" class="flex gap-2">
<select name="domain_id" class="filter-select" style="font-size:0.75rem;padding:3px 6px" required>
{% for d in sidebar.domain_tree %}<option value="{{ d.id }}">{{ d.name }}</option>{% endfor %}
{% if item.converted_to_type and item.converted_to_type != 'dismissed' %}
<span class="row-tag">{{ item.converted_to_type|replace('_', ' ') }}</span>
{% if item.converted_to_id %}
{% if item.converted_to_type == 'list_item' and item.list_id %}
<a href="/lists/{{ item.list_id }}" class="btn btn-ghost btn-xs">View &rarr;</a>
{% elif item.converted_to_type == 'weblink' %}
<a href="/weblinks" class="btn btn-ghost btn-xs">View &rarr;</a>
{% elif item.converted_to_type in ('task', 'note', 'project', 'contact', 'decision') %}
<a href="/{{ item.converted_to_type }}s/{{ item.converted_to_id }}" class="btn btn-ghost btn-xs">View &rarr;</a>
{% endif %}
{% endif %}
{% elif item.converted_to_type == 'dismissed' %}
<span class="row-tag" style="color:var(--muted)">dismissed</span>
{% endif %}
</div>
{% else %}
<div class="capture-actions">
<select onchange="if(this.value) window.location.href=this.value" class="filter-select" style="font-size:0.75rem;padding:3px 6px">
<option value="">Convert...</option>
{% for key, label in convert_types.items() %}
<option value="/capture/{{ item.id }}/convert/{{ key }}">{{ label }}</option>
{% endfor %}
</select>
<button type="submit" class="btn btn-ghost btn-xs" style="color:var(--green)">To Task</button>
</form>
<form action="/capture/{{ item.id }}/dismiss" method="post" style="display:inline"><button class="btn btn-ghost btn-xs">Dismiss</button></form>
<form action="/capture/{{ item.id }}/delete" method="post" style="display:inline"><button class="btn btn-ghost btn-xs" style="color:var(--red)">&times;</button></form>
</div>
@@ -37,6 +100,11 @@
</div>
{% endfor %}
{% else %}
<div class="empty-state"><div class="empty-state-icon">&#128229;</div><div class="empty-state-text">Capture queue is empty</div></div>
<div class="empty-state">
<div class="empty-state-icon">&#128229;</div>
<div class="empty-state-text">
{% if show == 'processed' %}No processed items{% elif show == 'all' %}No capture items{% else %}Inbox is empty{% endif %}
</div>
</div>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,153 @@
{% extends "base.html" %}
{% block content %}
<div class="breadcrumb">
<a href="/capture">Capture</a> <span class="sep">/</span> Convert to {{ type_label }}
</div>
<div class="page-header">
<h1 class="page-title">Convert to {{ type_label }}</h1>
</div>
<div class="card">
<!-- Original text -->
<div class="form-group mb-3">
<label class="form-label">Original Text</label>
<div style="padding:10px 12px;background:var(--surface2);border-radius:var(--radius);font-size:0.92rem;color:var(--text-secondary)">{{ item.raw_text }}</div>
</div>
<form action="/capture/{{ item.id }}/to-{{ convert_type }}" method="post">
<div class="form-grid">
{% if convert_type == 'task' %}
<div class="form-group full-width">
<label class="form-label">Title</label>
<input type="text" name="title" class="form-input" value="{{ item.raw_text }}" required>
</div>
<div class="form-group">
<label class="form-label">Domain *</label>
<select name="domain_id" class="form-select" required>
{% for d in sidebar.domain_tree %}
<option value="{{ d.id }}">{{ d.name }}</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label class="form-label">Project</label>
<select name="project_id" class="form-select">
<option value="">None</option>
{% for d in sidebar.domain_tree %}
<optgroup label="{{ d.name }}">
{% for a in d.areas %}{% for p in a.projects %}
<option value="{{ p.id }}" {{ 'selected' if item.project_id and p.id|string == item.project_id|string }}>{{ p.name }}</option>
{% endfor %}{% endfor %}
{% for p in d.standalone_projects %}
<option value="{{ p.id }}" {{ 'selected' if item.project_id and p.id|string == item.project_id|string }}>{{ p.name }}</option>
{% endfor %}
</optgroup>
{% endfor %}
</select>
</div>
<div class="form-group">
<label class="form-label">Priority</label>
<select name="priority" class="form-select">
<option value="1">1 - Critical</option>
<option value="2">2 - High</option>
<option value="3" selected>3 - Normal</option>
<option value="4">4 - Low</option>
</select>
</div>
{% elif convert_type == 'note' %}
<div class="form-group full-width">
<label class="form-label">Title</label>
<input type="text" name="title" class="form-input" value="{{ item.raw_text[:100] }}" required>
</div>
<div class="form-group">
<label class="form-label">Domain</label>
<select name="domain_id" class="form-select">
<option value="">None</option>
{% for d in sidebar.domain_tree %}
<option value="{{ d.id }}">{{ d.name }}</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label class="form-label">Project</label>
<select name="project_id" class="form-select">
<option value="">None</option>
{% for d in sidebar.domain_tree %}
<optgroup label="{{ d.name }}">
{% for a in d.areas %}{% for p in a.projects %}
<option value="{{ p.id }}" {{ 'selected' if item.project_id and p.id|string == item.project_id|string }}>{{ p.name }}</option>
{% endfor %}{% endfor %}
{% for p in d.standalone_projects %}
<option value="{{ p.id }}" {{ 'selected' if item.project_id and p.id|string == item.project_id|string }}>{{ p.name }}</option>
{% endfor %}
</optgroup>
{% endfor %}
</select>
</div>
{% elif convert_type == 'project' %}
<div class="form-group full-width">
<label class="form-label">Project Name</label>
<input type="text" name="name" class="form-input" value="{{ item.raw_text }}" required>
</div>
<div class="form-group">
<label class="form-label">Domain *</label>
<select name="domain_id" class="form-select" required>
{% for d in sidebar.domain_tree %}
<option value="{{ d.id }}">{{ d.name }}</option>
{% endfor %}
</select>
</div>
{% elif convert_type == 'list_item' %}
<div class="form-group full-width">
<label class="form-label">Content</label>
<input type="text" name="content" class="form-input" value="{{ item.raw_text }}" required>
</div>
<div class="form-group">
<label class="form-label">Add to List *</label>
<select name="list_id" class="form-select" required>
{% for lst in all_lists %}
<option value="{{ lst.id }}" {{ 'selected' if item.list_id and lst.id|string == item.list_id|string }}>{{ lst.name }}</option>
{% endfor %}
</select>
</div>
{% elif convert_type == 'contact' %}
<div class="form-group">
<label class="form-label">First Name *</label>
<input type="text" name="first_name" class="form-input" value="{{ first_name }}" required>
</div>
<div class="form-group">
<label class="form-label">Last Name</label>
<input type="text" name="last_name" class="form-input" value="{{ last_name }}">
</div>
{% elif convert_type == 'decision' %}
<div class="form-group full-width">
<label class="form-label">Decision Title</label>
<input type="text" name="title" class="form-input" value="{{ item.raw_text }}" required>
</div>
{% elif convert_type == 'weblink' %}
<div class="form-group full-width">
<label class="form-label">Label</label>
<input type="text" name="label" class="form-input" value="{{ prefill_label }}" required>
</div>
<div class="form-group full-width">
<label class="form-label">URL</label>
<input type="url" name="url" class="form-input" value="{{ prefill_url }}" placeholder="https://" required>
</div>
{% endif %}
<div class="form-actions">
<button type="submit" class="btn btn-primary">Convert to {{ type_label }}</button>
<a href="/capture" class="btn btn-secondary">Cancel</a>
</div>
</div>
</form>
</div>
{% endblock %}