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:
@@ -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 import APIRouter, Request, Form, Depends
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
from fastapi.responses import RedirectResponse
|
from fastapi.responses import RedirectResponse
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from core.database import get_db
|
from core.database import get_db
|
||||||
from core.base_repository import BaseRepository
|
from core.base_repository import BaseRepository
|
||||||
@@ -14,25 +17,58 @@ from core.sidebar import get_sidebar_data
|
|||||||
router = APIRouter(prefix="/capture", tags=["capture"])
|
router = APIRouter(prefix="/capture", tags=["capture"])
|
||||||
templates = Jinja2Templates(directory="templates")
|
templates = Jinja2Templates(directory="templates")
|
||||||
|
|
||||||
|
CONVERT_TYPES = {
|
||||||
|
"task": "Task",
|
||||||
|
"note": "Note",
|
||||||
|
"project": "Project",
|
||||||
|
"list_item": "List Item",
|
||||||
|
"contact": "Contact",
|
||||||
|
"decision": "Decision",
|
||||||
|
"weblink": "Weblink",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/")
|
@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)
|
sidebar = await get_sidebar_data(db)
|
||||||
if show == "all":
|
|
||||||
filters = {}
|
|
||||||
else:
|
|
||||||
filters = {"processed": False}
|
|
||||||
|
|
||||||
result = await db.execute(text("""
|
if show == "processed":
|
||||||
SELECT * FROM capture WHERE is_deleted = false
|
where = "is_deleted = false AND processed = true"
|
||||||
AND (:show_all OR processed = false)
|
elif show == "all":
|
||||||
ORDER BY created_at DESC
|
where = "is_deleted = false"
|
||||||
"""), {"show_all": show == "all"})
|
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]
|
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", {
|
return templates.TemplateResponse("capture.html", {
|
||||||
"request": request, "sidebar": sidebar, "items": items,
|
"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",
|
"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(
|
async def add_capture(
|
||||||
request: Request,
|
request: Request,
|
||||||
raw_text: str = Form(...),
|
raw_text: str = Form(...),
|
||||||
|
area_id: Optional[str] = Form(None),
|
||||||
|
project_id: Optional[str] = Form(None),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
repo = BaseRepository("capture", 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()]
|
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:
|
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)
|
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")
|
@router.post("/{capture_id}/to-task")
|
||||||
async def convert_to_task(
|
async def convert_to_task(
|
||||||
capture_id: str,
|
capture_id: str, request: Request,
|
||||||
request: Request,
|
|
||||||
domain_id: str = Form(...),
|
domain_id: str = Form(...),
|
||||||
project_id: Optional[str] = Form(None),
|
project_id: Optional[str] = Form(None),
|
||||||
|
priority: int = Form(3),
|
||||||
|
title: Optional[str] = Form(None),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
capture_repo = BaseRepository("capture", db)
|
capture_repo = BaseRepository("capture", db)
|
||||||
@@ -65,23 +170,177 @@ async def convert_to_task(
|
|||||||
return RedirectResponse(url="/capture", status_code=303)
|
return RedirectResponse(url="/capture", status_code=303)
|
||||||
|
|
||||||
task_repo = BaseRepository("tasks", db)
|
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():
|
if project_id and project_id.strip():
|
||||||
data["project_id"] = project_id
|
data["project_id"] = project_id
|
||||||
task = await task_repo.create(data)
|
task = await task_repo.create(data)
|
||||||
|
|
||||||
await capture_repo.update(capture_id, {
|
await capture_repo.update(capture_id, {
|
||||||
"processed": True,
|
"processed": True, "converted_to_type": "task",
|
||||||
"converted_to_type": "task",
|
|
||||||
"converted_to_id": str(task["id"]),
|
"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)
|
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")
|
@router.post("/{capture_id}/dismiss")
|
||||||
async def dismiss_capture(capture_id: str, db: AsyncSession = Depends(get_db)):
|
async def dismiss_capture(capture_id: str, db: AsyncSession = Depends(get_db)):
|
||||||
repo = BaseRepository("capture", 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)
|
return RedirectResponse(url="/capture", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -8,28 +8,91 @@
|
|||||||
<div class="card mb-4">
|
<div class="card mb-4">
|
||||||
<form action="/capture/add" method="post">
|
<form action="/capture/add" method="post">
|
||||||
<label class="form-label mb-2">Quick Capture (one item per line)</label>
|
<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... Each line becomes a separate capture item"></textarea>
|
<textarea name="raw_text" class="form-textarea" rows="3" placeholder="Type or paste items here... 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>
|
<div class="mt-2"><button type="submit" class="btn btn-primary">Capture</button></div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-2 mb-3">
|
<!-- Filter tabs -->
|
||||||
<a href="/capture?show=unprocessed" class="btn {{ 'btn-primary' if show != 'all' else 'btn-secondary' }} btn-sm">Unprocessed</a>
|
<div class="tab-strip mb-3">
|
||||||
<a href="/capture?show=all" class="btn {{ 'btn-primary' if show == 'all' else 'btn-secondary' }} btn-sm">All</a>
|
<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>
|
</div>
|
||||||
|
|
||||||
{% if items %}
|
{% if items %}
|
||||||
{% for item in 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 · {{ 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-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>
|
<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">
|
<div class="capture-actions">
|
||||||
<form action="/capture/{{ item.id }}/to-task" method="post" class="flex gap-2">
|
{% if item.converted_to_type and item.converted_to_type != 'dismissed' %}
|
||||||
<select name="domain_id" class="filter-select" style="font-size:0.75rem;padding:3px 6px" required>
|
<span class="row-tag">{{ item.converted_to_type|replace('_', ' ') }}</span>
|
||||||
{% for d in sidebar.domain_tree %}<option value="{{ d.id }}">{{ d.name }}</option>{% endfor %}
|
{% 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 →</a>
|
||||||
|
{% elif item.converted_to_type == 'weblink' %}
|
||||||
|
<a href="/weblinks" class="btn btn-ghost btn-xs">View →</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 →</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>
|
</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 }}/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)">×</button></form>
|
<form action="/capture/{{ item.id }}/delete" method="post" style="display:inline"><button class="btn btn-ghost btn-xs" style="color:var(--red)">×</button></form>
|
||||||
</div>
|
</div>
|
||||||
@@ -37,6 +100,11 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="empty-state"><div class="empty-state-icon">📥</div><div class="empty-state-text">Capture queue is empty</div></div>
|
<div class="empty-state">
|
||||||
|
<div class="empty-state-icon">📥</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 %}
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
153
templates/capture_convert.html
Normal file
153
templates/capture_convert.html
Normal 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 %}
|
||||||
Reference in New Issue
Block a user