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
</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>
+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"/>',
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
View File
@@ -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():
+15
View File
@@ -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")