Files
FabledScribe/src/scribe/routes/export.py
T
bvandeusen b255a0f90e
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 35s
CI & Build / Python tests (push) Successful in 54s
CI & Build / Build & push image (push) Successful in 1m14s
refactor: rename package fabledassistant -> scribe (code-only)
Renames src/fabledassistant -> src/scribe and all imports, plus the
default DB name and DB user/password (fabled -> scribe) in config +
compose. 952 refs / 154 files. Reverses the old 'internal name stays
fabledassistant' convention.

Code-only: live databases are still physically named 'fabledassistant'.
Deployed environments must set POSTGRES_DB / POSTGRES_USER (or rename the
DB) since the defaults now resolve to 'scribe'. Repo (FabledScribe), git
host (fabledsword), MCP (fabled-git) and the image name (fabledscribe)
are intentionally unchanged.

ruff check src/ clean locally; CI (typecheck + pytest) is the gate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 15:48:35 -04:00

118 lines
3.8 KiB
Python

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"',
},
)