Files
apple-notes-cli/tests/test_search.py
T
ingjieyeandClaude Opus 5 a8615b3c45 Read Apple Notes directly from its SQLite store
Splits the original single-file exporter into a package and adds a query
layer, so notes can be searched and read live instead of only exported.

Reading the Core Data store rather than driving Notes.app through AppleScript
is what makes full-text search practical. Measured on an 818-note store,
osascript needs ~0.325s per note body (`first note whose id is` is an
unindexed linear scan), so searching everything costs minutes; decoding every
gzipped protobuf body here takes 0.11s total. No cache, so results are always
current.

- store.py   read-only SQLite access, folder tree, attachments
- parser.py  gzip + protobuf decoding and Markdown rendering (moved verbatim)
- search.py  search, recency listing, single-note reads
- export.py  Markdown output and the incremental manifest

Three correctness details found while building this:

- Open with mode=ro, never immutable=1. The immutable flag makes SQLite skip
  the -wal file and silently serve a stale snapshot; the live store routinely
  carries megabytes of un-checkpointed WAL.
- Recently Deleted is ZFOLDERTYPE = 1, and notes in it keep
  ZMARKEDFORDELETION = 0, so the existing filter still surfaced them. Matching
  the folder type is also language-independent. Now excluded by default.
- Creation timestamps live in whichever of ZCREATIONDATE..3 is populated, so
  every candidate column is read.

Export no longer writes a CLAUDE.md into the output directory unless asked
with --write-claude-md.

tests/test_parity.py keeps the original suite byte-for-byte as a regression
check on the split; test_search.py covers the new query layer against a
synthetic store. 48 tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 16:38:00 +08:00

252 lines
8.6 KiB
Python

"""Tests for the query layer, built on a synthetic Core Data store."""
from __future__ import annotations
import datetime as dt
import gzip
import sqlite3
import tempfile
import unittest
from pathlib import Path
from apple_notes_cli import search as search_mod
from apple_notes_cli.store import trashed_folder_pks
APPLE_EPOCH_OFFSET = 978307200.0
def varint(value: int) -> bytes:
out = bytearray()
while True:
byte = value & 0x7F
value >>= 7
if value:
out.append(byte | 0x80)
else:
out.append(byte)
return bytes(out)
def field(number: int, wire_type: int, payload: bytes | int) -> bytes:
key = varint((number << 3) | wire_type)
if wire_type == 0:
return key + varint(payload)
return key + varint(len(payload)) + payload
def note_payload(text: str) -> bytes:
content = field(2, 2, text.encode("utf-8"))
document = field(1, 0, 0) + field(2, 0, 0) + field(3, 2, content)
return gzip.compress(field(1, 0, 0) + field(2, 2, document))
def apple_time(moment: dt.datetime) -> float:
return moment.timestamp() - APPLE_EPOCH_OFFSET
SCHEMA = """
CREATE TABLE ZICCLOUDSYNCINGOBJECT (
Z_PK INTEGER PRIMARY KEY,
ZTITLE1 VARCHAR,
ZTITLE2 VARCHAR,
ZFOLDER INTEGER,
ZPARENT INTEGER,
ZFOLDERTYPE INTEGER,
ZMARKEDFORDELETION INTEGER,
ZISPASSWORDPROTECTED INTEGER,
ZIDENTIFIER VARCHAR,
ZCREATIONDATE TIMESTAMP,
ZCREATIONDATE1 TIMESTAMP,
ZCREATIONDATE2 TIMESTAMP,
ZCREATIONDATE3 TIMESTAMP,
ZMODIFICATIONDATE1 TIMESTAMP,
ZNOTEDATA INTEGER
);
CREATE TABLE ZICNOTEDATA (
Z_PK INTEGER PRIMARY KEY,
ZNOTE INTEGER,
ZDATA BLOB
);
"""
class QueryLayerTests(unittest.TestCase):
def setUp(self) -> None:
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
self.db_path = Path(self.tmp.name) / "NoteStore.sqlite"
self.conn = sqlite3.connect(self.db_path)
self.conn.row_factory = sqlite3.Row
self.conn.executescript(SCHEMA)
now = dt.datetime.now(dt.timezone.utc)
# Folders: 10 = Work, 11 = Work/Deep, 20 = trash, 21 = a folder nested
# inside the trash (Notes does not create these itself, but the parent
# walk has to survive one).
self._folder(10, "Work", None, 0)
self._folder(11, "Deep", 10, 0)
self._folder(20, "Recently Deleted", None, 1)
self._folder(21, "Nested", 20, 0)
self._note(1, "Alpha", 10, "telemetry is here", now - dt.timedelta(hours=1))
self._note(2, "Beta", 11, "telemetry telemetry twice", now - dt.timedelta(days=5))
self._note(3, "Gamma", 10, "nothing to see", now - dt.timedelta(minutes=10))
self._note(4, "Trashed", 20, "telemetry in the bin", now - dt.timedelta(minutes=5))
self._note(5, "NestedTrash", 21, "telemetry nested", now - dt.timedelta(minutes=5))
self._note(6, "Locked", 10, "telemetry locked", now, protected=1)
self._note(7, "Deleted", 10, "telemetry deleted", now, marked=1)
self.conn.commit()
def _folder(self, pk: int, title: str, parent: int | None, folder_type: int) -> None:
self.conn.execute(
"INSERT INTO ZICCLOUDSYNCINGOBJECT (Z_PK, ZTITLE2, ZPARENT, ZFOLDERTYPE)"
" VALUES (?, ?, ?, ?)",
(pk, title, parent, folder_type),
)
def _note(
self,
pk: int,
title: str,
folder: int,
body: str,
modified: dt.datetime,
*,
protected: int = 0,
marked: int = 0,
) -> None:
note_pk = 1000 + pk
self.conn.execute(
"INSERT INTO ZICCLOUDSYNCINGOBJECT (Z_PK, ZTITLE1, ZFOLDER, ZMARKEDFORDELETION,"
" ZISPASSWORDPROTECTED, ZIDENTIFIER, ZCREATIONDATE1, ZMODIFICATIONDATE1, ZNOTEDATA)"
" VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
(
note_pk,
title,
folder,
marked,
protected,
f"uuid-{pk}",
apple_time(modified),
apple_time(modified),
pk,
),
)
self.conn.execute(
"INSERT INTO ZICNOTEDATA (Z_PK, ZNOTE, ZDATA) VALUES (?, ?, ?)",
(pk, note_pk, note_payload(body)),
)
# --- trash handling -------------------------------------------------
def test_trashed_folder_pks_includes_nested_folders(self):
self.assertEqual(trashed_folder_pks(self.conn), {20, 21})
def test_search_excludes_trashed_notes_by_default(self):
titles = {hit.note.title for hit in search_mod.search(self.conn, "telemetry")}
self.assertEqual(titles, {"Alpha", "Beta"})
def test_search_can_opt_into_trashed_notes(self):
titles = {
hit.note.title
for hit in search_mod.search(self.conn, "telemetry", include_trashed=True)
}
self.assertEqual(titles, {"Alpha", "Beta", "Trashed", "NestedTrash"})
def test_recent_excludes_trashed_notes(self):
self.assertEqual(
[n.title for n in search_mod.recent(self.conn)], ["Gamma", "Alpha", "Beta"]
)
def test_locked_and_deleted_notes_are_excluded(self):
titles = {
hit.note.title
for hit in search_mod.search(self.conn, "telemetry", include_trashed=True)
}
self.assertNotIn("Locked", titles)
self.assertNotIn("Deleted", titles)
# --- search behaviour -----------------------------------------------
def test_hits_rank_by_match_count(self):
hits = search_mod.search(self.conn, "telemetry")
self.assertEqual([h.note.title for h in hits], ["Beta", "Alpha"])
self.assertEqual([h.match_count for h in hits], [2, 1])
def test_title_is_searched_unless_body_only(self):
self.assertEqual(len(search_mod.search(self.conn, "Gamma")), 1)
self.assertEqual(
len(search_mod.search(self.conn, "Gamma", search_titles=False)), 0
)
def test_regex_search(self):
hits = search_mod.search(self.conn, r"tele\w+try", regex=True)
self.assertEqual({h.note.title for h in hits}, {"Alpha", "Beta"})
def test_plain_query_is_not_treated_as_regex(self):
self.assertEqual(search_mod.search(self.conn, "tele.metry"), [])
def test_case_sensitivity(self):
self.assertEqual(len(search_mod.search(self.conn, "TELEMETRY")), 2)
self.assertEqual(
len(search_mod.search(self.conn, "TELEMETRY", case_sensitive=True)), 0
)
def test_since_filters_older_notes(self):
since = dt.datetime.now(dt.timezone.utc) - dt.timedelta(days=1)
titles = {h.note.title for h in search_mod.search(self.conn, "telemetry", since=since)}
self.assertEqual(titles, {"Alpha"})
def test_folder_filter_matches_nested_paths(self):
hits = search_mod.search(self.conn, "telemetry", folder="Work/Deep")
self.assertEqual([h.note.title for h in hits], ["Beta"])
def test_snippets_carry_context_and_respect_cap(self):
hit = search_mod.search(self.conn, "telemetry", max_snippets=1)[0]
self.assertEqual(len(hit.snippets), 1)
self.assertIn("telemetry", hit.snippets[0])
# --- folders and show ------------------------------------------------
def test_folders_reports_counts_without_trash(self):
self.assertEqual(
search_mod.folders(self.conn), [("Work", 3), ("Work/Deep", 1)]
)
def test_show_returns_body_and_metadata(self):
note, body = search_mod.show(self.conn, 1001)
self.assertEqual(note.title, "Alpha")
self.assertEqual(note.folder, "Work")
self.assertIn("telemetry", body)
def test_show_raises_for_unknown_id(self):
with self.assertRaises(KeyError):
search_mod.show(self.conn, 999999)
class ParseSinceTests(unittest.TestCase):
def test_relative_offsets(self):
now = dt.datetime.now(dt.timezone.utc)
self.assertAlmostEqual(
(now - search_mod.parse_since("2d")).total_seconds(), 172800, delta=5
)
self.assertAlmostEqual(
(now - search_mod.parse_since("30m")).total_seconds(), 1800, delta=5
)
self.assertAlmostEqual(
(now - search_mod.parse_since("1w")).total_seconds(), 604800, delta=5
)
def test_iso_date_is_localized(self):
parsed = search_mod.parse_since("2026-09-01")
self.assertEqual((parsed.year, parsed.month, parsed.day), (2026, 9, 1))
self.assertIsNotNone(parsed.tzinfo)
def test_invalid_value_raises_value_error(self):
with self.assertRaises(ValueError):
search_mod.parse_since("last tuesday")
if __name__ == "__main__":
unittest.main()