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"}]