import io import json import zipfile from datetime import datetime, timezone import pytest from inkwell.common import coerce_bool, parse_dt from inkwell.colors import NOTE_COLORS from inkwell.models.note import Note from inkwell.notes.checklist import append_item, parse_items, set_item_checked, strip_marker from inkwell.unfurl_queue import detect_urls from inkwell.colors import normalize_color from inkwell.notes.helpers import ( _attachment_ext, _header_filename, _safe_filename, _slugify, derive_display_title, is_empty_note, parse_list_items, ) from inkwell.notes.import_export import ( NATIVE_APP_MARKERS, _ImportBudget, _keep_spec, _native_spec, _note_markdown, _read_import_specs, _usec_to_dt, ) from inkwell.notes.recurrence import next_occurrence, normalize_recurrence from inkwell.notes.tags import parse_tags, split_body_tags def test_all_note_routes_registered(app): # Guards the notes package split: every route handler must still be attached to the # blueprint. A route whose module isn't imported by notes/__init__ would silently # 404 at runtime, and most routes have no auth-guard test to otherwise catch it. registered = {r.endpoint for r in app.url_map.iter_rules()} expected = { f"notes.{name}" for name in ( "list_notes", "list_reminders", "complete_reminder", "snooze_reminder", "export_notes", "import_notes", "list_titles", "reorder_notes", "create_note", "get_note", "update_note", "list_revisions", "restore_revision", "set_note_labels", "update_item", "upload_attachment", "get_attachment", "delete_attachment", "delete_preview", "trash_note", "restore_note", "delete_note", ) } assert expected <= registered, f"unregistered note routes: {expected - registered}" # Items are edited in the body, previews arrive after a save, and there are no # saved views. gone = {"notes.add_item", "notes.delete_item", "notes.unfurl_link", "saved_filters.list_saved"} assert not gone & registered, f"removed note routes are back: {gone & registered}" def test_is_empty_note(): assert is_empty_note(None, None) assert is_empty_note(" ", []) assert not is_empty_note("body") # A note that is only a checklist is not empty — it just has nothing in its body. assert not is_empty_note("", ["milk"]) def test_normalize_color(): assert normalize_color("blue") == "blue" assert normalize_color("chartreuse") == "default" assert normalize_color(None) == "default" assert normalize_color(123) == "default" def test_palette_has_core_colors(): # A label's vocabulary; a note has no colour. for c in ("default", "red", "orange", "yellow", "green", "teal", "blue", "purple", "pink", "gray"): assert c in NOTE_COLORS def test_serialize_shape(): n = Note(body="b", pinned=True, archived=False) s = n.serialize() assert "title" not in s # a note's name is its first line, not a field assert s["body"] == "b" assert s["pinned"] is True assert s["archived"] is False assert s["trashed"] is False async def test_notes_list_requires_auth(app): client = app.test_client() resp = await client.get("/api/notes") assert resp.status_code == 401 async def test_notes_create_requires_auth(app): client = app.test_client() resp = await client.post("/api/notes", json={"body": "hi"}) assert resp.status_code == 401 async def test_search_requires_auth(app): client = app.test_client() resp = await client.get("/api/notes/search?q=hello") assert resp.status_code == 401 async def test_update_item_requires_auth(app): client = app.test_client() resp = await client.patch("/api/notes/00000000-0000-0000-0000-000000000000/items/0", json={"checked": True}) assert resp.status_code == 401 async def test_upload_attachment_requires_auth(app): client = app.test_client() resp = await client.post("/api/notes/00000000-0000-0000-0000-000000000000/attachments") assert resp.status_code == 401 async def test_reorder_requires_auth(app): client = app.test_client() resp = await client.post("/api/notes/reorder", json={"ids": []}) assert resp.status_code == 401 def test_derive_display_title_is_the_first_body_line(): assert derive_display_title("first line\nsecond line") == "first line" assert derive_display_title(" spaced first \nnext") == "spaced first" # leading blank/whitespace lines are skipped to the first line with content assert derive_display_title("\n \nreal line\nmore") == "real line" def test_derive_display_title_names_a_list_only_note(): # A checklist-only note is named by its first item, without the marker. assert derive_display_title("- [ ] milk\n- [ ] eggs") == "milk" assert derive_display_title(" * [x] eggs ") == "eggs" # Prose still wins when it comes first, because it IS the first line. assert derive_display_title("shopping\n\n- [ ] milk") == "shopping" # An EMPTY item does not name the note "" — a half-typed list still has a name. assert derive_display_title("- [ ]\n- [ ] eggs") == "eggs" def test_derive_display_title_empty(): assert derive_display_title(None) == "" assert derive_display_title("") == "" assert derive_display_title(" \n ") == "" # A list of nothing but empty items is still a note with no name. assert derive_display_title("- [ ]\n- [ ]") == "" def test_derive_display_title_caps_length(): long = "x" * 300 assert derive_display_title(long) == "x" * 200 # A first line that happens to be an item is capped on the same rule. assert derive_display_title(f"- [ ] {long}") == "x" * 200 def test_split_body_tags_lifts_only_a_line_that_is_nothing_else(): """The rule, in the cases it exists to get right. A tag on a line of its own is filing and the line can go. A tag sharing a line with words is left as typed, including `buy milk #grocery`: nothing distinguishes it from `remember to call #mom`. """ assert split_body_tags("#todo\nreorganize the homepage") == (["todo"], [], "reorganize the homepage") assert split_body_tags("needs a tauri app\n#todo") == (["todo"], [], "needs a tauri app") assert split_body_tags("#todo #work\nreal text") == (["todo", "work"], [], "real text") unchanged = "remember to call #mom tomorrow" assert split_body_tags(unchanged) == ([], ["mom"], unchanged) trailing = "buy milk #grocery" assert split_body_tags(trailing) == ([], ["grocery"], trailing) def test_split_body_tags_leaves_the_note_readable(): # Removing a line must not leave a hole where it was. assert split_body_tags("foo\n\n#todo\n\nbar") == (["todo"], [], "foo\n\nbar") # A note that is NOTHING but tags would be blanked. A duplicated chip beats an # empty card, so it keeps its text and its tags stay derived. assert split_body_tags("#todo") == ([], ["todo"], "#todo") assert split_body_tags("#todo #work") == ([], ["todo", "work"], "#todo #work") # A tag in a code fence is CODE — a shell comment in somebody's snippet. It still # becomes a label, because it always has, but the line is never touched. fenced = "code:\n```\n#!/bin/sh\n#deploy\n```\ndone" assert split_body_tags(fenced) == ([], ["deploy"], fenced) # Not a tag at all (no letter), so not a tag-only line either. assert split_body_tags("#2024\nreal") == ([], [], "#2024\nreal") assert split_body_tags("") == ([], [], "") assert split_body_tags(None) == ([], [], "") def test_split_body_tags_keeps_a_tag_derived_when_prose_still_carries_it(): """Appearing on its own line does NOT lift a tag that is also written in a sentence — the sentence still backs it, so deleting that sentence should still detach the label. Standalone and inline are not both true of one tag.""" standalone, inline, body = split_body_tags("#todo\nremember the #todo list") assert standalone == [] assert inline == ["todo"] assert body == "remember the #todo list" def test_migration_0028_lifts_exactly_what_the_app_lifts(): """The 0028 data migration rewrites note bodies, and that is not undoable. It carries its own frozen copy of the rule, which may diverge from `split_body_tags` later. This pins the frozen copy against fixed expectations. """ import importlib.util from pathlib import Path path = Path(__file__).resolve().parents[1] / "alembic" / "versions" / "0028_lift_standalone_tags.py" spec = importlib.util.spec_from_file_location("migration_0028", path) mod = importlib.util.module_from_spec(spec) spec.loader.exec_module(mod) # Lifted: the tag was the whole line. assert mod._split("#todo\nreorganize the homepage") == (["todo"], "reorganize the homepage") assert mod._split("needs a tauri app\n#todo") == (["todo"], "needs a tauri app") assert mod._split("#todo #work\nreal text") == (["todo", "work"], "real text") assert mod._split("foo\n\n#todo\n\nbar") == (["todo"], "foo\n\nbar") # Untouched: prose. Getting any of these wrong destroys somebody's words. for prose in ("remember to call #mom tomorrow", "buy milk #grocery", "#2024\nreal"): assert mod._split(prose) == ([], prose), prose # Untouched: a tag inside a fence is a shell comment in somebody's snippet. fenced = "code:\n```\n#!/bin/sh\n#deploy\n```\ndone" assert mod._split(fenced) == ([], fenced) # Untouched: a note that is nothing but tags would be blanked. assert mod._split("#todo") == ([], "#todo") # Not flipped: the tag is still written in prose, so its text still backs it and # it must stay derived — flipping it would be claiming otherwise. assert mod._split("#todo\nremember the #todo list") == ([], "remember the #todo list") # The name has to move with the body, or a note is titled after a deleted line. assert mod._display_title("reorganize the homepage") == "reorganize the homepage" assert mod._display_title("- [ ] milk\n- [ ] eggs") == "milk" assert mod._display_title("") == "" def test_parse_tags(): assert parse_tags("buy milk #groceries and #to-do now") == ["groceries", "to-do"] # case-insensitive dedup, first spelling wins assert parse_tags("#Work then #work") == ["Work"] # url fragments, mid-word #, purely-numeric, and a bare # are not tags assert parse_tags("frag http://x/#nope mid#word #2024 #") == [] assert parse_tags(None) == [] assert parse_tags("#a #b #a") == ["a", "b"] def test_parse_list_items(): assert parse_list_items(["milk", " eggs ", "", " ", "bread"]) == ["milk", "eggs", "bread"] assert parse_list_items("not a list") == [] assert parse_list_items(None) == [] assert parse_list_items([1, "x", None, {"a": 1}]) == ["x"] def test_parse_dt(): # A full ISO instant round-trips (validates the creation-date range). d = parse_dt("2026-07-19T12:30:00+00:00") assert (d.year, d.month, d.day, d.hour, d.minute) == (2026, 7, 19, 12, 30) assert d.tzinfo is not None # a trailing Z is accepted as UTC assert parse_dt("2026-07-19T00:00:00Z").tzinfo is not None # a plain calendar date parses to midnight assert parse_dt("2026-07-19").hour == 0 # garbage / non-strings return None (the endpoint turns this into a 400) assert parse_dt("not-a-date") is None assert parse_dt("") is None assert parse_dt(None) is None async def test_titles_requires_auth(app): client = app.test_client() resp = await client.get("/api/notes/titles") assert resp.status_code == 401 async def test_reminders_requires_auth(app): client = app.test_client() resp = await client.get("/api/notes/reminders") assert resp.status_code == 401 def test_slugify(): assert _slugify("My Great Note!") == "my-great-note" assert _slugify(" spaced / weird __name ") == "spaced-weird-name" assert _slugify("") == "note" # empty falls back assert _slugify("!!!") == "note" # all punctuation strips to empty → fallback assert len(_slugify("x" * 100)) == 60 async def test_export_requires_auth(app): client = app.test_client() resp = await client.get("/api/notes/export") assert resp.status_code == 401 async def test_list_revisions_requires_auth(app): client = app.test_client() resp = await client.get("/api/notes/00000000-0000-0000-0000-000000000000/revisions") assert resp.status_code == 401 async def test_restore_revision_requires_auth(app): client = app.test_client() resp = await client.post( "/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_safe_filename(): assert _safe_filename("report.pdf") == "report.pdf" assert _safe_filename("/etc/passwd") == "passwd" # path components stripped assert _safe_filename("a\\b\\c.doc") == "c.doc" # windows separators too assert _safe_filename("") == "file" # fallback assert _safe_filename(None) == "file" def test_attachment_ext(): assert _attachment_ext("report.pdf", "application/pdf") == ".pdf" assert _attachment_ext("memo.m4a", "audio/mp4") == ".m4a" # no extension in the name → fall back to a known image mime, else empty assert _attachment_ext("noext", "image/png") == ".png" assert _attachment_ext("noext", "application/octet-stream") == "" def test_header_filename(): # Quotes/newlines are stripped so the Content-Disposition header can't be broken. assert _header_filename('a"b\r\n.pdf') == "ab.pdf" assert _header_filename("") == "file" def test_coerce_bool(): assert coerce_bool("true") and coerce_bool("1") and coerce_bool("yes") and coerce_bool("on") assert coerce_bool(True) assert not coerce_bool("false") assert not coerce_bool(None) assert not coerce_bool("") assert not coerce_bool(False) # A bool setting can arrive as a JSON number. assert coerce_bool(1) and not coerce_bool(0) def test_normalize_recurrence(): for v in ("daily", "weekly", "monthly", "yearly"): assert normalize_recurrence(v) == v assert normalize_recurrence("none") is None assert normalize_recurrence("") is None assert normalize_recurrence(None) is None assert normalize_recurrence("hourly") is None def test_next_occurrence_daily_weekly(): base = datetime(2026, 7, 1, 9, 0, tzinfo=timezone.utc) after = datetime(2026, 7, 1, 12, 0, tzinfo=timezone.utc) # same day, later assert next_occurrence(base, "daily", after) == datetime(2026, 7, 2, 9, 0, tzinfo=timezone.utc) assert next_occurrence(base, "weekly", after) == datetime(2026, 7, 8, 9, 0, tzinfo=timezone.utc) def test_next_occurrence_skips_missed(): base = datetime(2026, 7, 1, 9, 0, tzinfo=timezone.utc) after = datetime(2026, 7, 10, 12, 0, tzinfo=timezone.utc) # 9+ days later # Rolls forward past every missed day to the first fire strictly after `after`. assert next_occurrence(base, "daily", after) == datetime(2026, 7, 11, 9, 0, tzinfo=timezone.utc) def test_next_occurrence_monthly_clamps_month_end(): base = datetime(2026, 1, 31, 8, 0, tzinfo=timezone.utc) after = datetime(2026, 2, 1, 0, 0, tzinfo=timezone.utc) # Jan 31 + 1 month → Feb 28 (clamped to the shorter month). assert next_occurrence(base, "monthly", after) == datetime(2026, 2, 28, 8, 0, tzinfo=timezone.utc) def test_next_occurrence_yearly_and_none(): base = datetime(2026, 3, 15, 7, 0, tzinfo=timezone.utc) after = datetime(2026, 3, 16, tzinfo=timezone.utc) assert next_occurrence(base, "yearly", after) == datetime(2027, 3, 15, 7, 0, tzinfo=timezone.utc) assert next_occurrence(base, "none", after) is None async def test_complete_reminder_requires_auth(app): client = app.test_client() resp = await client.post("/api/notes/00000000-0000-0000-0000-000000000000/reminder/complete") assert resp.status_code == 401 async def test_snooze_reminder_requires_auth(app): client = app.test_client() resp = await client.post( "/api/notes/00000000-0000-0000-0000-000000000000/reminder/snooze", json={"minutes": 10} ) 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_keeps_its_text_too(): # A Keep note with both textContent and listContent keeps both. kn = { "title": "Groceries", "textContent": "for the weekend", "listContent": [{"text": "Milk", "isChecked": False}, {"text": "Eggs", "isChecked": True}], "labels": [{"name": "shopping"}], # Keep's own colour, which the importer now reads past: there is nothing on a # note for it to land on, and a spec carrying a key nobody applies is a lie. "color": "TEAL", "isPinned": True, "isArchived": False, "isTrashed": False, "createdTimestampUsec": 1600000000000000, "userEditedTimestampUsec": 1600000100000000, } spec = _keep_spec(kn, "Takeout/Keep") assert spec["body"] == "for the weekend" assert "color" not in spec 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_drops_color(): kn = { "textContent": "Read this later", "annotations": [{"url": "https://example.com"}], "color": "BROWN", "attachments": [{"filePath": "img.jpg", "mimetype": "image/jpeg"}], } spec = _keep_spec(kn, "Takeout/Keep") assert "https://example.com" in spec["body"] # A note has no colour, so a Keep note's colour is not imported. assert "color" not in spec # 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", # Older exports carry a note colour; reading past it keeps them importable. "color": "blue", "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) # The spec carries a title (Keep notes and older exports have one), which # _create_imported_note folds into the body. assert spec["title"] == "T" assert spec["body"] == "b" assert "color" not in spec 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"}] def test_detect_urls_finds_each_link_once_in_order(): body = "see https://example.com/a and https://example.com/b\nand https://example.com/a again" assert detect_urls(body) == ["https://example.com/a", "https://example.com/b"] def test_detect_urls_trims_sentence_punctuation(): # A URL can end in most punctuation; a SENTENCE containing one usually doesn't. assert detect_urls("read https://example.com/page.") == ["https://example.com/page"] assert detect_urls("(see https://example.com/x)") == ["https://example.com/x"] # …but a path that legitimately ends in a slash or a dash keeps it. assert detect_urls("https://example.com/dir/") == ["https://example.com/dir/"] def test_detect_urls_ignores_non_http(): assert detect_urls("ftp://example.com and mailto:a@b.c and bare example.com") == [] assert detect_urls(None) == [] assert detect_urls("") == [] # --- checklist items: the body is the checklist ------------------------------ # # The same cases as core/src/local/derive.rs, duplicated so each implementation is # checked against the grammar. def test_parse_items_reads_a_list_out_of_prose(): body = "shopping\n\n- [ ] milk\n- [x] eggs" assert [(i.text, i.checked) for i in parse_items(body)] == [("milk", False), ("eggs", True)] def test_parse_items_between_paragraphs(): # A list between two paragraphs. assert [i.text for i in parse_items("before\n- [ ] middle\nafter")] == ["middle"] def test_parse_items_accepts_star_bullets_and_indentation(): # `*` because markdown.ts already takes it for a plain bullet. body = "* [ ] star\n - [x] indented" assert [(i.text, i.checked) for i in parse_items(body)] == [("star", False), ("indented", True)] def test_an_empty_item_is_still_an_item(): # What pressing Enter on a list leaves behind. assert [i.text for i in parse_items("- [ ]")] == [""] assert [i.text for i in parse_items("- [ ] ")] == [""] def test_uppercase_x_parses_and_normalises_on_rewrite(): assert parse_items("- [X] done")[0].checked assert set_item_checked("- [X] done", 0, True) == "- [x] done" def test_rewriters_preserve_indent_bullet_and_neighbours(): assert set_item_checked(" * [ ] milk", 0, True) == " * [x] milk" # Addressed by ITEM, not by line. assert set_item_checked("note\n- [ ] a\nprose\n- [ ] b", 1, True) == "note\n- [ ] a\nprose\n- [x] b" def test_a_stale_index_does_nothing(): # The index comes from a client that may be a moment behind. A late request # should be inert, not a 500. body = "- [ ] only" assert set_item_checked(body, 7, True) == body def test_a_plain_body_is_returned_unchanged(): body = "just prose\nwith two lines" assert set_item_checked(body, 0, True) == body def test_append_item_spacing(): # Prose, blank line, list — the layout _note_markdown has always exported, and # what the migration folds existing rows into. assert append_item("a note", "milk") == "a note\n\n- [ ] milk" # Nothing between consecutive items. assert append_item("a note\n\n- [ ] milk", "eggs") == "a note\n\n- [ ] milk\n- [ ] eggs" # A list-only note starts at the first line. assert append_item("", "milk") == "- [ ] milk" assert append_item("\n\n", "milk") == "- [ ] milk" # Carries state, which is what the migrations need of it. assert append_item("", "done", True) == "- [x] done" def test_strip_marker(): assert strip_marker("- [x] milk") == "milk" assert strip_marker("just prose") == "just prose" assert strip_marker("- [ ]") == "" def test_the_migration_folds_exactly_like_the_app(): """0027 inlines its own copy of append_item, frozen; this keeps the two in step while the app's spacing is unchanged.""" import importlib.util from pathlib import Path path = Path(__file__).resolve().parents[1] / "alembic" / "versions" / "0027_checklist_items_into_body.py" spec = importlib.util.spec_from_file_location("_m0027", path) module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) for body, text, checked in [ ("a note", "milk", False), ("a note\n\n- [ ] milk", "eggs", True), ("", "milk", False), ("\n\n", "milk", False), ("prose\n", " padded ", True), ]: assert module._append_item(body, text, checked) == append_item(body, text, checked) # --- import/export: the body already carries the list ------------------------ def test_note_markdown_writes_a_checklist_once(): """The checklist is written once, as part of the body, so a re-import doesn't double it.""" note = Note( display_title="shopping", body="shopping\n\n- [ ] milk\n- [x] eggs", pinned=False, archived=False, ) out = _note_markdown(note, []) assert out.count("- [ ] milk") == 1 assert out.count("- [x] eggs") == 1 # And in place, under the note's own first line rather than in a block of its own. assert out.rstrip().endswith("shopping\n\n- [ ] milk\n- [x] eggs") def test_native_spec_of_a_current_export_folds_nothing(): # Today's export carries no `items` key, because the body has the lines. An empty # list is what stops _insert_note folding them in a second time. assert _native_spec({"body": "a\n\n- [ ] milk"})["items"] == [] def test_native_spec_of_an_export_with_separate_items_folds_them(): # An older export has a separate items array; importing folds it into the body, # as the Keep importer does. old = {"body": "shopping", "items": [{"text": "milk", "checked": True}]} assert _native_spec(old)["items"] == [{"text": "milk", "checked": True}] def _export_zip(app_marker: str) -> zipfile.ZipFile: buf = io.BytesIO() with zipfile.ZipFile(buf, "w") as zf: zf.writestr("notes.json", json.dumps({"app": app_marker, "notes": [{"body": "kept"}]})) return zipfile.ZipFile(io.BytesIO(buf.getvalue())) @pytest.mark.parametrize("marker", ["inkwell", "thoughtsync"]) def test_an_export_imports_under_either_name(marker): # Exports from when the app was called ThoughtSync say "thoughtsync", and must # import as native exports rather than fall through to the Keep importer. specs, source = _read_import_specs(_export_zip(marker), _ImportBudget()) assert source == "inkwell" assert [s["body"] for s in specs] == ["kept"] def test_new_exports_are_written_as_inkwell(): assert NATIVE_APP_MARKERS[0] == "inkwell"