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
|
<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>
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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():
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
Reference in New Issue
Block a user