import io import json import re import zipfile from datetime import datetime, timezone from quart import Blueprint, Response, request from scribe.auth import login_required, get_current_user_id from scribe.models import async_session from scribe.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="scribe-{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="scribe-{stamp}.zip"', }, )