bearcli 1.0.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.
bearcli/db.py ADDED
@@ -0,0 +1,260 @@
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 pathlib import Path
10
+
11
+ DEFAULT_DB_PATH = (
12
+ Path.home() / "Library/Group Containers/9K33E3U3T4.net.shinyfrog.bear" / "Application Data/database.sqlite"
13
+ )
14
+
15
+ # Core Data stores timestamps as seconds since 2001-01-01 UTC.
16
+ CORE_DATA_EPOCH = datetime(2001, 1, 1, tzinfo=UTC)
17
+
18
+
19
+ def core_data_to_datetime(value: float | None) -> datetime | None:
20
+ if value is None:
21
+ return None
22
+ return (CORE_DATA_EPOCH + timedelta(seconds=value)).astimezone()
23
+
24
+
25
+ def datetime_to_core_data(value: datetime) -> float:
26
+ if value.tzinfo is None:
27
+ value = value.astimezone()
28
+ return (value - CORE_DATA_EPOCH).total_seconds()
29
+
30
+
31
+ @dataclass
32
+ class Attachment:
33
+ id: str
34
+ filename: str
35
+ path: Path
36
+ size: int | None
37
+ exists: bool
38
+
39
+
40
+ @dataclass
41
+ class Note:
42
+ id: str
43
+ title: str
44
+ created: datetime | None
45
+ modified: datetime | None
46
+ pinned: bool
47
+ encrypted: bool
48
+ archived: bool
49
+ trashed: bool
50
+ tags: list[str] = field(default_factory=list)
51
+ text: str | None = None
52
+ attachments: list[Attachment] = field(default_factory=list)
53
+
54
+
55
+ class BearDB:
56
+ def __init__(self, path: Path = DEFAULT_DB_PATH):
57
+ if not path.exists():
58
+ raise FileNotFoundError(f"Bear database not found at {path}")
59
+ self.conn = sqlite3.connect(f"file:{path}?mode=ro", uri=True)
60
+ self.conn.row_factory = sqlite3.Row
61
+ self.files_dir = path.parent / "Local Files"
62
+ self._tags_join = self._detect_tags_join()
63
+
64
+ def close(self) -> None:
65
+ self.conn.close()
66
+
67
+ def _detect_tags_join(self) -> tuple[str, str, str]:
68
+ """Find the note<->tag join table; its numeric prefixes vary by Bear version.
69
+
70
+ Returns (table, note_column, tag_column).
71
+ """
72
+ rows = self.conn.execute(
73
+ "SELECT name FROM sqlite_master WHERE type = 'table' AND name LIKE 'Z\\_%TAGS' ESCAPE '\\'"
74
+ ).fetchall()
75
+ for (table,) in rows:
76
+ if not re.fullmatch(r"Z_\d+TAGS", table):
77
+ continue
78
+ columns = [r["name"] for r in self.conn.execute(f"PRAGMA table_info({table})")]
79
+ note_col = next((c for c in columns if re.fullmatch(r"Z_\d+NOTES", c)), None)
80
+ tag_col = next((c for c in columns if re.fullmatch(r"Z_\d+TAGS", c)), None)
81
+ if note_col and tag_col:
82
+ return table, note_col, tag_col
83
+ raise RuntimeError("Could not locate the note/tag join table in the Bear database")
84
+
85
+ def _tags_for_note(self, note_pk: int) -> list[str]:
86
+ table, note_col, tag_col = self._tags_join
87
+ rows = self.conn.execute(
88
+ f"""
89
+ SELECT t.ZTITLE FROM ZSFNOTETAG t
90
+ JOIN {table} j ON j.{tag_col} = t.Z_PK
91
+ WHERE j.{note_col} = ?
92
+ ORDER BY t.ZTITLE
93
+ """,
94
+ (note_pk,),
95
+ ).fetchall()
96
+ return [r["ZTITLE"] for r in rows]
97
+
98
+ def list_tags(self, include_empty: bool = False) -> list[tuple[str, int]]:
99
+ """All tags with their note counts (excluding trashed/deleted notes).
100
+
101
+ Bear keeps tag rows around after their last note is untagged (the app
102
+ hides them), so empty tags are excluded unless include_empty is set.
103
+ """
104
+ table, note_col, tag_col = self._tags_join
105
+ having = "" if include_empty else "HAVING COUNT(n.Z_PK) > 0"
106
+ rows = self.conn.execute(
107
+ f"""
108
+ SELECT t.ZTITLE, COUNT(n.Z_PK)
109
+ FROM ZSFNOTETAG t
110
+ LEFT JOIN {table} j ON j.{tag_col} = t.Z_PK
111
+ LEFT JOIN ZSFNOTE n
112
+ ON n.Z_PK = j.{note_col} AND n.ZTRASHED = 0 AND n.ZPERMANENTLYDELETED = 0
113
+ GROUP BY t.Z_PK
114
+ {having}
115
+ ORDER BY t.ZTITLE
116
+ """
117
+ ).fetchall()
118
+ return [(row[0], row[1]) for row in rows]
119
+
120
+ def _attachments_for_note(self, note_pk: int) -> list[Attachment]:
121
+ rows = self.conn.execute(
122
+ """
123
+ SELECT ZUNIQUEIDENTIFIER, ZFILENAME, ZFILESIZE FROM ZSFNOTEFILE
124
+ WHERE ZNOTE = ? AND ZPERMANENTLYDELETED = 0 ORDER BY ZFILENAME
125
+ """,
126
+ (note_pk,),
127
+ ).fetchall()
128
+ attachments = []
129
+ for row in rows:
130
+ # Images live under "Note Images", everything else under "Note Files";
131
+ # the database doesn't record which, so probe both.
132
+ candidates = [
133
+ self.files_dir / subdir / row["ZUNIQUEIDENTIFIER"] / row["ZFILENAME"]
134
+ for subdir in ("Note Images", "Note Files")
135
+ ]
136
+ path = next((c for c in candidates if c.exists()), candidates[0])
137
+ attachments.append(
138
+ Attachment(
139
+ id=row["ZUNIQUEIDENTIFIER"],
140
+ filename=row["ZFILENAME"],
141
+ path=path,
142
+ size=row["ZFILESIZE"],
143
+ exists=path.exists(),
144
+ )
145
+ )
146
+ return attachments
147
+
148
+ def list_notes(
149
+ self,
150
+ limit: int | None = None,
151
+ tag: str | None = None,
152
+ created_after: datetime | None = None,
153
+ created_before: datetime | None = None,
154
+ modified_after: datetime | None = None,
155
+ modified_before: datetime | None = None,
156
+ only: str | None = None,
157
+ include_trashed: bool = False,
158
+ include_archived: bool = False,
159
+ with_text: bool = False,
160
+ ) -> list[Note]:
161
+ table, note_col, tag_col = self._tags_join
162
+ # Deleted-pending-sync rows linger in the table; never show them.
163
+ where = ["n.ZPERMANENTLYDELETED = 0"]
164
+ params: list[object] = []
165
+
166
+ if only == "pinned":
167
+ where.append("n.ZPINNED = 1")
168
+ elif only == "encrypted":
169
+ where.append("n.ZENCRYPTED = 1")
170
+
171
+ if only == "trashed":
172
+ where.append("n.ZTRASHED = 1")
173
+ elif not include_trashed:
174
+ where.append("n.ZTRASHED = 0")
175
+ if only == "archived":
176
+ where.append("n.ZARCHIVED = 1")
177
+ elif not include_archived and only != "trashed":
178
+ # A note trashed from the archive keeps both flags; Bear shows it in
179
+ # the trash, so --only trashed must not exclude archived notes.
180
+ where.append("n.ZARCHIVED = 0")
181
+ if tag:
182
+ # Match the tag itself and its nested sub-tags (e.g. "work" matches "work/ideas").
183
+ where.append(
184
+ f"""n.Z_PK IN (
185
+ SELECT j.{note_col} FROM {table} j
186
+ JOIN ZSFNOTETAG t ON t.Z_PK = j.{tag_col}
187
+ WHERE t.ZTITLE = ? OR t.ZTITLE LIKE ? ESCAPE '\\'
188
+ )"""
189
+ )
190
+ escaped = tag.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
191
+ params += [tag, f"{escaped}/%"]
192
+ if created_after:
193
+ where.append("n.ZCREATIONDATE >= ?")
194
+ params.append(datetime_to_core_data(created_after))
195
+ if created_before:
196
+ where.append("n.ZCREATIONDATE < ?")
197
+ params.append(datetime_to_core_data(created_before))
198
+ if modified_after:
199
+ where.append("n.ZMODIFICATIONDATE >= ?")
200
+ params.append(datetime_to_core_data(modified_after))
201
+ if modified_before:
202
+ where.append("n.ZMODIFICATIONDATE < ?")
203
+ params.append(datetime_to_core_data(modified_before))
204
+
205
+ text_col = ", n.ZTEXT" if with_text else ""
206
+ query = f"""
207
+ SELECT n.Z_PK, n.ZUNIQUEIDENTIFIER, n.ZTITLE, n.ZCREATIONDATE,
208
+ n.ZMODIFICATIONDATE, n.ZPINNED, n.ZENCRYPTED, n.ZARCHIVED, n.ZTRASHED{text_col}
209
+ FROM ZSFNOTE n
210
+ """
211
+ if where:
212
+ query += " WHERE " + " AND ".join(where)
213
+ query += " ORDER BY n.ZMODIFICATIONDATE DESC"
214
+ if limit is not None:
215
+ query += " LIMIT ?"
216
+ params.append(limit)
217
+
218
+ notes = []
219
+ for row in self.conn.execute(query, params):
220
+ notes.append(
221
+ Note(
222
+ id=row["ZUNIQUEIDENTIFIER"],
223
+ title=row["ZTITLE"] or "(untitled)",
224
+ created=core_data_to_datetime(row["ZCREATIONDATE"]),
225
+ modified=core_data_to_datetime(row["ZMODIFICATIONDATE"]),
226
+ pinned=bool(row["ZPINNED"]),
227
+ encrypted=bool(row["ZENCRYPTED"]),
228
+ archived=bool(row["ZARCHIVED"]),
229
+ trashed=bool(row["ZTRASHED"]),
230
+ tags=self._tags_for_note(row["Z_PK"]),
231
+ text=row["ZTEXT"] if with_text else None,
232
+ )
233
+ )
234
+ return notes
235
+
236
+ def get_note(self, note_id: str) -> Note | None:
237
+ row = self.conn.execute(
238
+ """
239
+ SELECT Z_PK, ZUNIQUEIDENTIFIER, ZTITLE, ZTEXT, ZCREATIONDATE,
240
+ ZMODIFICATIONDATE, ZPINNED, ZENCRYPTED, ZARCHIVED, ZTRASHED
241
+ FROM ZSFNOTE
242
+ WHERE ZUNIQUEIDENTIFIER = ? COLLATE NOCASE
243
+ """,
244
+ (note_id,),
245
+ ).fetchone()
246
+ if row is None:
247
+ return None
248
+ return Note(
249
+ id=row["ZUNIQUEIDENTIFIER"],
250
+ title=row["ZTITLE"] or "(untitled)",
251
+ created=core_data_to_datetime(row["ZCREATIONDATE"]),
252
+ modified=core_data_to_datetime(row["ZMODIFICATIONDATE"]),
253
+ pinned=bool(row["ZPINNED"]),
254
+ encrypted=bool(row["ZENCRYPTED"]),
255
+ archived=bool(row["ZARCHIVED"]),
256
+ trashed=bool(row["ZTRASHED"]),
257
+ tags=self._tags_for_note(row["Z_PK"]),
258
+ text=row["ZTEXT"],
259
+ attachments=self._attachments_for_note(row["Z_PK"]),
260
+ )
bearcli/export.py ADDED
@@ -0,0 +1,250 @@
1
+ """Export Bear notes to self-contained per-note directories."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ import shutil
8
+ from collections import Counter
9
+ from collections.abc import Callable
10
+ from dataclasses import dataclass
11
+ from pathlib import Path
12
+ from urllib.parse import quote
13
+
14
+ from bearcli.db import BearDB, Note
15
+
16
+ NOTE_FILENAME = "README.md"
17
+ ATTACHMENTS_DIRNAME = "attachments"
18
+
19
+
20
+ @dataclass
21
+ class ExportResult:
22
+ written: int = 0
23
+ unchanged: int = 0
24
+ removed: int = 0
25
+ skipped_encrypted: int = 0
26
+ index_updated: bool = False
27
+ index_skipped: bool = False
28
+
29
+
30
+ def slugify(title: str, max_length: int = 60) -> str:
31
+ slug = re.sub(r"[^a-z0-9]+", "-", title.lower()).strip("-")
32
+ return slug[:max_length].rstrip("-") or "untitled"
33
+
34
+
35
+ def _dirnames(notes: list[Note]) -> dict[str, str]:
36
+ """Map note id -> directory name: <slug>-<first 8 id chars>.
37
+
38
+ The id fragment makes names unique without title-dedup suffixes. In the
39
+ astronomically rare case two notes share both slug and id prefix, those
40
+ notes use their full id so the outcome never depends on iteration order.
41
+ """
42
+ short = {n.id: f"{slugify(n.title)}-{n.id[:8].lower()}" for n in notes}
43
+ counts = Counter(short.values())
44
+ return {n.id: short[n.id] if counts[short[n.id]] == 1 else f"{slugify(n.title)}-{n.id.lower()}" for n in notes}
45
+
46
+
47
+ def _frontmatter(note: Note) -> str:
48
+ lines = [
49
+ "---",
50
+ f"id: {note.id}",
51
+ f"title: {json.dumps(note.title, ensure_ascii=False)}",
52
+ f"tags: [{', '.join(note.tags)}]",
53
+ ]
54
+ if note.created:
55
+ lines.append(f"created: {note.created.isoformat()}")
56
+ if note.modified:
57
+ lines.append(f"modified: {note.modified.isoformat()}")
58
+ lines.append("---")
59
+ return "\n".join(lines)
60
+
61
+
62
+ def _parse_frontmatter(path: Path) -> dict[str, str]:
63
+ fields: dict[str, str] = {}
64
+ try:
65
+ with path.open() as fh:
66
+ if fh.readline().rstrip("\n") != "---":
67
+ return fields
68
+ for line in fh:
69
+ line = line.rstrip("\n")
70
+ if line == "---":
71
+ break
72
+ key, _, value = line.partition(": ")
73
+ fields[key] = value
74
+ except OSError:
75
+ pass
76
+ return fields
77
+
78
+
79
+ INDEX_MARKER = "generated-by: bearcli"
80
+
81
+
82
+ def _entry_flags(entry: dict) -> str:
83
+ flags = ""
84
+ if entry["attachments"]:
85
+ flags += "📎"
86
+ if entry["encrypted"]:
87
+ flags += "🔒"
88
+ if entry["archived"]:
89
+ flags += "🗄"
90
+ return flags
91
+
92
+
93
+ def _index_rows(entries: list[dict]) -> list[str]:
94
+ rows = ["| Note | Tags | Modified | |", "|---|---|---|---|"]
95
+ for e in entries:
96
+ title = e["title"].replace("|", "\\|")
97
+ link = f"[{title}]({e['path']})" if e["path"] else title
98
+ modified = (e["modified"] or "")[:10]
99
+ rows.append(f"| {link} | {', '.join(e['tags'])} | {modified} | {_entry_flags(e)} |")
100
+ return rows
101
+
102
+
103
+ def _index_markdown(entries: list[dict]) -> str:
104
+ entries = sorted(entries, key=lambda e: e["modified"] or "", reverse=True)
105
+ lines = [
106
+ "---",
107
+ INDEX_MARKER,
108
+ "---",
109
+ "",
110
+ "# Bear notes",
111
+ "",
112
+ f"{len(entries)} notes · 📎 attachments · 🔒 encrypted · 🗄 archived",
113
+ "",
114
+ ]
115
+ pinned = [e for e in entries if e["pinned"]]
116
+ if pinned:
117
+ lines += ["## 📌 Pinned", ""] + _index_rows(pinned) + [""]
118
+ rest = [e for e in entries if not e["pinned"]]
119
+ by_year: dict[str, list[dict]] = {}
120
+ for e in rest:
121
+ year = e["modified"][:4] if e["modified"] else "Undated"
122
+ by_year.setdefault(year, []).append(e)
123
+ for year in sorted(by_year, reverse=True):
124
+ lines += [f"## {year}", ""] + _index_rows(by_year[year]) + [""]
125
+ return "\n".join(lines)
126
+
127
+
128
+ def _write_if_changed(path: Path, content: str) -> bool:
129
+ try:
130
+ if path.read_text() == content:
131
+ return False
132
+ except OSError:
133
+ pass
134
+ path.write_text(content)
135
+ return True
136
+
137
+
138
+ def _rewrite_refs(note: Note) -> str:
139
+ text = note.text or ""
140
+ for att in note.attachments:
141
+ target = quote(f"{ATTACHMENTS_DIRNAME}/{att.filename}")
142
+ for ref in {att.filename, quote(att.filename)}:
143
+ text = text.replace(f"]({ref})", f"]({target})")
144
+ return text
145
+
146
+
147
+ def export_notes(
148
+ db: BearDB,
149
+ dest: Path,
150
+ sync: bool = False,
151
+ progress: Callable[[str], None] | None = None,
152
+ ) -> ExportResult:
153
+ """Write every non-trashed note as dest/<slug>/README.md plus attachments/.
154
+
155
+ With sync=True, notes whose id and modified timestamp match the existing
156
+ README's frontmatter are left untouched. In both modes, directories for
157
+ notes that no longer exist (or whose title changed slug) are removed; only
158
+ directories whose README carries an `id:` frontmatter field are ever touched.
159
+ """
160
+ dest.mkdir(parents=True, exist_ok=True)
161
+ result = ExportResult()
162
+
163
+ def report(message: str) -> None:
164
+ if progress is not None:
165
+ progress(message)
166
+
167
+ report("Reading notes from Bear…")
168
+ summaries = db.list_notes(limit=None, include_archived=True)
169
+ dirnames = _dirnames(summaries)
170
+
171
+ entries: list[dict] = []
172
+
173
+ def add_entry(note: Note, path: str | None, attachments: bool) -> None:
174
+ entries.append(
175
+ {
176
+ "id": note.id,
177
+ "title": note.title,
178
+ "path": path,
179
+ "tags": note.tags,
180
+ "created": note.created.isoformat() if note.created else None,
181
+ "modified": note.modified.isoformat() if note.modified else None,
182
+ "pinned": note.pinned,
183
+ "encrypted": note.encrypted,
184
+ "archived": note.archived,
185
+ "attachments": attachments,
186
+ }
187
+ )
188
+
189
+ for position, summary in enumerate(summaries, start=1):
190
+ report(f"[{position}/{len(summaries)}] {summary.title}")
191
+ if summary.encrypted:
192
+ result.skipped_encrypted += 1
193
+ add_entry(summary, None, False)
194
+ continue
195
+
196
+ slug = dirnames[summary.id]
197
+ note_dir = dest / slug
198
+ note_path = note_dir / NOTE_FILENAME
199
+
200
+ modified_iso = summary.modified.isoformat() if summary.modified else ""
201
+ if sync and note_path.exists():
202
+ existing = _parse_frontmatter(note_path)
203
+ if existing.get("id") == summary.id and existing.get("modified") == modified_iso:
204
+ result.unchanged += 1
205
+ add_entry(summary, f"{slug}/", (note_dir / ATTACHMENTS_DIRNAME).exists())
206
+ continue
207
+
208
+ note = db.get_note(summary.id)
209
+ if note is None or note.text is None:
210
+ result.skipped_encrypted += 1
211
+ add_entry(summary, None, False)
212
+ continue
213
+
214
+ note_dir.mkdir(exist_ok=True)
215
+ note_path.write_text(f"{_frontmatter(note)}\n{_rewrite_refs(note)}\n")
216
+
217
+ attach_dir = note_dir / ATTACHMENTS_DIRNAME
218
+ if attach_dir.exists():
219
+ shutil.rmtree(attach_dir)
220
+ for att in note.attachments:
221
+ if not att.exists:
222
+ continue
223
+ attach_dir.mkdir(exist_ok=True)
224
+ shutil.copy2(att.path, attach_dir / att.filename)
225
+ result.written += 1
226
+ add_entry(note, f"{slug}/", attach_dir.exists())
227
+
228
+ # Remove directories for notes that were deleted in Bear or whose slug changed.
229
+ report("Cleaning up removed notes…")
230
+ current_dirs = set(dirnames.values())
231
+ for stale in dest.iterdir():
232
+ if not stale.is_dir() or stale.name in current_dirs:
233
+ continue
234
+ if not _parse_frontmatter(stale / NOTE_FILENAME).get("id"):
235
+ continue
236
+ shutil.rmtree(stale)
237
+ result.removed += 1
238
+
239
+ report("Writing index…")
240
+ index_path = dest / "README.md"
241
+ if index_path.exists() and _parse_frontmatter(index_path).get("generated-by") != "bearcli":
242
+ result.index_skipped = True
243
+ else:
244
+ result.index_updated = _write_if_changed(index_path, _index_markdown(entries))
245
+ result.index_updated |= _write_if_changed(
246
+ dest / "index.json",
247
+ json.dumps(sorted(entries, key=lambda e: e["title"].lower()), indent=2, ensure_ascii=False) + "\n",
248
+ )
249
+
250
+ return result
bearcli/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 bearcli.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