diff --git a/frontend/src/components/AppShell.vue b/frontend/src/components/AppShell.vue
index e8cf5af..9a99b4b 100644
--- a/frontend/src/components/AppShell.vue
+++ b/frontend/src/components/AppShell.vue
@@ -305,6 +305,10 @@ async function signOut() {
>
Timeline
+
+
+ Export
+
diff --git a/frontend/src/components/Icon.vue b/frontend/src/components/Icon.vue
index d155b3a..852a8ae 100644
--- a/frontend/src/components/Icon.vue
+++ b/frontend/src/components/Icon.vue
@@ -23,6 +23,7 @@ const paths: Record = {
close: '',
merge: '',
history: '',
+ download: '',
};
diff --git a/src/thoughtsync/notes.py b/src/thoughtsync/notes.py
index 74a8e8b..80c268f 100644
--- a/src/thoughtsync/notes.py
+++ b/src/thoughtsync/notes.py
@@ -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():
diff --git a/tests/test_notes.py b/tests/test_notes.py
index 2dfafd4..edce952 100644
--- a/tests/test_notes.py
+++ b/tests/test_notes.py
@@ -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")