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:
@@ -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"}]
|
||||
|
||||
Reference in New Issue
Block a user