Import: ThoughtSync-native round-trip + Google Keep Takeout
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:
@@ -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():
|
||||
|
||||
Reference in New Issue
Block a user