UI polish pass: word count, slash commands, task lists, graph peek, bulk delete, export
- WordCount component (toggle words/chars vs read time, persisted mode) - TipTap: TaskList/TaskItem extensions, slash command menu (H1-H3, lists, code, quote, task) - Markdown serializer: task list → `- [ ]` / `- [x]` roundtrip - GraphView: slide-in peek panel for note/task nodes (body, tags, linked nodes); tag nodes still navigate - ChatView: bulk-select conversations with two-click confirm delete + chat retention policy (default 90d) - NoteEditorView: pill tab bar with animated edit/preview toggle + WordCount in toolbar - WorkspaceNoteEditor: inline search with tag matching, inline new-note creation, WordCount - WorkspaceTaskPanel: task body rendered in slide-over + Edit link - Settings: data export (Markdown ZIP / JSON) via GET /api/export - Accessibility: skip-to-content link, aria-labels on all icon-only buttons - Fix: export route used non-existent fabledassistant.database — corrected to async_session() - Fix: ToolCallRecord.status type now includes "running" (was causing TS build error) - Dockerfile: upgrade npm to latest before install to suppress major-version notice - npm audit fix: patched minimatch (ReDoS) and rollup (path traversal) — 0 vulnerabilities Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,117 @@
|
||||
import io
|
||||
import json
|
||||
import re
|
||||
import zipfile
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from quart import Blueprint, Response, request
|
||||
|
||||
from fabledassistant.auth import login_required, get_current_user_id
|
||||
from fabledassistant.models import async_session
|
||||
from fabledassistant.models.note import Note
|
||||
from sqlalchemy import select
|
||||
|
||||
export_bp = Blueprint("export", __name__)
|
||||
|
||||
|
||||
def _safe_filename(title: str) -> str:
|
||||
name = re.sub(r"[^\w\s-]", "", title or "untitled").strip()
|
||||
name = re.sub(r"[\s]+", "_", name) or "untitled"
|
||||
return name[:80]
|
||||
|
||||
|
||||
def _note_frontmatter(note: Note) -> str:
|
||||
lines = ["---"]
|
||||
lines.append(f"title: {json.dumps(note.title or '')}")
|
||||
lines.append(f"type: {'task' if note.is_task else 'note'}")
|
||||
if note.tags:
|
||||
lines.append(f"tags: [{', '.join(note.tags)}]")
|
||||
if note.is_task:
|
||||
lines.append(f"status: {note.status or 'todo'}")
|
||||
lines.append(f"priority: {note.priority or 'none'}")
|
||||
if note.due_date:
|
||||
lines.append(f"due_date: {note.due_date}")
|
||||
if note.project_id:
|
||||
lines.append(f"project_id: {note.project_id}")
|
||||
lines.append(f"created_at: {note.created_at.isoformat() if note.created_at else ''}")
|
||||
lines.append(f"updated_at: {note.updated_at.isoformat() if note.updated_at else ''}")
|
||||
lines.append("---")
|
||||
return "\n".join(lines) + "\n\n"
|
||||
|
||||
|
||||
async def _fetch_notes(uid: int):
|
||||
async with async_session() as db:
|
||||
result = await db.execute(
|
||||
select(Note)
|
||||
.where(Note.user_id == uid)
|
||||
.order_by(Note.updated_at.desc())
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
@export_bp.get("/api/export")
|
||||
@login_required
|
||||
async def export_data():
|
||||
uid = get_current_user_id()
|
||||
fmt = request.args.get("format", "markdown")
|
||||
|
||||
notes = await _fetch_notes(uid)
|
||||
|
||||
if fmt == "json":
|
||||
payload = [
|
||||
{
|
||||
"id": n.id,
|
||||
"title": n.title,
|
||||
"body": n.body,
|
||||
"is_task": n.is_task,
|
||||
"status": n.status,
|
||||
"priority": n.priority,
|
||||
"tags": n.tags or [],
|
||||
"due_date": str(n.due_date) if n.due_date else None,
|
||||
"project_id": n.project_id,
|
||||
"milestone_id": n.milestone_id,
|
||||
"parent_id": n.parent_id,
|
||||
"created_at": n.created_at.isoformat() if n.created_at else None,
|
||||
"updated_at": n.updated_at.isoformat() if n.updated_at else None,
|
||||
}
|
||||
for n in notes
|
||||
]
|
||||
data = json.dumps(payload, indent=2, ensure_ascii=False).encode("utf-8")
|
||||
stamp = datetime.now(timezone.utc).strftime("%Y%m%d")
|
||||
return Response(
|
||||
data,
|
||||
status=200,
|
||||
headers={
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
"Content-Disposition": f'attachment; filename="fabledassistant-{stamp}.json"',
|
||||
},
|
||||
)
|
||||
|
||||
# Markdown ZIP
|
||||
buf = io.BytesIO()
|
||||
used_names: dict[str, int] = {}
|
||||
|
||||
with zipfile.ZipFile(buf, mode="w", compression=zipfile.ZIP_DEFLATED) as zf:
|
||||
index_rows = []
|
||||
for note in notes:
|
||||
base = _safe_filename(note.title)
|
||||
count = used_names.get(base, 0)
|
||||
used_names[base] = count + 1
|
||||
fname = f"{base}_{count}.md" if count else f"{base}.md"
|
||||
|
||||
content = _note_frontmatter(note) + (note.body or "")
|
||||
zf.writestr(fname, content.encode("utf-8"))
|
||||
index_rows.append({"file": fname, "title": note.title, "id": note.id})
|
||||
|
||||
zf.writestr("index.json", json.dumps(index_rows, indent=2, ensure_ascii=False))
|
||||
|
||||
buf.seek(0)
|
||||
stamp = datetime.now(timezone.utc).strftime("%Y%m%d")
|
||||
return Response(
|
||||
buf.read(),
|
||||
status=200,
|
||||
headers={
|
||||
"Content-Type": "application/zip",
|
||||
"Content-Disposition": f'attachment; filename="fabledassistant-{stamp}.zip"',
|
||||
},
|
||||
)
|
||||
Reference in New Issue
Block a user