Files
apple-notes-cli/README.md
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

6.0 KiB

apple-notes-cli

Search, read, and export Apple Notes from the terminal by reading the local Notes database directly — no AppleScript, no UI automation, no cloud API.

$ apple-notes search "kubernetes" -n 3
   4821  2026-09-08 14:02   Work/Infra/Cluster upgrade notes  (7x)
         …drain the node before the kubernetes control plane restarts…
   3907  2026-08-30 09:15   Work/Runbooks/On-call  (2x)
         …kubernetes events are the fastest way to see why a pod is pending…

$ apple-notes recent --since 2d
   4830  2026-09-09 15:59   Work/Meeting notes
   4829  2026-09-09 13:54   Personal/Reading list

Why not AppleScript

The usual approach is osascript against Notes.app. It works, but the cost lands squarely on search. Measured on a real 818-note store:

Operation AppleScript apple-notes-cli
Enumerate folders and count notes 1.85 s —
Fetch one note body 0.325 s —
Full-text search every note ~266 s (extrapolated) 0.11 s

first note whose id is "…" is a linear scan inside Notes.app with no index behind it, so reading every body costs a third of a second each. That is why AppleScript-based tools either search titles only or cache aggressively and serve you stale results. Reading the SQLite store sidesteps the whole problem: one query, then in-memory decoding of every body.

Install

Requires macOS and Python 3.10+.

uv tool install git+https://github.com/ingjieye/apple-notes-cli
# or
pipx install git+https://github.com/ingjieye/apple-notes-cli

Run it straight from a clone without installing:

git clone https://github.com/ingjieye/apple-notes-cli && cd apple-notes-cli
uv run python -m apple_notes_cli recent

macOS may ask you to grant your terminal access to the Notes container the first time. Nothing here needs Full Disk Access on its own.

Commands

apple-notes search <query>   full-text search across every note body
apple-notes recent           list recently modified notes
apple-notes show <id>        print one note as Markdown
apple-notes folders          list folders with note counts
apple-notes export <dir>     export everything to Markdown files
apple-notes search "OpenTelemetry"
apple-notes search "TODO|FIXME" --regex
apple-notes search "invoice" --folder Finance --since 1w
apple-notes search "postgres" --json | jq -r '.[].title'
apple-notes search "draft" --body-only --no-snippets

Results rank by match count, then by recency. Titles are searched alongside bodies unless you pass --body-only. --json emits structured records, which is the useful shape for piping into an LLM or another tool.

recent

apple-notes recent -n 50
apple-notes recent --since 6h --folder Work

Metadata only — bodies are never inflated, so this stays fast regardless of store size.

export

apple-notes export ~/Downloads/notes
apple-notes export ~/Downloads/notes --incremental

One Markdown file per note, mirroring your folder tree, with YAML frontmatter (title, ids, dates, folder, links, attachments, and a notes:// link back to the note). Attachments are copied next to the Markdown under attachments/<id>/. --incremental keeps a manifest so unchanged notes are skipped and exports of deleted notes are pruned.

How it works

NoteStore.sqlite
  └── ZICNOTEDATA.ZDATA         gzip → protobuf → text runs → Markdown
  └── ZICCLOUDSYNCINGOBJECT     titles, dates, folder tree, attachments
Module Role
store.py read-only SQLite access, folder tree, attachments
parser.py gzip + protobuf decoding, Markdown rendering
search.py search, recency listing, single-note reads
export.py Markdown output and the incremental manifest

Three details are worth knowing if you build something similar:

Open the database with mode=ro, never immutable=1. The immutable flag makes SQLite skip the -wal file, which silently serves a stale snapshot missing your most recent notes. On a live store the -wal file routinely holds megabytes of un-checkpointed data. WAL mode allows concurrent readers, so mode=ro is safe while Notes.app is running and never blocks it. Export uses SQLite's backup() API instead, for a stable point-in-time copy.

There is no FTS index. The schema has no full-text tables, and bodies are gzipped protobuf blobs, so WHERE body LIKE … is not available. Search means inflating every candidate body and matching in memory. At ~800 notes that is about 30 ms, so this tool does no caching and always reflects the live store.

Recently Deleted is ZFOLDERTYPE = 1. Notes in the trash keep ZMARKEDFORDELETION = 0, so filtering on that column alone still surfaces them. Matching on the folder type is also language-independent, unlike comparing against a localized "Recently Deleted" string. They are excluded by default; pass --include-trashed to include them.

Exit codes

search follows the grep convention: 0 when something matched, 1 when nothing did, 2 on a bad argument or an unreadable database. That makes it usable in a conditional:

if apple-notes search "release checklist" --no-snippets >/dev/null; then
  echo "found it"
fi

Reading is all it does

This tool only reads. It never writes to NoteStore.sqlite — modifying the store directly would corrupt CloudKit's sync state and risk losing notes. If you need to create or edit notes, drive Notes.app through AppleScript for the write path and use this for reading.

Password-protected notes are listed but their bodies stay encrypted and unreadable; they are skipped unless you pass --include-password-protected.

Development

uv run python -m unittest discover -s tests

tests/test_parity.py is the original single-file exporter's suite, kept unchanged apart from neutralized example URLs, so it guards the protobuf decoding against regressions. tests/test_search.py builds a synthetic Core Data store to cover the query layer, trash handling, and time filters.

License

MIT