Import: ThoughtSync-native round-trip + Google Keep Takeout
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 8s
CI & Build / Build & push image (push) Successful in 33s

Complete the export/import pair (task 1907). POST /api/notes/import takes
an uploaded .zip and appends its notes — never overwriting existing ones.

Two formats, auto-detected:
- ThoughtSync export: recognized by its notes.json (app == thoughtsync);
  round-trips title/body/color/kind/pinned/archived/remind_at/timestamps/
  labels/items and re-attaches image media from the zip.
- Google Keep Takeout: each Keep <note>.json → a note. Maps title,
  textContent/listContent (+ checked), labels, Keep color enum (nearest
  palette match), isPinned/isArchived, isTrashed (→ trash), created/edited
  microsecond timestamps; folds annotation URLs into the body; resolves
  attachment filePaths relative to the note's folder.

Imported notes reuse create_note's derivation + reconciliation:
display-title derive, #tag reconcile, [[wiki-link]] rewrite. Explicit
labels attach as manual (via_tag=false); inline #tags reconcile as tags.
Image attachments copied into media storage; non-image types (e.g. Keep
audio) skipped until any-file attachments land.

Frontend: an Import control in the sidebar (next to Export) — hidden file
input + FormData POST + result toast ("Imported N notes (M skipped)"),
reloading the board + labels. New upload icon; notes-store importNotes().

Tests: import auth-guard + pure-helper coverage (_usec_to_dt, _keep_spec
list/text/color/annotation/attachment mapping, _native_spec round-trip).

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:
2026-07-22 21:24:02 -04:00
co-authored by Claude Opus 4.8
parent 1417479729
commit 333ab9ce74
6 changed files with 439 additions and 0 deletions
+2
View File
@@ -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() {
<a href="/api/notes/export" download class="nav-link" title="Download all your notes as a zip">
<Icon name="download" /> Export
</a>
<ImportNotes />
</nav>
</aside>
+1
View File
@@ -24,6 +24,7 @@ const paths: Record<string, string> = {
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"/>',
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"/>',
upload: '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" x2="12" y1="3" y2="15"/>',
};
</script>
+52
View File
@@ -0,0 +1,52 @@
<script setup lang="ts">
import { ref } from "vue";
import Icon from "./Icon.vue";
import { useNotesStore } from "../stores/notes";
import { useLabelsStore } from "../stores/labels";
import { useUiStore } from "../stores/ui";
// Sidebar counterpart to Export: pick a .zip (a ThoughtSync export for round-trip,
// or a Google Keep Takeout archive) and import its notes. Additive — never
// overwrites existing notes.
const notes = useNotesStore();
const labels = useLabelsStore();
const ui = useUiStore();
const inputRef = ref<HTMLInputElement | null>(null);
const busy = ref(false);
function pick() {
if (!busy.value) inputRef.value?.click();
}
async function onFile(e: Event) {
const input = e.target as HTMLInputElement;
const file = input.files?.[0];
input.value = ""; // reset so picking the same file again re-fires change
if (!file) return;
busy.value = true;
try {
const res = await notes.importNotes(file);
await labels.load(); // surface any labels the import created
const noun = res.imported === 1 ? "note" : "notes";
const tail = res.skipped ? ` (${res.skipped} skipped)` : "";
ui.showToast(`Imported ${res.imported} ${noun}${tail}.`);
} catch (err) {
ui.showToast((err as { error?: string }).error ?? "Import failed.");
} finally {
busy.value = false;
}
}
</script>
<template>
<button
type="button"
class="nav-link w-full disabled:opacity-60"
:disabled="busy"
title="Import a ThoughtSync export or a Google Keep Takeout zip"
@click="pick"
>
<Icon name="upload" /> {{ busy ? "Importing…" : "Import" }}
</button>
<input ref="inputRef" type="file" accept=".zip,application/zip" class="hidden" @change="onFile" />
</template>
+18
View File
@@ -171,6 +171,23 @@ export const useNotesStore = defineStore("notes", () => {
reconcile(await api.del<Note>(`/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<Note | null> {
try {
return await api.get<Note>(`/api/notes/${id}`);
@@ -242,6 +259,7 @@ export const useNotesStore = defineStore("notes", () => {
deleteItem,
uploadAttachment,
deleteAttachment,
importNotes,
fetchOne,
createTitled,
reorder,
+286
View File
@@ -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 <note>.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
<note>.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():
+80
View File
@@ -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"}]