M6: export — download all your notes as a zip
Data portability / no lock-in (task 1907, export half). GET /api/notes/export streams a zip of the caller's notes: a machine-readable notes.json (notes + labels + items + reminders + attachment refs), a human-readable Markdown file per note (frontmatter + body / checklist), and the attachment media. Sidebar 'Export' link (same-origin GET, session cookie) downloads it. _slugify unit-tested. Import (Google Keep Takeout) is the follow-up increment of this task. Pure backend + small frontend; no migration. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
This commit is contained in:
@@ -305,6 +305,10 @@ async function signOut() {
|
||||
>
|
||||
<Icon name="calendar" /> Timeline
|
||||
</RouterLink>
|
||||
<!-- Direct download (same-origin GET, session cookie sent); not a route. -->
|
||||
<a href="/api/notes/export" download class="nav-link" title="Download all your notes as a zip">
|
||||
<Icon name="download" /> Export
|
||||
</a>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ const paths: Record<string, string> = {
|
||||
close: '<path d="M18 6 6 18"/><path d="m6 6 12 12"/>',
|
||||
merge: '<circle cx="18" cy="18" r="3"/><circle cx="6" cy="6" r="3"/><path d="M6 21V9a9 9 0 0 0 9 9"/>',
|
||||
history: '<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/><path d="M12 7v5l4 2"/>',
|
||||
download: '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" x2="12" y1="15" y2="3"/>',
|
||||
};
|
||||
</script>
|
||||
|
||||
|
||||
+114
-1
@@ -1,11 +1,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import uuid
|
||||
import zipfile
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from quart import Blueprint, g, jsonify, request, send_file
|
||||
from quart import Blueprint, Response, g, jsonify, request, send_file
|
||||
from sqlalchemy import case, delete, func, literal_column, select
|
||||
|
||||
from .acl import visible_to_user
|
||||
@@ -361,6 +364,116 @@ async def list_reminders():
|
||||
return jsonify({"notes": await _serialize_notes(db, notes)})
|
||||
|
||||
|
||||
def _slugify(text: str) -> str:
|
||||
"""A filesystem-safe slug from a note's display name (for the .md filename)."""
|
||||
s = re.sub(r"[^\w\s-]", "", (text or "").strip().lower())
|
||||
s = re.sub(r"[\s_-]+", "-", s).strip("-")
|
||||
return s[:60] or "note"
|
||||
|
||||
|
||||
def _note_markdown(note: Note, labels: list, items: list) -> str:
|
||||
"""One note as a human-readable Markdown file with a small frontmatter block.
|
||||
The authoritative machine format is notes.json; this is for reading/portability."""
|
||||
fm = ["---"]
|
||||
if note.title:
|
||||
fm.append(f"title: {note.title}")
|
||||
fm.append(f"display_name: {note.display_title}")
|
||||
if labels:
|
||||
fm.append("labels: [" + ", ".join(lb["name"] for lb in labels) + "]")
|
||||
fm.append(f"color: {note.color}")
|
||||
if note.pinned:
|
||||
fm.append("pinned: true")
|
||||
if note.archived:
|
||||
fm.append("archived: true")
|
||||
if note.remind_at:
|
||||
fm.append(f"remind_at: {note.remind_at.isoformat()}")
|
||||
fm.append(f"created: {note.created_at.isoformat() if note.created_at else ''}")
|
||||
fm.append(f"updated: {note.updated_at.isoformat() if note.updated_at else ''}")
|
||||
fm.append("---")
|
||||
fm.append("")
|
||||
if note.kind == "list":
|
||||
for it in items:
|
||||
fm.append(f"- [{'x' if it['checked'] else ' '}] {it['text']}")
|
||||
else:
|
||||
fm.append(note.body)
|
||||
return "\n".join(fm) + "\n"
|
||||
|
||||
|
||||
@bp.get("/export")
|
||||
@login_required
|
||||
async def export_notes():
|
||||
"""Download all of the caller's notes as a zip: a machine-readable notes.json,
|
||||
a Markdown file per note, and the attachment media. 'Your data is yours.'"""
|
||||
async with session_scope() as db:
|
||||
notes_list = (
|
||||
await db.scalars(
|
||||
select(Note).where(Note.owner_id == g.user_id, Note.deleted_at.is_(None)).order_by(Note.created_at)
|
||||
)
|
||||
).all()
|
||||
ids = [n.id for n in notes_list]
|
||||
labels_map = await _labels_for_notes(db, ids)
|
||||
items_map = await _items_for_notes(db, ids)
|
||||
att_rows = (
|
||||
(await db.scalars(select(NoteAttachment).where(NoteAttachment.note_id.in_(ids)))).all() if ids else []
|
||||
)
|
||||
att_by_note: dict = {}
|
||||
for a in att_rows:
|
||||
att_by_note.setdefault(a.note_id, []).append(a)
|
||||
all_labels = (
|
||||
await db.scalars(select(Label).where(Label.owner_id == g.user_id).order_by(Label.name))
|
||||
).all()
|
||||
|
||||
payload: dict = {
|
||||
"app": "thoughtsync",
|
||||
"version": 1,
|
||||
"exported_at": datetime.now(timezone.utc).isoformat(),
|
||||
"labels": [{"name": lb.name, "color": lb.color} for lb in all_labels],
|
||||
"notes": [],
|
||||
}
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||
for n in notes_list:
|
||||
labels = labels_map.get(n.id, [])
|
||||
items = items_map.get(n.id, [])
|
||||
atts = att_by_note.get(n.id, [])
|
||||
short = str(n.id)[:8]
|
||||
payload["notes"].append(
|
||||
{
|
||||
"id": str(n.id),
|
||||
"title": n.title,
|
||||
"display_title": n.display_title,
|
||||
"body": n.body,
|
||||
"color": n.color,
|
||||
"kind": n.kind,
|
||||
"pinned": n.pinned,
|
||||
"archived": n.archived,
|
||||
"remind_at": n.remind_at.isoformat() if n.remind_at else None,
|
||||
"created_at": n.created_at.isoformat() if n.created_at else None,
|
||||
"updated_at": n.updated_at.isoformat() if n.updated_at else None,
|
||||
"labels": [lb["name"] for lb in labels],
|
||||
"items": [{"text": it["text"], "checked": it["checked"]} for it in items],
|
||||
"attachments": [
|
||||
{"file": f"attachments/{short}/{os.path.basename(a.path)}", "mime": a.mime} for a in atts
|
||||
],
|
||||
}
|
||||
)
|
||||
zf.writestr(f"notes/{_slugify(n.display_title)}-{short}.md", _note_markdown(n, labels, items))
|
||||
for a in atts:
|
||||
src = Config.media_root() / a.path
|
||||
if src.is_file():
|
||||
zf.write(src, f"attachments/{short}/{os.path.basename(a.path)}")
|
||||
zf.writestr("notes.json", json.dumps(payload, indent=2, ensure_ascii=False))
|
||||
data = buf.getvalue()
|
||||
stamp = datetime.now(timezone.utc).strftime("%Y%m%d")
|
||||
return Response(
|
||||
data,
|
||||
headers={
|
||||
"Content-Type": "application/zip",
|
||||
"Content-Disposition": f'attachment; filename="thoughtsync-export-{stamp}.zip"',
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@bp.get("/titles")
|
||||
@login_required
|
||||
async def list_titles():
|
||||
|
||||
@@ -5,6 +5,7 @@ from thoughtsync.models.note import NOTE_COLORS, Note
|
||||
from thoughtsync.notes import (
|
||||
_escape_like,
|
||||
_parse_iso_dt,
|
||||
_slugify,
|
||||
derive_display_title,
|
||||
is_empty_note,
|
||||
normalize_color,
|
||||
@@ -196,6 +197,20 @@ async def test_reminders_requires_auth(app):
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
def test_slugify():
|
||||
assert _slugify("My Great Note!") == "my-great-note"
|
||||
assert _slugify(" spaced / weird __name ") == "spaced-weird-name"
|
||||
assert _slugify("") == "note" # empty falls back
|
||||
assert _slugify("!!!") == "note" # all punctuation strips to empty → fallback
|
||||
assert len(_slugify("x" * 100)) == 60
|
||||
|
||||
|
||||
async def test_export_requires_auth(app):
|
||||
client = app.test_client()
|
||||
resp = await client.get("/api/notes/export")
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
async def test_list_revisions_requires_auth(app):
|
||||
client = app.test_client()
|
||||
resp = await client.get("/api/notes/00000000-0000-0000-0000-000000000000/revisions")
|
||||
|
||||
Reference in New Issue
Block a user