CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 4s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 14s
CI & Build / integration (push) Successful in 19s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 2m28s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m52s
Desktop (Tauri) / Update manifest (push) Skipped
Android / Kotlin + Rust (APK) (push) Failing after 4m1s
Step 3 of M315, and the destructive half. Steps 1 and 2 stopped every read of this field: a card is one neutral surface per theme, and the only coloured thing on a board is a tag. What was left was a column written by a picker and read by nothing. Rule 22 — the old path comes out completely. No flag, no fallback, no "override if set". Server: the column, the `?color=` facet, the create/update/serialise paths, the sync assignment, the front-matter line, and Keep's colour map. Alembic 0029 drops it and sweeps `"color"` out of stored saved-filter params — a view that silently filtered on a field the app no longer has would return nothing and never say why. That sweep is Python, not `params::jsonb - 'color'`, because Postgres has no try-cast and one malformed blob would abort a migration that is running over somebody's saved views. `NOTE_COLORS` moves from `models/note.py` to `colors.py`. A palette defined on the model that lost one is an invitation to put the column back; labels still name a colour, so the vocabulary belongs where the normalizer already is. Core: the field, the facet, the `NoteCreateInput`, and every read and write in store/push/pull. Local schema v9 drops the column and does the same saved-filter sweep, guarded on `json_valid` so a corrupt blob loses a key rather than becoming NULL. The uniffi layer drops `NoteEdit::Color` and `NoteDraft.color` with it. Web: `ColorPicker.vue`, the per-card swatch popover and its stylesheet rule, the FilterBar colour row, the facet in the query round-trip, and the colour half of the editor's baseline-and-save. Android: the `ColorSheet`, the `Picker.COLOR` case, the toolbar's swatch dot, `EditorAction.SetColor`. ## The protocol: v4, and the floor deliberately stays at 3 Checked against `compat.rs` and the push handler rather than trusting the `#[serde(default)]` annotation, because the v2 precedent points the other way: v2 dropped `kind` and `title` and DID raise both floors, on the rule that dropping a field a client sends and expects back is breaking. `color` fails the second half of that test. A v3 client reading a v4 note gets `"default"` from its own serde default and draws the colour it derives locally — the board it drew yesterday. A v3 client pushing `color` has the key ignored, since `_assign_note_fields` reads its payload key by key and never validates the shape. Neither direction errors and neither shows anything wrong. `title` was the note's NAME; this is a field that no longer renders. So `SYNC_PROTOCOL_VERSION` and `CLIENT_PROTOCOL_VERSION` go to 4, and both floors stay at 3. `docs/sync.md` carries the reasoning and the per-version history, and its push example is brought back in line — it still listed `title`, `kind` and `items`, all gone before this. Import stays tolerant: a pre-M315 export or a Keep takeout carrying `color:` imports fine, the key simply read past. Old exports must still import. #3041 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
675 lines
27 KiB
Python
675 lines
27 KiB
Python
from datetime import datetime, timezone
|
|
|
|
import pytest
|
|
|
|
from thoughtsync.app import create_app
|
|
from thoughtsync.common import coerce_bool, parse_dt
|
|
from thoughtsync.colors import NOTE_COLORS
|
|
from thoughtsync.models.note import Note
|
|
from thoughtsync.notes.checklist import (
|
|
append_item,
|
|
parse_items,
|
|
remove_item,
|
|
set_item_checked,
|
|
set_item_text,
|
|
strip_marker,
|
|
)
|
|
from thoughtsync.unfurl_queue import detect_urls
|
|
from thoughtsync.notes import (
|
|
_attachment_ext,
|
|
_header_filename,
|
|
_keep_spec,
|
|
_native_spec,
|
|
_note_markdown,
|
|
_safe_filename,
|
|
_slugify,
|
|
_usec_to_dt,
|
|
derive_display_title,
|
|
is_empty_note,
|
|
next_occurrence,
|
|
normalize_color,
|
|
normalize_recurrence,
|
|
parse_list_items,
|
|
parse_tags,
|
|
)
|
|
from thoughtsync.notes.tags import split_body_tags
|
|
|
|
|
|
@pytest.fixture
|
|
def app():
|
|
return create_app()
|
|
|
|
|
|
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", "add_item", "update_item", "delete_item",
|
|
"upload_attachment", "get_attachment",
|
|
"delete_attachment", "unfurl_link", "delete_preview", "trash_note",
|
|
"restore_note", "delete_note",
|
|
)
|
|
}
|
|
assert expected <= registered, f"unregistered note routes: {expected - 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 since M315 — a note has no colour to be one of these.
|
|
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 # there is no title field any more (M13 step 3)
|
|
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_add_item_requires_auth(app):
|
|
client = app.test_client()
|
|
resp = await client.post("/api/notes/00000000-0000-0000-0000-000000000000/items", json={"text": "x"})
|
|
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
|
|
|
|
|
|
# [[wiki-links]] are gone entirely (note 2897), and with them backlinks, the graph,
|
|
# the name index and the `[[` autocomplete. So are the two helpers that used to keep
|
|
# links alive across a rename, and the id-binding that briefly replaced them. Nothing
|
|
# here asserts their absence — `test_all_note_routes_registered` below is what would
|
|
# notice a route coming back, and the removal is one commit rather than a fossil.
|
|
|
|
|
|
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():
|
|
# The same property the old `first_item` fallback protected — a note that is only
|
|
# a checklist still has a name — reached a different way. Items ARE body lines now
|
|
# (M304), so the first one is simply the first line, with its marker stripped:
|
|
# calling the note "- [ ] milk" would show someone the storage instead of the note.
|
|
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 part of what was written, and taking it out would leave "remember to
|
|
call" — so the line is left exactly as typed. The trailing-tag case (`buy milk
|
|
#grocery`) is deliberately on the conservative side of the line: it reads like
|
|
filing, but nothing in the text distinguishes it from `remember to call #mom`, and
|
|
guessing wrong mangles a sentence to save a duplicate chip.
|
|
"""
|
|
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_today():
|
|
"""The 0028 data migration rewrites note bodies, and that is not undoable.
|
|
|
|
It carries its OWN frozen copy of the rule rather than importing
|
|
`split_body_tags`, on 0027's principle that a migration must keep producing what
|
|
it produced the day it ran. This does not assert the two agree — they are allowed
|
|
to diverge later, which is the entire point of freezing one. It pins the frozen
|
|
copy against fixed expectations, so nobody can "tidy" it into eating prose.
|
|
"""
|
|
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 (used to validate the Timeline 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)
|
|
|
|
|
|
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():
|
|
# Keep's own notes carry one or the other, but its textContent used to be
|
|
# DISCARDED whenever a note also had listContent, because a note could only be
|
|
# one kind. A note holds both now, so nothing is dropped on the way in.
|
|
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"]
|
|
# BROWN used to map to the nearest hue we had. There is no hue to map TO now, so a
|
|
# Keep import brings across everything except the one thing this app stopped having.
|
|
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",
|
|
# An export taken before M315 still carries this. Reading past it rather than
|
|
# rejecting the file is the whole point — old exports must still import.
|
|
"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 still CARRIES a title — an export taken before M13 has one, and
|
|
# _create_imported_note folds it into the body rather than dropping it.
|
|
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 (M304) -----------------------
|
|
#
|
|
# The same table of cases as core/src/local/derive.rs. Deliberately duplicated
|
|
# rather than shared: the point of three implementations is that each is checked
|
|
# against the same grammar, and a test that only ran once would not catch the two
|
|
# drifting apart.
|
|
|
|
|
|
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():
|
|
# The case a side table could not express, which is the whole reason for M304.
|
|
assert [i.text for i in parse_items("before\n- [ ] middle\nafter")] == ["middle"]
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"body",
|
|
[
|
|
"-[ ] no space after the dash",
|
|
"- [] empty brackets",
|
|
"- [ ]no space after the brackets",
|
|
"- [y] not a mark",
|
|
"a [ ] mid sentence",
|
|
"[ ] no bullet at all",
|
|
],
|
|
)
|
|
def test_parse_items_rejects_near_misses(body):
|
|
assert parse_items(body) == []
|
|
|
|
|
|
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"
|
|
assert set_item_text("- [x] old", 0, "new") == "- [x] new"
|
|
assert remove_item("keep\n- [ ] drop\n- [ ] stay", 0) == "keep\n- [ ] stay"
|
|
# 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
|
|
assert remove_item(body, 7) == body
|
|
assert set_item_text(body, 7, "x") == body
|
|
|
|
|
|
def test_a_plain_body_is_returned_unchanged():
|
|
body = "just prose\nwith two lines"
|
|
assert set_item_checked(body, 0, True) == body
|
|
assert remove_item(body, 0) == 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, deliberately — a migration has to keep
|
|
producing what it produced the day it ran, so it must not follow the app if the
|
|
app's spacing ever changes. This is what keeps the copy honest until then."""
|
|
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 export used to append the items after the body. The body IS them now
|
|
(M304), so the old branch would have doubled every checklist in an export — and
|
|
doubled it again on the next re-import."""
|
|
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_a_pre_m304_export_still_carries_its_items():
|
|
# An export taken BEFORE this milestone has a body with no task lines and a
|
|
# separate items array. Importing one has to put the checklist back — which is
|
|
# the same fold the Keep importer does, and the reason _insert_note still accepts
|
|
# items at all.
|
|
old = {"body": "shopping", "items": [{"text": "milk", "checked": True}]}
|
|
assert _native_spec(old)["items"] == [{"text": "milk", "checked": True}]
|