M6: export — download all your notes as a zip
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 5s
CI & Build / Python tests (push) Successful in 9s
CI & Build / Build & push image (push) Successful in 33s

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:
2026-07-22 16:48:49 -04:00
co-authored by Claude Opus 4.8
parent efbf981a2a
commit cc828559ff
4 changed files with 134 additions and 1 deletions
+4
View File
@@ -305,6 +305,10 @@ async function signOut() {
> >
<Icon name="calendar" /> Timeline <Icon name="calendar" /> Timeline
</RouterLink> </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> </nav>
</aside> </aside>
+1
View File
@@ -23,6 +23,7 @@ const paths: Record<string, string> = {
close: '<path d="M18 6 6 18"/><path d="m6 6 12 12"/>', 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"/>', 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"/>', 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> </script>
+114 -1
View File
@@ -1,11 +1,14 @@
from __future__ import annotations from __future__ import annotations
import io
import json
import os import os
import re import re
import uuid import uuid
import zipfile
from datetime import datetime, timezone 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 sqlalchemy import case, delete, func, literal_column, select
from .acl import visible_to_user from .acl import visible_to_user
@@ -361,6 +364,116 @@ async def list_reminders():
return jsonify({"notes": await _serialize_notes(db, notes)}) 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") @bp.get("/titles")
@login_required @login_required
async def list_titles(): async def list_titles():
+15
View File
@@ -5,6 +5,7 @@ from thoughtsync.models.note import NOTE_COLORS, Note
from thoughtsync.notes import ( from thoughtsync.notes import (
_escape_like, _escape_like,
_parse_iso_dt, _parse_iso_dt,
_slugify,
derive_display_title, derive_display_title,
is_empty_note, is_empty_note,
normalize_color, normalize_color,
@@ -196,6 +197,20 @@ async def test_reminders_requires_auth(app):
assert resp.status_code == 401 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): async def test_list_revisions_requires_auth(app):
client = app.test_client() client = app.test_client()
resp = await client.get("/api/notes/00000000-0000-0000-0000-000000000000/revisions") resp = await client.get("/api/notes/00000000-0000-0000-0000-000000000000/revisions")