diff --git a/frontend/src/components/AppShell.vue b/frontend/src/components/AppShell.vue index 9a99b4b..975ef6a 100644 --- a/frontend/src/components/AppShell.vue +++ b/frontend/src/components/AppShell.vue @@ -7,6 +7,7 @@ import { useLabelsStore } from "../stores/labels"; import { useUiStore } from "../stores/ui"; import CommandPalette from "./CommandPalette.vue"; import Icon from "./Icon.vue"; +import ImportNotes from "./ImportNotes.vue"; import LabelsModal from "./LabelsModal.vue"; import { NOTE_SWATCH_CLASSES, type NoteColor } from "../notes/colors"; @@ -309,6 +310,7 @@ async function signOut() { Export + diff --git a/frontend/src/components/Icon.vue b/frontend/src/components/Icon.vue index 852a8ae..351586e 100644 --- a/frontend/src/components/Icon.vue +++ b/frontend/src/components/Icon.vue @@ -24,6 +24,7 @@ const paths: Record = { merge: '', history: '', download: '', + upload: '', }; diff --git a/frontend/src/components/ImportNotes.vue b/frontend/src/components/ImportNotes.vue new file mode 100644 index 0000000..813d18c --- /dev/null +++ b/frontend/src/components/ImportNotes.vue @@ -0,0 +1,52 @@ + + + diff --git a/frontend/src/stores/notes.ts b/frontend/src/stores/notes.ts index 1e8bae5..509c0b8 100644 --- a/frontend/src/stores/notes.ts +++ b/frontend/src/stores/notes.ts @@ -171,6 +171,23 @@ export const useNotesStore = defineStore("notes", () => { reconcile(await api.del(`/api/notes/${id}/attachments/${attId}`)); } + async function importNotes(file: File): Promise<{ source: string; imported: number; skipped: number }> { + const form = new FormData(); + form.append("file", file); + const resp = await fetch("/api/notes/import", { method: "POST", credentials: "include", body: form }); + const data: unknown = await resp.json().catch(() => ({})); + if (!resp.ok) { + const message = + typeof data === "object" && data !== null && "error" in data + ? String((data as { error: unknown }).error) + : "Import failed."; + throw { error: message, status: resp.status }; + } + // Refresh the current lens so imported notes appear (labels reloaded by caller). + await load(view.value, activeLabel.value); + return data as { source: string; imported: number; skipped: number }; + } + async function fetchOne(id: string): Promise { try { return await api.get(`/api/notes/${id}`); @@ -242,6 +259,7 @@ export const useNotesStore = defineStore("notes", () => { deleteItem, uploadAttachment, deleteAttachment, + importNotes, fetchOne, createTitled, reorder, diff --git a/src/thoughtsync/notes.py b/src/thoughtsync/notes.py index 80c268f..efc9d68 100644 --- a/src/thoughtsync/notes.py +++ b/src/thoughtsync/notes.py @@ -3,6 +3,7 @@ from __future__ import annotations import io import json import os +import posixpath import re import uuid import zipfile @@ -474,6 +475,291 @@ async def export_notes(): ) +# --- Import: ThoughtSync's own export (round-trip) OR a Google Keep Takeout zip --- + +# Google Keep (Takeout) color enum → our palette. Keep has a few hues we don't +# (BROWN/DARKBLUE/CERULEAN); map each to the nearest. Unknowns fall back to default. +_KEEP_COLOR_MAP = { + "DEFAULT": "default", + "RED": "red", + "ORANGE": "orange", + "YELLOW": "yellow", + "GREEN": "green", + "TEAL": "teal", + "CERULEAN": "teal", + "BLUE": "blue", + "DARKBLUE": "blue", + "PURPLE": "purple", + "PINK": "pink", + "BROWN": "orange", + "GRAY": "gray", +} + +# Reverse of ALLOWED_IMAGE_MIMES, for inferring an attachment's mime from its +# filename when the source didn't record one (Keep usually does; be defensive). +_EXT_MIME = {ext: mime for mime, ext in ALLOWED_IMAGE_MIMES.items()} +_EXT_MIME[".jpeg"] = "image/jpeg" + + +def _usec_to_dt(usec: object) -> datetime | None: + """Google Keep timestamps are integer MICROseconds since the Unix epoch (UTC).""" + try: + return datetime.fromtimestamp(int(usec) / 1_000_000, tz=timezone.utc) + except (TypeError, ValueError, OverflowError, OSError): + return None + + +def _iso_to_dt(raw: object) -> datetime | None: + if not isinstance(raw, str) or not raw: + return None + try: + return _parse_iso_dt(raw) + except ValueError: + return None + + +def _native_spec(n: dict) -> dict: + """Normalize one note from a ThoughtSync export's notes.json into the common + import spec consumed by _create_imported_note.""" + return { + "title": n.get("title"), + "body": n.get("body") or "", + "kind": n.get("kind"), + "color": n.get("color"), + "pinned": bool(n.get("pinned")), + "archived": bool(n.get("archived")), + "trashed": False, # export only includes live notes + "remind_at": _iso_to_dt(n.get("remind_at")), + "created_at": _iso_to_dt(n.get("created_at")), + "updated_at": _iso_to_dt(n.get("updated_at")), + "labels": [s for s in (n.get("labels") or []) if isinstance(s, str)], + "items": [ + {"text": it.get("text"), "checked": bool(it.get("checked"))} + for it in (n.get("items") or []) + if isinstance(it, dict) + ], + # export writes attachments[].file as the zip-internal path already. + "attachments": [ + {"file": a.get("file"), "mime": a.get("mime")} + for a in (n.get("attachments") or []) + if isinstance(a, dict) and a.get("file") + ], + } + + +def _keep_spec(kn: dict, keep_dir: str) -> dict: + """Normalize one Google Keep note (Takeout .json) into the common import + spec. `keep_dir` is the note JSON's folder, used to resolve attachment paths.""" + list_content = kn.get("listContent") if isinstance(kn.get("listContent"), list) else [] + is_list = bool(list_content) + body = kn.get("textContent") or "" if not is_list else "" + # Keep stores link annotations (e.g. shared URLs) separately from the text — + # fold any URLs into the body so the content survives the move. + urls = [ + ann.get("url") + for ann in (kn.get("annotations") or []) + if isinstance(ann, dict) and ann.get("url") + ] + extra = "\n".join(u for u in urls if u and u not in body) + if extra: + body = f"{body}\n\n{extra}" if body.strip() else extra + + attachments = [] + for a in kn.get("attachments") or []: + if not isinstance(a, dict): + continue + fp = a.get("filePath") + if not fp: + continue + zpath = posixpath.join(keep_dir, fp) if keep_dir else fp + mime = a.get("mimetype") or _EXT_MIME.get(posixpath.splitext(fp)[1].lower()) + attachments.append({"file": zpath, "mime": mime}) + + return { + "title": kn.get("title"), + "body": body, + "kind": "list" if is_list else "text", + "color": _KEEP_COLOR_MAP.get(str(kn.get("color") or "DEFAULT").upper(), "default"), + "pinned": bool(kn.get("isPinned")), + "archived": bool(kn.get("isArchived")), + "trashed": bool(kn.get("isTrashed")), + "remind_at": None, # Keep reminders aren't in Takeout note JSON + "created_at": _usec_to_dt(kn.get("createdTimestampUsec")), + "updated_at": _usec_to_dt(kn.get("userEditedTimestampUsec")), + "labels": [ + lb.get("name") + for lb in (kn.get("labels") or []) + if isinstance(lb, dict) and lb.get("name") + ], + "items": [ + {"text": li.get("text"), "checked": bool(li.get("isChecked"))} + for li in list_content + if isinstance(li, dict) + ], + "attachments": attachments, + } + + +def _read_import_specs(zf: zipfile.ZipFile) -> tuple[list[dict], str]: + """Detect the archive format and return (specs, source). A ThoughtSync export + is recognized by its notes.json (app == thoughtsync); otherwise each Keep-shaped + .json is imported. Returns ([], "") when nothing importable is found.""" + names = zf.namelist() + for name in names: + if posixpath.basename(name) == "notes.json": + try: + doc = json.loads(zf.read(name)) + except (ValueError, KeyError): + continue + if isinstance(doc, dict) and doc.get("app") == "thoughtsync": + specs = [_native_spec(n) for n in (doc.get("notes") or []) if isinstance(n, dict)] + return specs, "thoughtsync" + + keep_specs: list[dict] = [] + keep_keys = ("textContent", "listContent", "isPinned", "isArchived", "isTrashed", "userEditedTimestampUsec") + for name in names: + if not name.lower().endswith(".json") or posixpath.basename(name) == "notes.json": + continue + try: + kn = json.loads(zf.read(name)) + except (ValueError, KeyError): + continue + if isinstance(kn, dict) and any(k in kn for k in keep_keys): + keep_specs.append(_keep_spec(kn, posixpath.dirname(name))) + return (keep_specs, "keep") if keep_specs else ([], "") + + +def _import_attachment(db, note: Note, zf: zipfile.ZipFile, att: dict) -> bool: + """Copy one image attachment out of the zip into media storage and record it. + Non-image types (e.g. Keep audio memos) are skipped until any-file attachments + land. Returns True if written.""" + mime = (att.get("mime") or "").split(";")[0].strip().lower() + ext = ALLOWED_IMAGE_MIMES.get(mime) + zpath = att.get("file") + if ext is None or not zpath: + return False + try: + raw = zf.read(zpath) + except KeyError: + return False + att_id = uuid.uuid4() + rel = os.path.join(str(note.id), f"{att_id}{ext}") + dest = Config.media_root() / rel + dest.parent.mkdir(parents=True, exist_ok=True) + dest.write_bytes(raw) + db.add(NoteAttachment(id=att_id, note_id=note.id, path=rel, mime=mime, size=len(raw))) + return True + + +async def _create_imported_note(db, owner_id, spec: dict, zf: zipfile.ZipFile, position: int) -> bool: + """Insert one imported note plus its items/labels/attachments, reusing the same + display-title derivation + tag/link reconciliation as create_note. Returns False + (nothing written) when the spec is empty.""" + title = (spec.get("title") or "").strip() or None + body = spec.get("body") or "" + kind = spec.get("kind") if spec.get("kind") in ("text", "list") else "text" + items = spec.get("items") or [] + if kind == "list": + if not (title or any((it.get("text") or "").strip() for it in items)): + return False + elif is_empty_note(title, body): + return False + + note = Note( + owner_id=owner_id, + title=title, + display_title=derive_display_title(title, body), + body=body, + kind=kind, + color=normalize_color(spec.get("color")), + pinned=bool(spec.get("pinned")), + archived=bool(spec.get("archived")), + position=position, + ) + if spec.get("remind_at"): + note.remind_at = spec["remind_at"] + if spec.get("trashed"): + note.deleted_at = datetime.now(timezone.utc) + # Preserve source timestamps: set before flush so they land in the INSERT + # (updated_at's onupdate only fires on later UPDATEs, which we don't trigger). + if spec.get("created_at"): + note.created_at = spec["created_at"] + if spec.get("updated_at"): + note.updated_at = spec["updated_at"] + db.add(note) + await db.flush() # assign note.id before items/labels/attachments/links + + if kind == "list": + for pos, it in enumerate(items): + text = (it.get("text") or "").strip() + if text: + db.add(NoteItem(note_id=note.id, text=text, checked=bool(it.get("checked")), position=pos)) + + # Explicit (picker-style) labels are manual — via_tag=False. Inline #tags in the + # body are handled by _reconcile_tags below, same as a normal create. + for name in spec.get("labels") or []: + name = (name or "").strip() + if not name: + continue + lid = await _find_or_create_label(db, owner_id, name) + exists = await db.scalar( + select(NoteLabel).where(NoteLabel.note_id == note.id, NoteLabel.label_id == lid) + ) + if exists is None: + db.add(NoteLabel(note_id=note.id, label_id=lid, via_tag=False)) + + for att in spec.get("attachments") or []: + if isinstance(att, dict): + _import_attachment(db, note, zf, att) + + await _rewrite_links(db, note) + await _reconcile_tags(db, note) + return True + + +@bp.post("/import") +@login_required +async def import_notes(): + """Import notes from an uploaded zip — either a ThoughtSync export (round-trip) + or a Google Keep Takeout archive. Additive: imported notes are appended, never + overwriting existing ones. Returns a per-run summary.""" + files = await request.files + upload = files.get("file") + if upload is None: + return jsonify({"error": "no file provided"}), 400 + raw = upload.stream.read() + if not raw: + return jsonify({"error": "empty upload"}), 400 + try: + zf = zipfile.ZipFile(io.BytesIO(raw)) + except zipfile.BadZipFile: + return jsonify({"error": "that file isn't a valid .zip archive"}), 400 + + specs, source = _read_import_specs(zf) + if not specs: + return jsonify( + {"error": "no importable notes found — expected a ThoughtSync export or a Google Keep Takeout zip"} + ), 400 + + imported = 0 + skipped = 0 + async with session_scope() as db: + max_pos = await db.scalar( + select(func.coalesce(func.max(Note.position), 0)).where( + Note.owner_id == g.user_id, Note.deleted_at.is_(None) + ) + ) + pos = int(max_pos) + for spec in specs: + if await _create_imported_note(db, g.user_id, spec, zf, pos + 1): + pos += 1 + imported += 1 + else: + skipped += 1 + await db.commit() + return jsonify({"source": source, "imported": imported, "skipped": skipped}), 201 + + @bp.get("/titles") @login_required async def list_titles(): diff --git a/tests/test_notes.py b/tests/test_notes.py index edce952..33ece70 100644 --- a/tests/test_notes.py +++ b/tests/test_notes.py @@ -4,8 +4,11 @@ from thoughtsync.app import create_app from thoughtsync.models.note import NOTE_COLORS, Note from thoughtsync.notes import ( _escape_like, + _keep_spec, + _native_spec, _parse_iso_dt, _slugify, + _usec_to_dt, derive_display_title, is_empty_note, normalize_color, @@ -223,3 +226,80 @@ async def test_restore_revision_requires_auth(app): "/api/notes/00000000-0000-0000-0000-000000000000/revisions/00000000-0000-0000-0000-000000000001/restore" ) assert resp.status_code == 401 + + +async def test_import_requires_auth(app): + client = app.test_client() + resp = await client.post("/api/notes/import") + assert resp.status_code == 401 + + +def test_usec_to_dt(): + # Google Keep timestamps are microseconds since the epoch (UTC). + d = _usec_to_dt(1600000000000000) + assert d is not None and d.year == 2020 and d.tzinfo is not None + # garbage / missing → None (the note still imports, just without the timestamp) + assert _usec_to_dt("nope") is None + assert _usec_to_dt(None) is None + + +def test_keep_spec_list_note(): + kn = { + "title": "Groceries", + "listContent": [{"text": "Milk", "isChecked": False}, {"text": "Eggs", "isChecked": True}], + "labels": [{"name": "shopping"}], + "color": "TEAL", + "isPinned": True, + "isArchived": False, + "isTrashed": False, + "createdTimestampUsec": 1600000000000000, + "userEditedTimestampUsec": 1600000100000000, + } + spec = _keep_spec(kn, "Takeout/Keep") + assert spec["kind"] == "list" + assert spec["color"] == "teal" + assert spec["pinned"] is True + assert spec["archived"] is False + assert spec["trashed"] is False + assert spec["items"] == [{"text": "Milk", "checked": False}, {"text": "Eggs", "checked": True}] + assert spec["labels"] == ["shopping"] + assert spec["created_at"].year == 2020 + + +def test_keep_spec_text_note_folds_annotation_urls_and_maps_color(): + kn = { + "textContent": "Read this later", + "annotations": [{"url": "https://example.com"}], + "color": "BROWN", # no brown in our palette → nearest (orange) + "attachments": [{"filePath": "img.jpg", "mimetype": "image/jpeg"}], + } + spec = _keep_spec(kn, "Takeout/Keep") + assert spec["kind"] == "text" + assert "https://example.com" in spec["body"] + assert spec["color"] == "orange" + # attachment path is resolved relative to the note JSON's folder + assert spec["attachments"] == [{"file": "Takeout/Keep/img.jpg", "mime": "image/jpeg"}] + + +def test_native_spec_roundtrip_fields(): + n = { + "title": "T", + "body": "b", + "color": "blue", + "kind": "text", + "pinned": True, + "archived": False, + "created_at": "2026-07-19T00:00:00+00:00", + "labels": ["x"], + "items": [], + "attachments": [{"file": "attachments/ab/img.png", "mime": "image/png"}], + } + spec = _native_spec(n) + assert spec["title"] == "T" + assert spec["body"] == "b" + assert spec["color"] == "blue" + assert spec["pinned"] is True + assert spec["trashed"] is False # exports only carry live notes + assert spec["created_at"].year == 2026 + assert spec["labels"] == ["x"] + assert spec["attachments"] == [{"file": "attachments/ab/img.png", "mime": "image/png"}]