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")