bearkit 1.6.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
bearkit/__init__.py ADDED
@@ -0,0 +1,54 @@
1
+ """bearkit: the fundamentals for interacting with the Bear notes app.
2
+
3
+ - `Bear`: the facade and recommended entry point - read notes, write them
4
+ through the Bear app (verified), all from one object.
5
+ - `db` / `BearDB`: the raw read-only database layer (strictly read-only).
6
+ - `actions`: Bear's x-callback-url write API (fire-and-forget URLs).
7
+ - `ops`: verified write operations - fire an action, confirm it via the
8
+ database, return the fresh note, or raise `BearWriteError`.
9
+ - `search`: naive and fuzzy search over notes.
10
+ - `secrets`: offline secret detection and redaction for note text.
11
+ - `markdown`: Bear markdown conventions (attachment links, tag markers).
12
+
13
+ Nothing in this package depends on a UI; bearcli's CLI and TUI are built
14
+ on top of it. macOS only (Bear is a macOS/iOS app).
15
+ """
16
+
17
+ from bearkit import actions, db, markdown, ops, search, secrets
18
+ from bearkit.bear import Bear
19
+ from bearkit.db import (
20
+ DEFAULT_DB_PATH,
21
+ AmbiguousNoteId,
22
+ Attachment,
23
+ BearDB,
24
+ Note,
25
+ NoteFilter,
26
+ )
27
+ from bearkit.ops import BearWriteError, TagMarkerNotFound, TextMode
28
+ from bearkit.search import SearchResult, naive_search, search_notes
29
+ from bearkit.secrets import ScanReport, SecretFinding, scan_notes
30
+
31
+ __all__ = [
32
+ "DEFAULT_DB_PATH",
33
+ "AmbiguousNoteId",
34
+ "Attachment",
35
+ "Bear",
36
+ "BearDB",
37
+ "BearWriteError",
38
+ "Note",
39
+ "NoteFilter",
40
+ "ScanReport",
41
+ "SearchResult",
42
+ "SecretFinding",
43
+ "TagMarkerNotFound",
44
+ "TextMode",
45
+ "actions",
46
+ "db",
47
+ "markdown",
48
+ "naive_search",
49
+ "ops",
50
+ "scan_notes",
51
+ "search",
52
+ "search_notes",
53
+ "secrets",
54
+ ]
bearkit/actions.py ADDED
@@ -0,0 +1,71 @@
1
+ """Write actions via Bear's x-callback-url scheme.
2
+
3
+ The database stays strictly read-only; all mutations go through Bear's own
4
+ URL API (which launches the app if needed). Calls are fire-and-forget — Bear
5
+ gives no result back — so callers verify outcomes by re-reading the database.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import subprocess
11
+ import time
12
+ from collections.abc import Callable
13
+ from urllib.parse import quote, urlencode
14
+
15
+ BASE_URL = "bear://x-callback-url/"
16
+
17
+
18
+ def call_bear(action: str, foreground: bool = False, **params: str | None) -> None:
19
+ query = urlencode({k: v for k, v in params.items() if v is not None}, quote_via=quote)
20
+ # -g keeps Bear in the background instead of stealing focus.
21
+ cmd = ["open", f"{BASE_URL}{action}?{query}"] if foreground else ["open", "-g", f"{BASE_URL}{action}?{query}"]
22
+ subprocess.run(cmd, check=True)
23
+
24
+
25
+ def create_note(title: str, text: str | None = None, tags: list[str] | None = None) -> None:
26
+ call_bear(
27
+ "create",
28
+ title=title,
29
+ text=text,
30
+ tags=",".join(tags) if tags else None,
31
+ open_note="no",
32
+ show_window="no",
33
+ )
34
+
35
+
36
+ def add_text(note_id: str, text: str, mode: str = "append") -> None:
37
+ call_bear("add-text", id=note_id, text=text, mode=mode, open_note="no", show_window="no")
38
+
39
+
40
+ def add_file(note_id: str, filename: str, file_b64: str) -> None:
41
+ call_bear("add-file", id=note_id, filename=filename, file=file_b64, mode="append", open_note="no", show_window="no")
42
+
43
+
44
+ def rename_tag(name: str, new_name: str) -> None:
45
+ call_bear("rename-tag", name=name, new_name=new_name, show_window="no")
46
+
47
+
48
+ def delete_tag(name: str) -> None:
49
+ call_bear("delete-tag", name=name, show_window="no")
50
+
51
+
52
+ def open_note(note_id: str, new_window: bool = False) -> None:
53
+ call_bear("open-note", foreground=True, id=note_id, new_window="yes" if new_window else "no")
54
+
55
+
56
+ def trash_note(note_id: str) -> None:
57
+ call_bear("trash", id=note_id, show_window="no")
58
+
59
+
60
+ def archive_note(note_id: str) -> None:
61
+ call_bear("archive", id=note_id, show_window="no")
62
+
63
+
64
+ def wait_for(predicate: Callable[[], bool], timeout: float = 6.0, interval: float = 0.3) -> bool:
65
+ """Poll until predicate() is true; Bear applies URL actions asynchronously."""
66
+ deadline = time.monotonic() + timeout
67
+ while time.monotonic() < deadline:
68
+ if predicate():
69
+ return True
70
+ time.sleep(interval)
71
+ return predicate()
bearkit/bear.py ADDED
@@ -0,0 +1,159 @@
1
+ """The Bear facade: one object for reading and (verified) writing.
2
+
3
+ The recommended entry point for library users. Reads go straight to the
4
+ read-only database; writes go through Bear's x-callback API and are
5
+ verified against the database before returning (raising `BearWriteError`
6
+ if Bear never applies the change). The raw layers remain available as
7
+ `bear.db`, `bearkit.ops`, and `bearkit.actions`.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from collections.abc import Sequence
13
+ from datetime import datetime
14
+ from pathlib import Path
15
+ from types import TracebackType
16
+
17
+ from bearkit import actions, ops
18
+ from bearkit.db import DEFAULT_DB_PATH, BearDB, Note, NoteFilter
19
+ from bearkit.ops import TextMode
20
+ from bearkit.search import SearchResult, naive_search, search_notes
21
+ from bearkit.secrets import ScanReport, scan_notes
22
+
23
+
24
+ class Bear:
25
+ """A Bear library: read notes, and write them through the Bear app."""
26
+
27
+ def __init__(self, path: Path = DEFAULT_DB_PATH):
28
+ self.db = BearDB(path)
29
+
30
+ def close(self) -> None:
31
+ self.db.close()
32
+
33
+ def __enter__(self) -> Bear:
34
+ return self
35
+
36
+ def __exit__(
37
+ self, exc_type: type[BaseException] | None, exc: BaseException | None, tb: TracebackType | None
38
+ ) -> None:
39
+ self.close()
40
+
41
+ # ── reading ──────────────────────────────────────────────────────────
42
+
43
+ def list_notes(
44
+ self,
45
+ limit: int | None = None,
46
+ tag: str | None = None,
47
+ created_after: datetime | None = None,
48
+ created_before: datetime | None = None,
49
+ modified_after: datetime | None = None,
50
+ modified_before: datetime | None = None,
51
+ only: NoteFilter | str | None = None,
52
+ include_trashed: bool = False,
53
+ include_archived: bool = False,
54
+ ) -> list[Note]:
55
+ """Notes, most recently modified first (see BearDB.list_notes)."""
56
+ return self.db.list_notes(
57
+ limit=limit,
58
+ tag=tag,
59
+ created_after=created_after,
60
+ created_before=created_before,
61
+ modified_after=modified_after,
62
+ modified_before=modified_before,
63
+ only=only,
64
+ include_trashed=include_trashed,
65
+ include_archived=include_archived,
66
+ )
67
+
68
+ def get_note(self, note_id: str) -> Note | None:
69
+ """Fetch a note by id or unique 4+ char prefix; None when absent."""
70
+ return self.db.get_note(note_id)
71
+
72
+ def list_tags(self, include_empty: bool = False) -> list[tuple[str, int]]:
73
+ """All tags with their note counts."""
74
+ return self.db.list_tags(include_empty=include_empty)
75
+
76
+ def attachment_stats(self) -> tuple[int, int]:
77
+ """(count, total bytes) of attachments on non-trashed notes."""
78
+ return self.db.attachment_stats()
79
+
80
+ def search(
81
+ self,
82
+ query: str,
83
+ fuzzy: bool = False,
84
+ min_score: float = 60.0,
85
+ tag: str | Sequence[str] | None = None,
86
+ include_trashed: bool = False,
87
+ include_archived: bool = False,
88
+ ) -> list[SearchResult]:
89
+ """Search titles, tags, and content; fuzzy is typo-tolerant and ranked.
90
+
91
+ `tag` scopes the search to one tag or any of several (each including
92
+ its nested sub-tags, e.g. "work" also covers "work/ideas").
93
+ """
94
+ single = tag if isinstance(tag, str) else None
95
+ notes = self.list_notes(
96
+ limit=None, tag=single, include_trashed=include_trashed, include_archived=include_archived
97
+ )
98
+ if tag and not isinstance(tag, str):
99
+ wanted = [t.lower() for t in tag]
100
+ notes = [
101
+ n
102
+ for n in notes
103
+ if any(nt == w or nt.startswith(w + "/") for nt in (t.lower() for t in n.tags) for w in wanted)
104
+ ]
105
+ if fuzzy:
106
+ return search_notes(notes, query, min_score=min_score)
107
+ return naive_search(notes, query)
108
+
109
+ def scan_secrets(self, notes: list[Note] | None = None) -> ScanReport:
110
+ """Scan for secrets; defaults to every note, archived included."""
111
+ return scan_notes(notes if notes is not None else self.list_notes(limit=None, include_archived=True))
112
+
113
+ # ── verified writing ─────────────────────────────────────────────────
114
+
115
+ def create_note(self, title: str, text: str | None = None, tags: list[str] | None = None) -> Note:
116
+ """Create a note and return it once it appears in the database."""
117
+ return ops.create_note(self.db, title, text, tags)
118
+
119
+ def add_text(self, note: Note, text: str, mode: TextMode | str = TextMode.APPEND) -> Note:
120
+ """Append/prepend/replace note text."""
121
+ return ops.add_text(self.db, note, text, mode)
122
+
123
+ def rename(self, note: Note, new_title: str) -> Note:
124
+ """Replace the heading line, keeping the body."""
125
+ return ops.rename(self.db, note, new_title)
126
+
127
+ def add_tag(self, note: Note, name: str) -> Note:
128
+ """Add an inline tag marker to the note."""
129
+ return ops.add_tag(self.db, note, name)
130
+
131
+ def remove_tag(self, note: Note, name: str) -> Note:
132
+ """Remove the tag's inline marker; raises TagMarkerNotFound if absent."""
133
+ return ops.remove_tag(self.db, note, name)
134
+
135
+ def attach_file(self, note: Note, filename: str, file_b64: str) -> Note:
136
+ """Attach a base64-encoded file (keep it under ~500 KB)."""
137
+ return ops.attach_file(self.db, note, filename, file_b64)
138
+
139
+ def trash(self, note: Note) -> Note:
140
+ """Move the note to Bear's trash (one-way: no untrash API)."""
141
+ return ops.trash(self.db, note)
142
+
143
+ def archive(self, note: Note) -> Note:
144
+ """Archive the note (one-way: no unarchive API)."""
145
+ return ops.archive(self.db, note)
146
+
147
+ def rename_tag(self, name: str, new_name: str) -> None:
148
+ """Rename a tag across all notes."""
149
+ ops.rename_tag(self.db, name, new_name)
150
+
151
+ def delete_tag(self, name: str) -> None:
152
+ """Delete a tag from every note that has it."""
153
+ ops.delete_tag(self.db, name)
154
+
155
+ # ── the Bear app ─────────────────────────────────────────────────────
156
+
157
+ def open(self, note: Note, new_window: bool = False) -> None:
158
+ """Bring the note up in the Bear app."""
159
+ actions.open_note(note.id, new_window=new_window)
bearkit/db.py ADDED
@@ -0,0 +1,343 @@
1
+ """Read-only access to Bear's SQLite database."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import sqlite3
7
+ from dataclasses import dataclass, field
8
+ from datetime import UTC, datetime, timedelta
9
+ from enum import StrEnum
10
+ from pathlib import Path
11
+ from types import TracebackType
12
+
13
+ DEFAULT_DB_PATH = (
14
+ Path.home() / "Library/Group Containers/9K33E3U3T4.net.shinyfrog.bear" / "Application Data/database.sqlite"
15
+ )
16
+
17
+ # Core Data stores timestamps as seconds since 2001-01-01 UTC.
18
+ CORE_DATA_EPOCH = datetime(2001, 1, 1, tzinfo=UTC)
19
+
20
+
21
+ def core_data_to_datetime(value: float | None) -> datetime | None:
22
+ if value is None:
23
+ return None
24
+ return (CORE_DATA_EPOCH + timedelta(seconds=value)).astimezone()
25
+
26
+
27
+ def datetime_to_core_data(value: datetime) -> float:
28
+ if value.tzinfo is None:
29
+ value = value.astimezone()
30
+ return (value - CORE_DATA_EPOCH).total_seconds()
31
+
32
+
33
+ class NoteFilter(StrEnum):
34
+ """Restrict a listing to notes carrying exactly this status."""
35
+
36
+ PINNED = "pinned"
37
+ ENCRYPTED = "encrypted"
38
+ TRASHED = "trashed"
39
+ ARCHIVED = "archived"
40
+
41
+
42
+ @dataclass
43
+ class Attachment:
44
+ id: str
45
+ filename: str
46
+ path: Path
47
+ size: int | None
48
+ exists: bool
49
+
50
+
51
+ @dataclass
52
+ class Note:
53
+ id: str
54
+ title: str
55
+ created: datetime | None
56
+ modified: datetime | None
57
+ pinned: bool
58
+ encrypted: bool
59
+ archived: bool
60
+ trashed: bool
61
+ tags: list[str] = field(default_factory=list)
62
+ text: str | None = None
63
+ """The full markdown content; None if and only if the note is encrypted."""
64
+ attachments: list[Attachment] = field(default_factory=list)
65
+
66
+ def has_tag(self, name: str) -> bool:
67
+ """Whether the note carries the tag (case-insensitive, exact name)."""
68
+ return name.lower() in (t.lower() for t in self.tags)
69
+
70
+ def to_dict(self) -> dict:
71
+ """Serializable metadata: id, title, tags, ISO dates, status flags."""
72
+ return {
73
+ "id": self.id,
74
+ "title": self.title,
75
+ "tags": self.tags,
76
+ "created": self.created.isoformat() if self.created else None,
77
+ "modified": self.modified.isoformat() if self.modified else None,
78
+ "pinned": self.pinned,
79
+ "encrypted": self.encrypted,
80
+ "archived": self.archived,
81
+ "trashed": self.trashed,
82
+ }
83
+
84
+ @property
85
+ def status_line(self) -> str:
86
+ """Comma-joined status flags for display, e.g. "pinned,archived"."""
87
+ flags = (
88
+ ("pinned", self.pinned),
89
+ ("encrypted", self.encrypted),
90
+ ("trashed", self.trashed),
91
+ ("archived", self.archived),
92
+ )
93
+ return ",".join(name for name, on in flags if on)
94
+
95
+
96
+ class AmbiguousNoteId(Exception):
97
+ """A note id prefix matched more than one note."""
98
+
99
+ def __init__(self, prefix: str, matches: list[tuple[str, str]]):
100
+ super().__init__(f"note id prefix {prefix!r} is ambiguous")
101
+ self.prefix = prefix
102
+ self.matches = matches # (id, title) pairs
103
+
104
+
105
+ MIN_ID_PREFIX = 4
106
+
107
+
108
+ class BearDB:
109
+ def __init__(self, path: Path = DEFAULT_DB_PATH):
110
+ if not path.exists():
111
+ raise FileNotFoundError(f"Bear database not found at {path}")
112
+ self.conn = sqlite3.connect(f"file:{path}?mode=ro", uri=True)
113
+ self.conn.row_factory = sqlite3.Row
114
+ self.files_dir = path.parent / "Local Files"
115
+ self._tags_join = self._detect_tags_join()
116
+
117
+ def close(self) -> None:
118
+ self.conn.close()
119
+
120
+ def __enter__(self) -> BearDB:
121
+ return self
122
+
123
+ def __exit__(
124
+ self, exc_type: type[BaseException] | None, exc: BaseException | None, tb: TracebackType | None
125
+ ) -> None:
126
+ self.close()
127
+
128
+ def _detect_tags_join(self) -> tuple[str, str, str]:
129
+ """Find the note<->tag join table; its numeric prefixes vary by Bear version.
130
+
131
+ Returns (table, note_column, tag_column).
132
+ """
133
+ rows = self.conn.execute(
134
+ "SELECT name FROM sqlite_master WHERE type = 'table' AND name LIKE 'Z\\_%TAGS' ESCAPE '\\'"
135
+ ).fetchall()
136
+ for (table,) in rows:
137
+ if not re.fullmatch(r"Z_\d+TAGS", table):
138
+ continue
139
+ columns = [r["name"] for r in self.conn.execute(f"PRAGMA table_info({table})")]
140
+ note_col = next((c for c in columns if re.fullmatch(r"Z_\d+NOTES", c)), None)
141
+ tag_col = next((c for c in columns if re.fullmatch(r"Z_\d+TAGS", c)), None)
142
+ if note_col and tag_col:
143
+ return table, note_col, tag_col
144
+ raise RuntimeError("Could not locate the note/tag join table in the Bear database")
145
+
146
+ def _tags_for_note(self, note_pk: int) -> list[str]:
147
+ table, note_col, tag_col = self._tags_join
148
+ rows = self.conn.execute(
149
+ f"""
150
+ SELECT t.ZTITLE FROM ZSFNOTETAG t
151
+ JOIN {table} j ON j.{tag_col} = t.Z_PK
152
+ WHERE j.{note_col} = ?
153
+ ORDER BY t.ZTITLE
154
+ """,
155
+ (note_pk,),
156
+ ).fetchall()
157
+ return [r["ZTITLE"] for r in rows]
158
+
159
+ def list_tags(self, include_empty: bool = False) -> list[tuple[str, int]]:
160
+ """All tags with their note counts (excluding trashed/deleted notes).
161
+
162
+ Bear keeps tag rows around after their last note is untagged (the app
163
+ hides them), so empty tags are excluded unless include_empty is set.
164
+ """
165
+ table, note_col, tag_col = self._tags_join
166
+ having = "" if include_empty else "HAVING COUNT(n.Z_PK) > 0"
167
+ rows = self.conn.execute(
168
+ f"""
169
+ SELECT t.ZTITLE, COUNT(n.Z_PK)
170
+ FROM ZSFNOTETAG t
171
+ LEFT JOIN {table} j ON j.{tag_col} = t.Z_PK
172
+ LEFT JOIN ZSFNOTE n
173
+ ON n.Z_PK = j.{note_col} AND n.ZTRASHED = 0 AND n.ZPERMANENTLYDELETED = 0
174
+ GROUP BY t.Z_PK
175
+ {having}
176
+ ORDER BY t.ZTITLE
177
+ """
178
+ ).fetchall()
179
+ return [(row[0], row[1]) for row in rows]
180
+
181
+ def attachment_stats(self) -> tuple[int, int]:
182
+ """(count, total bytes) of attachments on non-deleted notes."""
183
+ row = self.conn.execute(
184
+ """
185
+ SELECT COUNT(*), COALESCE(SUM(f.ZFILESIZE), 0)
186
+ FROM ZSFNOTEFILE f
187
+ JOIN ZSFNOTE n ON n.Z_PK = f.ZNOTE
188
+ WHERE f.ZPERMANENTLYDELETED = 0 AND n.ZPERMANENTLYDELETED = 0 AND n.ZTRASHED = 0
189
+ """
190
+ ).fetchone()
191
+ return row[0], row[1]
192
+
193
+ def _attachments_for_note(self, note_pk: int) -> list[Attachment]:
194
+ rows = self.conn.execute(
195
+ """
196
+ SELECT ZUNIQUEIDENTIFIER, ZFILENAME, ZFILESIZE FROM ZSFNOTEFILE
197
+ WHERE ZNOTE = ? AND ZPERMANENTLYDELETED = 0 ORDER BY ZFILENAME
198
+ """,
199
+ (note_pk,),
200
+ ).fetchall()
201
+ attachments = []
202
+ for row in rows:
203
+ # Images live under "Note Images", everything else under "Note Files";
204
+ # the database doesn't record which, so probe both.
205
+ candidates = [
206
+ self.files_dir / subdir / row["ZUNIQUEIDENTIFIER"] / row["ZFILENAME"]
207
+ for subdir in ("Note Images", "Note Files")
208
+ ]
209
+ path = next((c for c in candidates if c.exists()), candidates[0])
210
+ attachments.append(
211
+ Attachment(
212
+ id=row["ZUNIQUEIDENTIFIER"],
213
+ filename=row["ZFILENAME"],
214
+ path=path,
215
+ size=row["ZFILESIZE"],
216
+ exists=path.exists(),
217
+ )
218
+ )
219
+ return attachments
220
+
221
+ def list_notes(
222
+ self,
223
+ limit: int | None = None,
224
+ tag: str | None = None,
225
+ created_after: datetime | None = None,
226
+ created_before: datetime | None = None,
227
+ modified_after: datetime | None = None,
228
+ modified_before: datetime | None = None,
229
+ only: NoteFilter | str | None = None,
230
+ include_trashed: bool = False,
231
+ include_archived: bool = False,
232
+ ) -> list[Note]:
233
+ """Notes, most recently modified first.
234
+
235
+ Trashed and archived notes are excluded unless included explicitly or
236
+ selected via `only`; permanently-deleted rows never appear.
237
+ """
238
+ only = NoteFilter(only) if only is not None else None
239
+ table, note_col, tag_col = self._tags_join
240
+ # Deleted-pending-sync rows linger in the table; never show them.
241
+ where = ["n.ZPERMANENTLYDELETED = 0"]
242
+ params: list[object] = []
243
+
244
+ if only is NoteFilter.PINNED:
245
+ where.append("n.ZPINNED = 1")
246
+ elif only is NoteFilter.ENCRYPTED:
247
+ where.append("n.ZENCRYPTED = 1")
248
+
249
+ if only is NoteFilter.TRASHED:
250
+ where.append("n.ZTRASHED = 1")
251
+ elif not include_trashed:
252
+ where.append("n.ZTRASHED = 0")
253
+ if only is NoteFilter.ARCHIVED:
254
+ where.append("n.ZARCHIVED = 1")
255
+ elif not include_archived and only is not NoteFilter.TRASHED:
256
+ # A note trashed from the archive keeps both flags; Bear shows it in
257
+ # the trash, so --only trashed must not exclude archived notes.
258
+ where.append("n.ZARCHIVED = 0")
259
+ if tag:
260
+ # Match the tag itself and its nested sub-tags (e.g. "work" matches "work/ideas").
261
+ where.append(
262
+ f"""n.Z_PK IN (
263
+ SELECT j.{note_col} FROM {table} j
264
+ JOIN ZSFNOTETAG t ON t.Z_PK = j.{tag_col}
265
+ WHERE t.ZTITLE = ? OR t.ZTITLE LIKE ? ESCAPE '\\'
266
+ )"""
267
+ )
268
+ escaped = tag.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
269
+ params += [tag, f"{escaped}/%"]
270
+ if created_after:
271
+ where.append("n.ZCREATIONDATE >= ?")
272
+ params.append(datetime_to_core_data(created_after))
273
+ if created_before:
274
+ where.append("n.ZCREATIONDATE < ?")
275
+ params.append(datetime_to_core_data(created_before))
276
+ if modified_after:
277
+ where.append("n.ZMODIFICATIONDATE >= ?")
278
+ params.append(datetime_to_core_data(modified_after))
279
+ if modified_before:
280
+ where.append("n.ZMODIFICATIONDATE < ?")
281
+ params.append(datetime_to_core_data(modified_before))
282
+
283
+ query = """
284
+ SELECT n.Z_PK, n.ZUNIQUEIDENTIFIER, n.ZTITLE, n.ZCREATIONDATE,
285
+ n.ZMODIFICATIONDATE, n.ZPINNED, n.ZENCRYPTED, n.ZARCHIVED, n.ZTRASHED, n.ZTEXT
286
+ FROM ZSFNOTE n
287
+ """
288
+ if where:
289
+ query += " WHERE " + " AND ".join(where)
290
+ query += " ORDER BY n.ZMODIFICATIONDATE DESC"
291
+ if limit is not None:
292
+ query += " LIMIT ?"
293
+ params.append(limit)
294
+
295
+ return [self._note_from_row(row, text=row["ZTEXT"]) for row in self.conn.execute(query, params)]
296
+
297
+ def get_note(self, note_id: str) -> Note | None:
298
+ """Fetch a note by id; a unique prefix (>= 4 chars) works like a full id."""
299
+ row = self.conn.execute(
300
+ """
301
+ SELECT Z_PK, ZUNIQUEIDENTIFIER, ZTITLE, ZTEXT, ZCREATIONDATE,
302
+ ZMODIFICATIONDATE, ZPINNED, ZENCRYPTED, ZARCHIVED, ZTRASHED
303
+ FROM ZSFNOTE
304
+ WHERE ZUNIQUEIDENTIFIER = ? COLLATE NOCASE
305
+ """,
306
+ (note_id,),
307
+ ).fetchone()
308
+ if row is None and len(note_id) >= MIN_ID_PREFIX:
309
+ escaped = note_id.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
310
+ matches = self.conn.execute(
311
+ """
312
+ SELECT Z_PK, ZUNIQUEIDENTIFIER, ZTITLE, ZTEXT, ZCREATIONDATE,
313
+ ZMODIFICATIONDATE, ZPINNED, ZENCRYPTED, ZARCHIVED, ZTRASHED
314
+ FROM ZSFNOTE
315
+ WHERE ZUNIQUEIDENTIFIER LIKE ? ESCAPE '\\' COLLATE NOCASE AND ZPERMANENTLYDELETED = 0
316
+ LIMIT 6
317
+ """,
318
+ (f"{escaped}%",),
319
+ ).fetchall()
320
+ if len(matches) > 1:
321
+ raise AmbiguousNoteId(note_id, [(r["ZUNIQUEIDENTIFIER"], r["ZTITLE"] or "(untitled)") for r in matches])
322
+ if matches:
323
+ row = matches[0]
324
+ if row is None:
325
+ return None
326
+ return self._note_from_row(row, text=row["ZTEXT"], attachments=self._attachments_for_note(row["Z_PK"]))
327
+
328
+ def _note_from_row(
329
+ self, row: sqlite3.Row, text: str | None = None, attachments: list[Attachment] | None = None
330
+ ) -> Note:
331
+ return Note(
332
+ id=row["ZUNIQUEIDENTIFIER"],
333
+ title=row["ZTITLE"] or "(untitled)",
334
+ created=core_data_to_datetime(row["ZCREATIONDATE"]),
335
+ modified=core_data_to_datetime(row["ZMODIFICATIONDATE"]),
336
+ pinned=bool(row["ZPINNED"]),
337
+ encrypted=bool(row["ZENCRYPTED"]),
338
+ archived=bool(row["ZARCHIVED"]),
339
+ trashed=bool(row["ZTRASHED"]),
340
+ tags=self._tags_for_note(row["Z_PK"]),
341
+ text=text,
342
+ attachments=attachments or [],
343
+ )
bearkit/markdown.py ADDED
@@ -0,0 +1,45 @@
1
+ """Helpers for Bear's markdown conventions, shared by the CLI, TUI, and export."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from collections.abc import Callable
7
+ from urllib.parse import quote
8
+
9
+ from bearkit.db import Attachment, Note
10
+
11
+
12
+ def rewrite_attachment_refs(note: Note, target_for: Callable[[Attachment], str]) -> str:
13
+ """Rewrite bare attachment filename links to per-attachment targets.
14
+
15
+ Bear references attachments by bare filename and percent-encodes it in the
16
+ note text (spaces as %20), so both the raw and encoded forms must be
17
+ matched; targets are emitted percent-encoded to keep the links valid.
18
+ Only filenames known from the attachment records are rewritten — regular
19
+ URLs in the text are never touched.
20
+ """
21
+ text = note.text or ""
22
+ for att in note.attachments:
23
+ target = quote(target_for(att))
24
+ for ref in {att.filename, quote(att.filename)}:
25
+ text = text.replace(f"]({ref})", f"]({target})")
26
+ return text
27
+
28
+
29
+ def tag_marker(name: str) -> str:
30
+ """The inline marker Bear uses for a tag: #name, or #name# when it needs delimiting."""
31
+ return f"#{name}#" if re.search(r"[^\w/-]", name) else f"#{name}"
32
+
33
+
34
+ def remove_tag_marker(text: str, name: str) -> str | None:
35
+ """The text with the tag's inline markers stripped, or None if none matched.
36
+
37
+ Longer tags sharing the prefix are left alone: removing "work" must not
38
+ touch "#work/ideas" or "#workout".
39
+ """
40
+ escaped = re.escape(name)
41
+ stripped = re.sub(rf"[ \t]?#{escaped}#", "", text, flags=re.IGNORECASE)
42
+ stripped = re.sub(rf"[ \t]?#{escaped}(?![\w/-])", "", stripped, flags=re.IGNORECASE)
43
+ if stripped == text:
44
+ return None
45
+ return stripped.rstrip("\n") + "\n"
bearkit/ops.py ADDED
@@ -0,0 +1,150 @@
1
+ """Verified write operations.
2
+
3
+ Each function fires a Bear x-callback action, confirms the outcome by
4
+ re-reading the database, and returns the fresh note. If Bear never
5
+ observably applies the change within `VERIFY_TIMEOUT` seconds (typically:
6
+ Bear cannot run), `BearWriteError` is raised.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from collections.abc import Callable
12
+ from datetime import UTC, datetime, timedelta
13
+ from enum import StrEnum
14
+
15
+ from bearkit import actions
16
+ from bearkit.db import BearDB, Note
17
+ from bearkit.markdown import remove_tag_marker, tag_marker
18
+
19
+ VERIFY_TIMEOUT = 6.0
20
+ """Seconds to wait for Bear to apply a write before raising BearWriteError."""
21
+
22
+
23
+ class TextMode(StrEnum):
24
+ """Where `add_text` puts the text (mirrors Bear's add-text modes)."""
25
+
26
+ APPEND = "append"
27
+ PREPEND = "prepend"
28
+ REPLACE = "replace" # replaces the body, keeps the title
29
+ REPLACE_ALL = "replace_all" # replaces everything including the title
30
+
31
+
32
+ class BearWriteError(RuntimeError):
33
+ """Bear did not observably apply the write (is Bear able to run?)."""
34
+
35
+
36
+ class TagMarkerNotFound(LookupError):
37
+ """The note text contains no inline marker for the tag being removed."""
38
+
39
+
40
+ def _confirmed(db: BearDB, note_id: str, operation: str, changed: Callable[[Note], bool]) -> Note:
41
+ def check() -> bool:
42
+ fresh = db.get_note(note_id)
43
+ return fresh is not None and changed(fresh)
44
+
45
+ if actions.wait_for(check, timeout=VERIFY_TIMEOUT):
46
+ fresh = db.get_note(note_id)
47
+ if fresh is not None:
48
+ return fresh
49
+ raise BearWriteError(f"{operation} was not applied to note {note_id}")
50
+
51
+
52
+ def _modified_after(before: Note) -> Callable[[Note], bool]:
53
+ return lambda n: n.modified is not None and (before.modified is None or n.modified > before.modified)
54
+
55
+
56
+ def create_note(db: BearDB, title: str, text: str | None, tags: list[str] | None = None) -> Note:
57
+ """Create a note through Bear and return it once it appears in the database."""
58
+ started = datetime.now(UTC)
59
+ actions.create_note(title, text=text, tags=tags)
60
+
61
+ def find() -> Note | None:
62
+ return next(
63
+ (
64
+ n
65
+ for n in db.list_notes(limit=10)
66
+ if n.title == title and n.created and n.created >= started - timedelta(seconds=5)
67
+ ),
68
+ None,
69
+ )
70
+
71
+ if actions.wait_for(lambda: find() is not None, timeout=VERIFY_TIMEOUT):
72
+ created = find()
73
+ if created is not None:
74
+ return created
75
+ raise BearWriteError(f"created note {title!r} did not appear in the database")
76
+
77
+
78
+ def add_text(db: BearDB, note: Note, text: str, mode: TextMode | str = TextMode.APPEND) -> Note:
79
+ """Append/prepend/replace note text; verified by the modification date bump."""
80
+ actions.add_text(note.id, text, mode=TextMode(mode).value)
81
+ return _confirmed(db, note.id, "text change", _modified_after(note))
82
+
83
+
84
+ def rename(db: BearDB, note: Note, new_title: str) -> Note:
85
+ """Replace the heading line, keeping the body."""
86
+ head, sep, body = (note.text or "").partition("\n")
87
+ if head.startswith("# "):
88
+ new_text = f"# {new_title}{sep}{body}"
89
+ else:
90
+ new_text = f"# {new_title}\n{note.text or ''}"
91
+ actions.add_text(note.id, new_text, mode=TextMode.REPLACE_ALL.value)
92
+ return _confirmed(db, note.id, "rename", lambda n: n.title == new_title)
93
+
94
+
95
+ def add_tag(db: BearDB, note: Note, name: str) -> Note:
96
+ actions.add_text(note.id, tag_marker(name), mode=TextMode.APPEND.value)
97
+ return _confirmed(db, note.id, "tag", lambda n: n.has_tag(name))
98
+
99
+
100
+ def remove_tag(db: BearDB, note: Note, name: str) -> Note:
101
+ """Rewrite the note without the tag marker.
102
+
103
+ Raises TagMarkerNotFound when the text has no marker for the tag.
104
+ """
105
+ new_text = remove_tag_marker(note.text or "", name)
106
+ if new_text is None:
107
+ raise TagMarkerNotFound(f"no #{name} marker in the note text")
108
+ actions.add_text(note.id, new_text, mode=TextMode.REPLACE_ALL.value)
109
+ return _confirmed(db, note.id, "untag", lambda n: not n.has_tag(name))
110
+
111
+
112
+ def attach_file(db: BearDB, note: Note, filename: str, file_b64: str) -> Note:
113
+ before = len(note.attachments)
114
+ actions.add_file(note.id, filename, file_b64)
115
+ return _confirmed(db, note.id, "attach", lambda n: len(n.attachments) > before)
116
+
117
+
118
+ def trash(db: BearDB, note: Note) -> Note:
119
+ actions.trash_note(note.id)
120
+ return _confirmed(db, note.id, "trash", lambda n: n.trashed)
121
+
122
+
123
+ def archive(db: BearDB, note: Note) -> Note:
124
+ actions.archive_note(note.id)
125
+ return _confirmed(db, note.id, "archive", lambda n: n.archived)
126
+
127
+
128
+ def rename_tag(db: BearDB, name: str, new_name: str) -> None:
129
+ """Rename a tag across all notes; verified via the tag list."""
130
+ actions.rename_tag(name, new_name)
131
+ if not actions.wait_for(
132
+ lambda: new_name.lower() in {t.lower() for t, _ in db.list_tags(include_empty=True)},
133
+ timeout=VERIFY_TIMEOUT,
134
+ ):
135
+ raise BearWriteError(f"tag {name!r} was not renamed")
136
+
137
+
138
+ def delete_tag(db: BearDB, name: str) -> None:
139
+ """Delete a tag from every note; verified by its note count reaching zero.
140
+
141
+ Bear keeps the empty tag row behind, so absence is not the signal.
142
+ """
143
+ actions.delete_tag(name)
144
+
145
+ def gone() -> bool:
146
+ counts = {t.lower(): c for t, c in db.list_tags(include_empty=True)}
147
+ return counts.get(name.lower(), 0) == 0
148
+
149
+ if not actions.wait_for(gone, timeout=VERIFY_TIMEOUT):
150
+ raise BearWriteError(f"tag {name!r} was not deleted")
bearkit/py.typed ADDED
File without changes
bearkit/search.py ADDED
@@ -0,0 +1,121 @@
1
+ """Fuzzy search over notes using rapidfuzz."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from dataclasses import dataclass
7
+
8
+ from rapidfuzz import fuzz, utils
9
+
10
+ from bearkit.db import Note
11
+
12
+ # Where the match landed determines its weight: a title hit beats an equally
13
+ # good tag hit, which beats a body hit.
14
+ TITLE_WEIGHT = 1.0
15
+ TAG_WEIGHT = 0.95
16
+ BODY_WEIGHT = 0.9
17
+
18
+ SNIPPET_LENGTH = 70
19
+
20
+
21
+ @dataclass
22
+ class SearchResult:
23
+ note: Note
24
+ snippet: str
25
+ score: float | None = None # None for naive (substring) matches
26
+
27
+
28
+ def naive_search(notes: list[Note], query: str) -> list[SearchResult]:
29
+ """Case-insensitive substring search over titles, tags, and text.
30
+
31
+ Input order (most recently modified first) is preserved.
32
+ """
33
+ needle = query.lower()
34
+ results = []
35
+ for note in notes:
36
+ title_hit = needle in note.title.lower()
37
+ tag_hit = any(needle in tag.lower() for tag in note.tags)
38
+ body_line = next(
39
+ (line.strip() for line in (note.text or "").splitlines() if needle in line.lower()),
40
+ "",
41
+ )
42
+ if not (title_hit or tag_hit or body_line):
43
+ continue
44
+ snippet = _trim_snippet(body_line, query) if body_line and not (title_hit or tag_hit) else ""
45
+ results.append(SearchResult(note=note, snippet=snippet))
46
+ return results
47
+
48
+
49
+ def _fuzzy_score(processed_query: str, target: str) -> float:
50
+ """Direction-aware scoring: the query must always be the needle.
51
+
52
+ partial_ratio slides the shorter string over the longer one, so a trivially
53
+ short target ("Bindi") would score ~80 against a long query. Only allow the
54
+ sliding when the target is at least query-sized; otherwise use plain ratio,
55
+ which penalizes the missing content.
56
+ """
57
+ processed_target = utils.default_process(target)
58
+ if not processed_query or not processed_target:
59
+ return 0.0
60
+ if len(processed_target) >= len(processed_query):
61
+ return fuzz.partial_ratio(processed_query, processed_target)
62
+ return fuzz.ratio(processed_query, processed_target)
63
+
64
+
65
+ def _body_match(query: str, text: str | None) -> tuple[float, str]:
66
+ """Score the query against the full note text; return (score, matching line).
67
+
68
+ partial_ratio must slide the query over the text, never the reverse: with a
69
+ short haystack the needle/haystack roles flip and trivial strings score ~90.
70
+ """
71
+ processed_query = utils.default_process(query)
72
+ processed_text = utils.default_process(text or "")
73
+ if not processed_query or not processed_text:
74
+ return 0.0, ""
75
+ if len(processed_text) < len(processed_query):
76
+ return fuzz.ratio(processed_query, processed_text), (text or "").strip().splitlines()[0]
77
+
78
+ alignment = fuzz.partial_ratio_alignment(processed_query, processed_text)
79
+ if alignment is None:
80
+ return 0.0, ""
81
+ # default_process keeps positions 1:1 (non-alnum become spaces) except for
82
+ # the leading strip, so shift by where the first alphanumeric char was.
83
+ first_alnum = re.search(r"[^\W_]", text or "")
84
+ pos = alignment.dest_start + (first_alnum.start() if first_alnum else 0)
85
+ line_start = (text or "").rfind("\n", 0, pos) + 1
86
+ line_end = (text or "").find("\n", pos)
87
+ line = (text or "")[line_start : line_end if line_end >= 0 else None].strip()
88
+ return alignment.score, line
89
+
90
+
91
+ def _trim_snippet(line: str, query: str) -> str:
92
+ if len(line) <= SNIPPET_LENGTH:
93
+ return line
94
+ # Center the snippet on the first query word that occurs literally, if any.
95
+ lowered = line.lower()
96
+ pos = next((p for w in query.lower().split() if (p := lowered.find(w)) >= 0), 0)
97
+ start = max(0, min(pos - SNIPPET_LENGTH // 3, len(line) - SNIPPET_LENGTH))
98
+ end = start + SNIPPET_LENGTH
99
+ prefix = "…" if start > 0 else ""
100
+ suffix = "…" if end < len(line) else ""
101
+ return f"{prefix}{line[start:end].strip()}{suffix}"
102
+
103
+
104
+ def search_notes(notes: list[Note], query: str, min_score: float = 60.0) -> list[SearchResult]:
105
+ """Score every note against the query; return matches sorted best-first."""
106
+ processed_query = utils.default_process(query)
107
+ results = []
108
+ for note in notes:
109
+ title_score = _fuzzy_score(processed_query, note.title) * TITLE_WEIGHT
110
+ tag_score = max(_fuzzy_score(processed_query, tag) for tag in note.tags) * TAG_WEIGHT if note.tags else 0.0
111
+ body_raw, body_line = _body_match(query, note.text)
112
+ body_score = body_raw * BODY_WEIGHT
113
+
114
+ score = max(title_score, tag_score, body_score)
115
+ if score < min_score:
116
+ continue
117
+ snippet = _trim_snippet(body_line, query) if body_score >= max(title_score, tag_score) else ""
118
+ results.append(SearchResult(note=note, score=score, snippet=snippet))
119
+
120
+ results.sort(key=lambda r: r.score or 0.0, reverse=True)
121
+ return results
bearkit/secrets.py ADDED
@@ -0,0 +1,242 @@
1
+ """Secret detection over note text, powered by detect-secrets (Yelp) plus
2
+ note-specific detectors.
3
+
4
+ A first line of defense before notes leave the machine via export. Everything
5
+ runs offline — note content is never sent anywhere. detect-secrets provides
6
+ the format detectors (AWS, GitHub, Slack, Stripe, private keys, JWTs, keyword
7
+ assignments, …); two gaps matter for prose notes and are covered here:
8
+
9
+ - detect-secrets' entropy plugins only match *quoted* strings, which pasted
10
+ keys in notes never are — so unquoted token runs get their own entropy scan.
11
+ - Credentials in notes often sit on the line *below* a label ("Client
12
+ secret:" then the value, often inside a code fence) — a look-ahead rule
13
+ catches those.
14
+
15
+ Secrets written as prose are still undetectable; for deeper auditing run
16
+ gitleaks over an --allow-secrets export.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import math
22
+ import re
23
+ from collections.abc import Iterator
24
+ from dataclasses import dataclass
25
+
26
+ from detect_secrets.core.plugins.util import get_mapping_from_secret_type_to_class
27
+ from detect_secrets.filters import heuristic
28
+ from detect_secrets.plugins.base import BasePlugin
29
+
30
+ from bearkit.db import Note
31
+
32
+ # IPPublicDetector: an IP address in a note is not a credential.
33
+ # The entropy plugins only match quoted strings — replaced by _scan_entropy.
34
+ _EXCLUDED_PLUGINS = {"IPPublicDetector", "Base64HighEntropyString", "HexHighEntropyString"}
35
+
36
+ _BASE64_ENTROPY_LIMIT = 4.5 # detect-secrets' default
37
+ _HEX_ENTROPY_LIMIT = 3.5 # above default (3.0): notes are full of UUID/hash fragments
38
+
39
+ # A contiguous credential-looking run: base64/urlsafe chars plus the '.' that
40
+ # joins JWT segments. 20+ chars keeps ordinary words and short ids out.
41
+ _TOKEN_RUN = re.compile(r"[A-Za-z0-9+/\-_=.]{20,}")
42
+ _HEX_ONLY = re.compile(r"^[0-9a-fA-F]+$")
43
+
44
+ # "Client secret:" / "Access token" style label with nothing after it — the
45
+ # value is expected on the next content line (code fences skipped).
46
+ _LABEL_ONLY = re.compile(
47
+ r"(?i)^\s*\**(?:client\s+secret|secret\s+access\s+key|access\s+token|auth\s+token|refresh\s+token"
48
+ r"|api\s*-?\s*key|access\s+key|private\s+key|secret|password|passwd|pwd|token)\s*\**\s*[:=]?\s*$"
49
+ )
50
+ _FENCE = re.compile(r"^\s*(?:```|~~~)")
51
+ _TRIM_PUNCT = "()[]{}<>«»\"'`,;|"
52
+
53
+
54
+ def _build_plugins() -> list[BasePlugin]:
55
+ return [cls() for cls in get_mapping_from_secret_type_to_class().values() if cls.__name__ not in _EXCLUDED_PLUGINS]
56
+
57
+
58
+ def _entropy(value: str) -> float:
59
+ counts = {char: value.count(char) for char in set(value)}
60
+ return -sum(n / len(value) * math.log2(n / len(value)) for n in counts.values())
61
+
62
+
63
+ def _is_false_positive(value: str, line: str) -> bool:
64
+ return (
65
+ heuristic.is_potential_uuid(value)
66
+ or heuristic.is_sequential_string(value)
67
+ or heuristic.is_templated_secret(value)
68
+ or heuristic.is_likely_id_string(value, line)
69
+ or heuristic.is_not_alphanumeric_string(value)
70
+ )
71
+
72
+
73
+ def _expand_to_token(line: str, value: str) -> str:
74
+ """Grow a detected value to the full unbroken token around it.
75
+
76
+ Format detectors can match only part of a longer credential (a JWT's
77
+ first two segments, say); redacting the partial match would leave the
78
+ rest behind.
79
+ """
80
+ start = line.find(value)
81
+ if start < 0:
82
+ return value
83
+ end = start + len(value)
84
+ while start > 0 and not line[start - 1].isspace():
85
+ start -= 1
86
+ while end < len(line) and not line[end].isspace():
87
+ end += 1
88
+ return line[start:end].strip(_TRIM_PUNCT)
89
+
90
+
91
+ def _in_url(line: str, start: int) -> bool:
92
+ """Whether the token starting at `start` is part of a URL.
93
+
94
+ Long ids inside links (Google Docs, Notion, …) are high-entropy but are
95
+ shareable references, not credentials — flagging them would drown real
96
+ findings. Token-bearing URLs with known formats (Slack webhooks, …) are
97
+ still caught by their format detectors.
98
+ """
99
+ token_start = start
100
+ while token_start > 0 and not line[token_start - 1].isspace():
101
+ token_start -= 1
102
+ return "://" in line[token_start : start + 3]
103
+
104
+
105
+ def _scan_entropy(line: str) -> list[str]:
106
+ hits = []
107
+ for match in _TOKEN_RUN.finditer(line):
108
+ if _in_url(line, match.start()):
109
+ continue
110
+ value = match.group().strip(_TRIM_PUNCT + ".=")
111
+ limit = _HEX_ENTROPY_LIMIT if _HEX_ONLY.fullmatch(value) else _BASE64_ENTROPY_LIMIT
112
+ if len(value) >= 20 and _entropy(value) >= limit:
113
+ hits.append(match.group())
114
+ return hits
115
+
116
+
117
+ def _labeled_value(lines: list[str], label_index: int) -> tuple[int, str] | None:
118
+ """The first content line after a bare credential label, if it looks like a value."""
119
+ for offset in range(1, 4):
120
+ index = label_index + offset
121
+ if index >= len(lines):
122
+ return None
123
+ candidate = lines[index].strip()
124
+ if not candidate or _FENCE.match(candidate):
125
+ continue
126
+ candidate = candidate.strip(_TRIM_PUNCT)
127
+ if " " not in candidate and 8 <= len(candidate) <= 200:
128
+ return index, candidate
129
+ return None
130
+ return None
131
+
132
+
133
+ @dataclass
134
+ class SecretFinding:
135
+ note_id: str
136
+ note_title: str
137
+ rule: str
138
+ line: int
139
+ excerpt: str
140
+ secret: str # raw value, for redaction only — never print this
141
+
142
+
143
+ def _redact(value: str) -> str:
144
+ """Show enough to locate the secret in the note, never the secret itself."""
145
+ return f"{value[:8]}…" if len(value) > 12 else "…"
146
+
147
+
148
+ def _scan_note(note: Note, plugins: list[BasePlugin]) -> list[SecretFinding]:
149
+ lines = list((note.text or "").splitlines())
150
+ seen: set[str] = set()
151
+ findings: list[SecretFinding] = []
152
+
153
+ def add(lineno: int, value: str, rule: str) -> None:
154
+ if value and value not in seen and not _is_false_positive(value, lines[lineno - 1]):
155
+ seen.add(value)
156
+ findings.append(
157
+ SecretFinding(
158
+ note_id=note.id,
159
+ note_title=note.title,
160
+ rule=rule,
161
+ line=lineno,
162
+ excerpt=_redact(value),
163
+ secret=value,
164
+ )
165
+ )
166
+
167
+ for lineno, line in enumerate(lines, start=1):
168
+ for plugin in plugins:
169
+ # The yaml filetype hint makes KeywordDetector accept unquoted
170
+ # `password: value` assignments, which is how notes write them.
171
+ for secret in plugin.analyze_line(filename="note.yaml", line=line, line_number=lineno):
172
+ add(lineno, _expand_to_token(line, secret.secret_value or ""), secret.type)
173
+ for value in _scan_entropy(line):
174
+ add(lineno, value, "High Entropy String")
175
+ if _LABEL_ONLY.match(line):
176
+ if labeled := _labeled_value(lines, lineno - 1):
177
+ value_index, value = labeled
178
+ add(value_index + 1, value, "Labeled Credential")
179
+ return findings
180
+
181
+
182
+ def _replace(text: str, secrets: dict[str, str]) -> str:
183
+ # Longest first, in case one detected value contains another.
184
+ for value in sorted(secrets, key=len, reverse=True):
185
+ text = text.replace(value, f"[redacted: {secrets[value]}]")
186
+ return text
187
+
188
+
189
+ @dataclass
190
+ class ScanReport:
191
+ """The outcome of a scan: findings, plus the redaction that follows.
192
+
193
+ Iterable and truthy on findings; per-note access via `has`/`for_note`;
194
+ `redact`/`redact_text` replace each detected value with a
195
+ `[redacted: <rule>]` placeholder.
196
+ """
197
+
198
+ findings: list[SecretFinding]
199
+
200
+ def __iter__(self) -> Iterator[SecretFinding]:
201
+ return iter(self.findings)
202
+
203
+ def __len__(self) -> int:
204
+ return len(self.findings)
205
+
206
+ def __bool__(self) -> bool:
207
+ return bool(self.findings)
208
+
209
+ def has(self, note_id: str) -> bool:
210
+ return any(f.note_id == note_id for f in self.findings)
211
+
212
+ def for_note(self, note_id: str) -> dict[str, str]:
213
+ """The note's secret values mapped to the rule that found them."""
214
+ values: dict[str, str] = {}
215
+ for finding in self.findings:
216
+ if finding.note_id == note_id and finding.secret:
217
+ values.setdefault(finding.secret, finding.rule)
218
+ return values
219
+
220
+ def redact_text(self, text: str) -> str:
221
+ """The text with every detected value replaced by its placeholder."""
222
+ secrets: dict[str, str] = {}
223
+ for finding in self.findings:
224
+ if finding.secret:
225
+ secrets.setdefault(finding.secret, finding.rule)
226
+ return _replace(text, secrets)
227
+
228
+ def redact(self, note: Note) -> str:
229
+ """The note's text, redacted ("" for encrypted notes)."""
230
+ return _replace(note.text or "", self.for_note(note.id))
231
+
232
+ def notes_affected(self) -> int:
233
+ return len({f.note_id for f in self.findings})
234
+
235
+
236
+ def scan_notes(notes: list[Note]) -> ScanReport:
237
+ """Scan every note; the report carries the findings and can redact."""
238
+ plugins = _build_plugins()
239
+ findings: list[SecretFinding] = []
240
+ for note in notes:
241
+ findings.extend(_scan_note(note, plugins))
242
+ return ScanReport(findings)
@@ -0,0 +1,39 @@
1
+ Metadata-Version: 2.4
2
+ Name: bearkit
3
+ Version: 1.6.0
4
+ Summary: Python toolkit for the Bear notes app — read, search, secret detection, and verified writes
5
+ Keywords: bear,notes,markdown,macos
6
+ Author: Michel Tricot
7
+ Author-email: Michel Tricot <michel.tricot@gmail.com>
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Classifier: Development Status :: 5 - Production/Stable
11
+ Classifier: Operating System :: MacOS
12
+ Classifier: Programming Language :: Python :: 3.13
13
+ Classifier: Topic :: Software Development :: Libraries
14
+ Requires-Dist: detect-secrets>=1.5.0
15
+ Requires-Dist: rapidfuzz>=3.14.5
16
+ Requires-Python: >=3.13
17
+ Project-URL: Documentation, https://github.com/michel-tricot/bearcli/blob/main/docs/BEARKIT.md
18
+ Project-URL: Homepage, https://michel-tricot.github.io/bearcli/
19
+ Project-URL: Repository, https://github.com/michel-tricot/bearcli
20
+ Description-Content-Type: text/markdown
21
+
22
+ # 🐻 `bearkit`
23
+
24
+ The Python toolkit for the [Bear](https://bear.app) notes app: read your
25
+ notes, write them through the Bear app with verification, search them, and
26
+ detect secrets - all offline. macOS only.
27
+
28
+ ```python
29
+ from bearkit import Bear
30
+
31
+ with Bear() as bear:
32
+ for note in bear.list_notes(tag="work", limit=10):
33
+ print(note.title)
34
+ ```
35
+
36
+ Full API reference:
37
+ [docs/BEARKIT.md](https://github.com/michel-tricot/bearcli/blob/main/docs/BEARKIT.md).
38
+ `bearkit` powers [`bearcli`](https://pypi.org/project/bearcli/), the CLI and
39
+ terminal UI; install that for the full tool.
@@ -0,0 +1,13 @@
1
+ bearkit/__init__.py,sha256=9gCu5LUwl2eO_pmFPCsuwLm9p2lKXrKKPNl5ar8NM88,1611
2
+ bearkit/actions.py,sha256=nKhu4iUjS6-SAvsJKvoTqPRa0mj9lD9zKAOFJKb9OZY,2430
3
+ bearkit/bear.py,sha256=I5ZS8ZI07UDMMyflupFcl6L2XSeZBv_fHtqUYGPtkV0,6555
4
+ bearkit/db.py,sha256=WvXRuB4fHDZIlXH2NgHVo3HD8MzOMYzByG3nasYUeGQ,12946
5
+ bearkit/markdown.py,sha256=tTviQilh8iCMubU3Xif-yfx1z-OP3YMzbWWAOE53y3g,1734
6
+ bearkit/ops.py,sha256=pzmQDyOEhY-vYup7QYyVxlTJG4CCvH-xxLY_v4_GsgM,5492
7
+ bearkit/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
+ bearkit/search.py,sha256=KRvlBpd9jYpUbbnnT17kn4NHueR280lURxMgzICH1ZY,4814
9
+ bearkit/secrets.py,sha256=0XaI2o49sOo3sCS6lDIT_LmkLR3czx622CTu3AnIR6w,9077
10
+ bearkit-1.6.0.dist-info/licenses/LICENSE,sha256=6GQ08_KAylombOwcGnemMQM8Ai39XsWdW25vHPE6v_s,1070
11
+ bearkit-1.6.0.dist-info/WHEEL,sha256=eh7sammvW2TypMMMGKgsM83HyA_3qQ5Lgg3ynoecH3M,79
12
+ bearkit-1.6.0.dist-info/METADATA,sha256=st_vccwU2zCjvw06qXvQgEqTFA7KYrlRB1TbN57Px9U,1453
13
+ bearkit-1.6.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.8.24
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Michel Tricot
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.