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/__init__.py +0 -0
- bearcli/actions.py +71 -0
- bearcli/cli.py +788 -0
- bearcli/db.py +260 -0
- bearcli/export.py +250 -0
- bearcli/search.py +121 -0
- bearcli-1.0.0.dist-info/METADATA +159 -0
- bearcli-1.0.0.dist-info/RECORD +11 -0
- bearcli-1.0.0.dist-info/WHEEL +4 -0
- bearcli-1.0.0.dist-info/entry_points.txt +3 -0
- bearcli-1.0.0.dist-info/licenses/LICENSE +21 -0
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
|