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/cli.py
ADDED
|
@@ -0,0 +1,788 @@
|
|
|
1
|
+
"""Typer CLI for reading notes from the Bear note app."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import json
|
|
7
|
+
import re
|
|
8
|
+
import sys
|
|
9
|
+
from collections.abc import Callable
|
|
10
|
+
from datetime import UTC, datetime, timedelta
|
|
11
|
+
from enum import StrEnum
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Annotated
|
|
14
|
+
from urllib.parse import quote
|
|
15
|
+
|
|
16
|
+
import typer
|
|
17
|
+
from rich import box
|
|
18
|
+
from rich.console import Console
|
|
19
|
+
from rich.markup import escape as rich_escape
|
|
20
|
+
from rich.table import Table
|
|
21
|
+
|
|
22
|
+
from bearcli import actions
|
|
23
|
+
from bearcli.db import DEFAULT_DB_PATH, BearDB, Note
|
|
24
|
+
from bearcli.export import export_notes
|
|
25
|
+
from bearcli.search import naive_search, search_notes
|
|
26
|
+
|
|
27
|
+
app = typer.Typer(help="Read notes from the Bear note app.", no_args_is_help=True, add_completion=False)
|
|
28
|
+
note_app = typer.Typer(help="Create, read, and modify notes.", no_args_is_help=True)
|
|
29
|
+
tag_app = typer.Typer(help="List and manage tags.", no_args_is_help=True)
|
|
30
|
+
app.add_typer(note_app, name="note")
|
|
31
|
+
app.add_typer(tag_app, name="tag")
|
|
32
|
+
console = Console()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class OutputFormat(StrEnum):
|
|
36
|
+
table = "table"
|
|
37
|
+
json = "json"
|
|
38
|
+
text = "text"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class OnlyFilter(StrEnum):
|
|
42
|
+
pinned = "pinned"
|
|
43
|
+
encrypted = "encrypted"
|
|
44
|
+
trashed = "trashed"
|
|
45
|
+
archived = "archived"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _note_to_dict(note: Note, with_text: bool = False) -> dict:
|
|
49
|
+
data = {
|
|
50
|
+
"id": note.id,
|
|
51
|
+
"title": note.title,
|
|
52
|
+
"tags": note.tags,
|
|
53
|
+
"created": note.created.isoformat() if note.created else None,
|
|
54
|
+
"modified": note.modified.isoformat() if note.modified else None,
|
|
55
|
+
"pinned": note.pinned,
|
|
56
|
+
"encrypted": note.encrypted,
|
|
57
|
+
"archived": note.archived,
|
|
58
|
+
"trashed": note.trashed,
|
|
59
|
+
}
|
|
60
|
+
if with_text:
|
|
61
|
+
data["text"] = note.text
|
|
62
|
+
data["attachments"] = [
|
|
63
|
+
{
|
|
64
|
+
"filename": a.filename,
|
|
65
|
+
"path": str(a.path),
|
|
66
|
+
"size": a.size,
|
|
67
|
+
"exists": a.exists,
|
|
68
|
+
}
|
|
69
|
+
for a in note.attachments
|
|
70
|
+
]
|
|
71
|
+
return data
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _note_status(note: Note) -> str:
|
|
75
|
+
flags = (
|
|
76
|
+
("pinned", note.pinned),
|
|
77
|
+
("encrypted", note.encrypted),
|
|
78
|
+
("trashed", note.trashed),
|
|
79
|
+
("archived", note.archived),
|
|
80
|
+
)
|
|
81
|
+
return ",".join(s for s, on in flags if on)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _resolve_attachments(note: Note) -> str:
|
|
85
|
+
"""Rewrite bare attachment filenames in markdown links to absolute paths.
|
|
86
|
+
|
|
87
|
+
Bear percent-encodes filenames in the note text (e.g. spaces as %20), so match
|
|
88
|
+
both the raw and encoded forms; emit encoded paths to keep the links valid.
|
|
89
|
+
"""
|
|
90
|
+
text = note.text or ""
|
|
91
|
+
for att in note.attachments:
|
|
92
|
+
target = quote(str(att.path))
|
|
93
|
+
for ref in {att.filename, quote(att.filename)}:
|
|
94
|
+
text = text.replace(f"]({ref})", f"]({target})")
|
|
95
|
+
return text
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
DbPathOption = Annotated[
|
|
99
|
+
Path,
|
|
100
|
+
typer.Option("--db", envvar="BEAR_DB_PATH", help="Path to the Bear SQLite database."),
|
|
101
|
+
]
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _open_db(path: Path) -> BearDB:
|
|
105
|
+
try:
|
|
106
|
+
return BearDB(path)
|
|
107
|
+
except FileNotFoundError as exc:
|
|
108
|
+
console.print(f"[red]Error:[/red] {exc}")
|
|
109
|
+
raise typer.Exit(1) from None
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _parse_date(value: str | None, option: str) -> datetime | None:
|
|
113
|
+
if value is None:
|
|
114
|
+
return None
|
|
115
|
+
try:
|
|
116
|
+
return datetime.fromisoformat(value)
|
|
117
|
+
except ValueError:
|
|
118
|
+
console.print(
|
|
119
|
+
f"[red]Error:[/red] invalid date for {option}: {value!r} "
|
|
120
|
+
"(expected ISO format, e.g. 2026-07-01 or 2026-07-01T14:30)"
|
|
121
|
+
)
|
|
122
|
+
raise typer.Exit(2) from None
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@note_app.command("list")
|
|
126
|
+
def list_notes(
|
|
127
|
+
limit: Annotated[int, typer.Option("--limit", "-n", help="Maximum number of notes.")] = 20,
|
|
128
|
+
tag: Annotated[
|
|
129
|
+
str | None,
|
|
130
|
+
typer.Option("--tag", "-t", help="Only notes with this tag (includes nested sub-tags)."),
|
|
131
|
+
] = None,
|
|
132
|
+
modified_after: Annotated[
|
|
133
|
+
str | None, typer.Option("--modified-after", help="Modified on or after this date.")
|
|
134
|
+
] = None,
|
|
135
|
+
modified_before: Annotated[str | None, typer.Option("--modified-before", help="Modified before this date.")] = None,
|
|
136
|
+
created_after: Annotated[str | None, typer.Option("--created-after", help="Created on or after this date.")] = None,
|
|
137
|
+
created_before: Annotated[str | None, typer.Option("--created-before", help="Created before this date.")] = None,
|
|
138
|
+
only: Annotated[
|
|
139
|
+
OnlyFilter | None,
|
|
140
|
+
typer.Option(
|
|
141
|
+
"--only",
|
|
142
|
+
help="Only notes with this status: pinned, encrypted, trashed, or archived.",
|
|
143
|
+
),
|
|
144
|
+
] = None,
|
|
145
|
+
all_notes: Annotated[bool, typer.Option("--all", "-a", help="No limit (overrides --limit).")] = False,
|
|
146
|
+
trashed: Annotated[bool, typer.Option("--trashed", help="Include trashed notes.")] = False,
|
|
147
|
+
archived: Annotated[bool, typer.Option("--archived", help="Include archived notes.")] = False,
|
|
148
|
+
ids_only: Annotated[bool, typer.Option("--ids", help="Print only note identifiers, one per line.")] = False,
|
|
149
|
+
fmt: Annotated[
|
|
150
|
+
OutputFormat,
|
|
151
|
+
typer.Option(
|
|
152
|
+
"--format",
|
|
153
|
+
"-f",
|
|
154
|
+
help="Output format: table, json, or text (tab-separated: id, modified, tags, title).",
|
|
155
|
+
),
|
|
156
|
+
] = OutputFormat.table,
|
|
157
|
+
db_path: DbPathOption = DEFAULT_DB_PATH,
|
|
158
|
+
) -> None:
|
|
159
|
+
"""List notes, most recently modified first."""
|
|
160
|
+
db = _open_db(db_path)
|
|
161
|
+
try:
|
|
162
|
+
notes = db.list_notes(
|
|
163
|
+
limit=None if all_notes else limit,
|
|
164
|
+
tag=tag,
|
|
165
|
+
created_after=_parse_date(created_after, "--created-after"),
|
|
166
|
+
created_before=_parse_date(created_before, "--created-before"),
|
|
167
|
+
modified_after=_parse_date(modified_after, "--modified-after"),
|
|
168
|
+
modified_before=_parse_date(modified_before, "--modified-before"),
|
|
169
|
+
only=only.value if only else None,
|
|
170
|
+
include_trashed=trashed,
|
|
171
|
+
include_archived=archived,
|
|
172
|
+
)
|
|
173
|
+
finally:
|
|
174
|
+
db.close()
|
|
175
|
+
|
|
176
|
+
if ids_only:
|
|
177
|
+
for note in notes:
|
|
178
|
+
print(note.id)
|
|
179
|
+
return
|
|
180
|
+
|
|
181
|
+
if fmt is OutputFormat.json:
|
|
182
|
+
print(json.dumps([_note_to_dict(n) for n in notes], indent=2, ensure_ascii=False))
|
|
183
|
+
return
|
|
184
|
+
|
|
185
|
+
if fmt is OutputFormat.text:
|
|
186
|
+
for note in notes:
|
|
187
|
+
modified = note.modified.isoformat() if note.modified else ""
|
|
188
|
+
print(f"{note.id}\t{modified}\t{','.join(note.tags)}\t{_note_status(note)}\t{note.title}")
|
|
189
|
+
return
|
|
190
|
+
|
|
191
|
+
if not notes:
|
|
192
|
+
console.print("No notes found.")
|
|
193
|
+
return
|
|
194
|
+
|
|
195
|
+
table = Table(box=box.ROUNDED, header_style="bold")
|
|
196
|
+
table.add_column("ID", style="dim", no_wrap=True)
|
|
197
|
+
table.add_column("Title", overflow="ellipsis", max_width=50)
|
|
198
|
+
table.add_column("Tags", style="cyan", overflow="ellipsis", max_width=30)
|
|
199
|
+
table.add_column("Status", style="yellow", no_wrap=True)
|
|
200
|
+
table.add_column("Modified", no_wrap=True)
|
|
201
|
+
for note in notes:
|
|
202
|
+
table.add_row(
|
|
203
|
+
note.id,
|
|
204
|
+
note.title,
|
|
205
|
+
", ".join(note.tags),
|
|
206
|
+
_note_status(note),
|
|
207
|
+
note.modified.strftime("%Y-%m-%d %H:%M") if note.modified else "",
|
|
208
|
+
)
|
|
209
|
+
console.print(table)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
@note_app.command()
|
|
213
|
+
def get(
|
|
214
|
+
note_id: Annotated[str, typer.Argument(help="Note identifier (UUID from `bearcli list`).")],
|
|
215
|
+
meta: Annotated[bool, typer.Option("--meta", help="Print metadata frontmatter before the content.")] = False,
|
|
216
|
+
resolve_attachments: Annotated[
|
|
217
|
+
bool,
|
|
218
|
+
typer.Option(
|
|
219
|
+
"--resolve-attachments",
|
|
220
|
+
"-r",
|
|
221
|
+
help="Rewrite attachment references in the content to absolute file paths.",
|
|
222
|
+
),
|
|
223
|
+
] = False,
|
|
224
|
+
fmt: Annotated[
|
|
225
|
+
OutputFormat,
|
|
226
|
+
typer.Option(
|
|
227
|
+
"--format",
|
|
228
|
+
"-f",
|
|
229
|
+
help="Output format: text (raw content), json (metadata + content), or table.",
|
|
230
|
+
),
|
|
231
|
+
] = OutputFormat.text,
|
|
232
|
+
db_path: DbPathOption = DEFAULT_DB_PATH,
|
|
233
|
+
) -> None:
|
|
234
|
+
"""Print the content of a note."""
|
|
235
|
+
db = _open_db(db_path)
|
|
236
|
+
try:
|
|
237
|
+
note = db.get_note(note_id)
|
|
238
|
+
finally:
|
|
239
|
+
db.close()
|
|
240
|
+
|
|
241
|
+
if note is None:
|
|
242
|
+
console.print(f"[red]Error:[/red] no note with id {note_id!r}")
|
|
243
|
+
raise typer.Exit(1)
|
|
244
|
+
if note.encrypted or note.text is None:
|
|
245
|
+
console.print(f"[red]Error:[/red] note {note.id} is encrypted; its content is unavailable")
|
|
246
|
+
raise typer.Exit(1)
|
|
247
|
+
|
|
248
|
+
text = _resolve_attachments(note) if resolve_attachments else note.text
|
|
249
|
+
|
|
250
|
+
if fmt is OutputFormat.json:
|
|
251
|
+
data = _note_to_dict(note, with_text=True)
|
|
252
|
+
data["text"] = text
|
|
253
|
+
print(json.dumps(data, indent=2, ensure_ascii=False))
|
|
254
|
+
return
|
|
255
|
+
|
|
256
|
+
if fmt is OutputFormat.table:
|
|
257
|
+
table = Table(box=box.ROUNDED, show_header=False)
|
|
258
|
+
table.add_column(style="bold")
|
|
259
|
+
table.add_column()
|
|
260
|
+
table.add_row("ID", note.id)
|
|
261
|
+
table.add_row("Title", note.title)
|
|
262
|
+
table.add_row("Tags", ", ".join(note.tags))
|
|
263
|
+
if note.created:
|
|
264
|
+
table.add_row("Created", note.created.strftime("%Y-%m-%d %H:%M"))
|
|
265
|
+
if note.modified:
|
|
266
|
+
table.add_row("Modified", note.modified.strftime("%Y-%m-%d %H:%M"))
|
|
267
|
+
for i, att in enumerate(note.attachments):
|
|
268
|
+
label = "Attachments" if i == 0 else ""
|
|
269
|
+
missing = "" if att.exists else " (missing)"
|
|
270
|
+
table.add_row(label, f"{att.path}{missing}")
|
|
271
|
+
console.print(table)
|
|
272
|
+
console.print()
|
|
273
|
+
print(text)
|
|
274
|
+
return
|
|
275
|
+
|
|
276
|
+
if meta:
|
|
277
|
+
print("---")
|
|
278
|
+
print(f"id: {note.id}")
|
|
279
|
+
print(f"title: {note.title}")
|
|
280
|
+
print(f"tags: [{', '.join(note.tags)}]")
|
|
281
|
+
if note.created:
|
|
282
|
+
print(f"created: {note.created.isoformat()}")
|
|
283
|
+
if note.modified:
|
|
284
|
+
print(f"modified: {note.modified.isoformat()}")
|
|
285
|
+
if note.attachments:
|
|
286
|
+
print(f"attachments: [{', '.join(str(a.path) for a in note.attachments)}]")
|
|
287
|
+
print("---")
|
|
288
|
+
print(text)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
@app.command()
|
|
292
|
+
def export(
|
|
293
|
+
dest: Annotated[Path, typer.Argument(help="Destination directory for the markdown files.")],
|
|
294
|
+
sync: Annotated[
|
|
295
|
+
bool,
|
|
296
|
+
typer.Option(
|
|
297
|
+
"--sync",
|
|
298
|
+
help="Only rewrite notes that changed since the last export instead of everything.",
|
|
299
|
+
),
|
|
300
|
+
] = False,
|
|
301
|
+
db_path: DbPathOption = DEFAULT_DB_PATH,
|
|
302
|
+
) -> None:
|
|
303
|
+
"""Export all notes as markdown files with frontmatter and attachments."""
|
|
304
|
+
db = _open_db(db_path)
|
|
305
|
+
try:
|
|
306
|
+
with console.status("Exporting…", spinner="dots") as status:
|
|
307
|
+
result = export_notes(db, dest, sync=sync, progress=lambda msg: status.update(rich_escape(msg)))
|
|
308
|
+
finally:
|
|
309
|
+
db.close()
|
|
310
|
+
|
|
311
|
+
parts = [f"{result.written} written"]
|
|
312
|
+
if sync:
|
|
313
|
+
parts.append(f"{result.unchanged} unchanged")
|
|
314
|
+
if result.removed:
|
|
315
|
+
parts.append(f"{result.removed} removed")
|
|
316
|
+
if result.skipped_encrypted:
|
|
317
|
+
parts.append(f"{result.skipped_encrypted} encrypted skipped")
|
|
318
|
+
if result.index_updated:
|
|
319
|
+
parts.append("index updated")
|
|
320
|
+
console.print(f"Exported to {dest}: " + ", ".join(parts))
|
|
321
|
+
if result.index_skipped:
|
|
322
|
+
console.print("[yellow]Warning:[/yellow] README.md exists but was not generated by bearcli; left untouched")
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _text_or_stdin(text: str | None) -> str | None:
|
|
326
|
+
if text is None and not sys.stdin.isatty():
|
|
327
|
+
return sys.stdin.read()
|
|
328
|
+
return text
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _require_note(db: BearDB, note_id: str) -> Note:
|
|
332
|
+
note = db.get_note(note_id)
|
|
333
|
+
if note is None:
|
|
334
|
+
console.print(f"[red]Error:[/red] no note with id {note_id!r}")
|
|
335
|
+
raise typer.Exit(1)
|
|
336
|
+
return note
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _verify(ok: Callable[[], bool], success: str, failure: str) -> None:
|
|
340
|
+
if actions.wait_for(ok):
|
|
341
|
+
console.print(success)
|
|
342
|
+
else:
|
|
343
|
+
console.print(f"[red]Error:[/red] {failure}")
|
|
344
|
+
raise typer.Exit(1)
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _create_and_report(db: BearDB, title: str, text: str | None, tags: list[str] | None) -> None:
|
|
348
|
+
started = datetime.now(UTC)
|
|
349
|
+
actions.create_note(title, text=text, tags=tags)
|
|
350
|
+
|
|
351
|
+
def find_created() -> Note | None:
|
|
352
|
+
candidates = db.list_notes(limit=10)
|
|
353
|
+
return next(
|
|
354
|
+
(n for n in candidates if n.title == title and n.created and n.created >= started - timedelta(seconds=5)),
|
|
355
|
+
None,
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
if actions.wait_for(lambda: find_created() is not None):
|
|
359
|
+
created = find_created()
|
|
360
|
+
console.print(f"Created note {created.id}" if created else "Created note")
|
|
361
|
+
else:
|
|
362
|
+
console.print("[red]Error:[/red] note did not appear in the Bear database; is Bear able to run?")
|
|
363
|
+
raise typer.Exit(1)
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
@note_app.command()
|
|
367
|
+
def create(
|
|
368
|
+
title: Annotated[str, typer.Argument(help="Title of the new note.")],
|
|
369
|
+
text: Annotated[str | None, typer.Option("--text", help="Note body (reads stdin if piped).")] = None,
|
|
370
|
+
tags: Annotated[list[str] | None, typer.Option("--tag", "-t", help="Tag to add (repeatable).")] = None,
|
|
371
|
+
db_path: DbPathOption = DEFAULT_DB_PATH,
|
|
372
|
+
) -> None:
|
|
373
|
+
"""Create a new note in Bear."""
|
|
374
|
+
db = _open_db(db_path)
|
|
375
|
+
try:
|
|
376
|
+
_create_and_report(db, title, _text_or_stdin(text), tags)
|
|
377
|
+
finally:
|
|
378
|
+
db.close()
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
@note_app.command()
|
|
382
|
+
def append(
|
|
383
|
+
note_id: Annotated[str, typer.Argument(help="Note identifier.")],
|
|
384
|
+
text: Annotated[str | None, typer.Option("--text", help="Text to add (reads stdin if piped).")] = None,
|
|
385
|
+
prepend: Annotated[bool, typer.Option("--prepend", help="Add at the top instead of the bottom.")] = False,
|
|
386
|
+
db_path: DbPathOption = DEFAULT_DB_PATH,
|
|
387
|
+
) -> None:
|
|
388
|
+
"""Append (or prepend) text to an existing note."""
|
|
389
|
+
body = _text_or_stdin(text)
|
|
390
|
+
if body is None:
|
|
391
|
+
console.print("[red]Error:[/red] provide --text or pipe content on stdin")
|
|
392
|
+
raise typer.Exit(2)
|
|
393
|
+
db = _open_db(db_path)
|
|
394
|
+
try:
|
|
395
|
+
before = _require_note(db, note_id)
|
|
396
|
+
actions.add_text(before.id, body, mode="prepend" if prepend else "append")
|
|
397
|
+
_verify(
|
|
398
|
+
lambda: (
|
|
399
|
+
(n := db.get_note(before.id)) is not None
|
|
400
|
+
and n.modified is not None
|
|
401
|
+
and (before.modified is None or n.modified > before.modified)
|
|
402
|
+
),
|
|
403
|
+
f"Updated note {before.id}",
|
|
404
|
+
"note was not modified; is Bear able to run?",
|
|
405
|
+
)
|
|
406
|
+
finally:
|
|
407
|
+
db.close()
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
@note_app.command()
|
|
411
|
+
def trash(
|
|
412
|
+
note_id: Annotated[str, typer.Argument(help="Note identifier.")],
|
|
413
|
+
db_path: DbPathOption = DEFAULT_DB_PATH,
|
|
414
|
+
) -> None:
|
|
415
|
+
"""Move a note to Bear's trash."""
|
|
416
|
+
db = _open_db(db_path)
|
|
417
|
+
try:
|
|
418
|
+
note = _require_note(db, note_id)
|
|
419
|
+
if note.trashed:
|
|
420
|
+
console.print(f"Note {note.id} is already in the trash")
|
|
421
|
+
return
|
|
422
|
+
actions.trash_note(note.id)
|
|
423
|
+
_verify(
|
|
424
|
+
lambda: (n := db.get_note(note.id)) is not None and n.trashed,
|
|
425
|
+
f"Trashed note {note.id}",
|
|
426
|
+
"note was not trashed; is Bear able to run?",
|
|
427
|
+
)
|
|
428
|
+
finally:
|
|
429
|
+
db.close()
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
@note_app.command()
|
|
433
|
+
def archive(
|
|
434
|
+
note_id: Annotated[str, typer.Argument(help="Note identifier.")],
|
|
435
|
+
db_path: DbPathOption = DEFAULT_DB_PATH,
|
|
436
|
+
) -> None:
|
|
437
|
+
"""Archive a note."""
|
|
438
|
+
db = _open_db(db_path)
|
|
439
|
+
try:
|
|
440
|
+
note = _require_note(db, note_id)
|
|
441
|
+
if note.archived:
|
|
442
|
+
console.print(f"Note {note.id} is already archived")
|
|
443
|
+
return
|
|
444
|
+
actions.archive_note(note.id)
|
|
445
|
+
_verify(
|
|
446
|
+
lambda: (n := db.get_note(note.id)) is not None and n.archived,
|
|
447
|
+
f"Archived note {note.id}",
|
|
448
|
+
"note was not archived; is Bear able to run?",
|
|
449
|
+
)
|
|
450
|
+
finally:
|
|
451
|
+
db.close()
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
@tag_app.command("list")
|
|
455
|
+
def tags(
|
|
456
|
+
fmt: Annotated[
|
|
457
|
+
OutputFormat,
|
|
458
|
+
typer.Option("--format", "-f", help="Output format: table, json, or text (tab-separated: count, tag)."),
|
|
459
|
+
] = OutputFormat.table,
|
|
460
|
+
include_empty: Annotated[
|
|
461
|
+
bool, typer.Option("--all", "-a", help="Include empty tags (Bear keeps them hidden after their last note).")
|
|
462
|
+
] = False,
|
|
463
|
+
db_path: DbPathOption = DEFAULT_DB_PATH,
|
|
464
|
+
) -> None:
|
|
465
|
+
"""List all tags with their note counts."""
|
|
466
|
+
db = _open_db(db_path)
|
|
467
|
+
try:
|
|
468
|
+
all_tags = db.list_tags(include_empty=include_empty)
|
|
469
|
+
finally:
|
|
470
|
+
db.close()
|
|
471
|
+
|
|
472
|
+
if fmt is OutputFormat.json:
|
|
473
|
+
print(json.dumps([{"tag": t, "notes": c} for t, c in all_tags], indent=2, ensure_ascii=False))
|
|
474
|
+
return
|
|
475
|
+
if fmt is OutputFormat.text:
|
|
476
|
+
for t, c in all_tags:
|
|
477
|
+
print(f"{c}\t{t}")
|
|
478
|
+
return
|
|
479
|
+
|
|
480
|
+
table = Table(box=box.ROUNDED, header_style="bold")
|
|
481
|
+
table.add_column("Tag", style="cyan")
|
|
482
|
+
table.add_column("Notes", justify="right")
|
|
483
|
+
for t, c in all_tags:
|
|
484
|
+
table.add_row(t, str(c))
|
|
485
|
+
console.print(table)
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def _tag_marker(name: str) -> str:
|
|
489
|
+
# Tags containing anything beyond word chars, '/', or '-' need the #...# form.
|
|
490
|
+
return f"#{name}#" if re.search(r"[^\w/-]", name) else f"#{name}"
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def _has_tag(note: Note, name: str) -> bool:
|
|
494
|
+
return name.lower() in (t.lower() for t in note.tags)
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
@note_app.command()
|
|
498
|
+
def tag(
|
|
499
|
+
note_id: Annotated[str, typer.Argument(help="Note identifier.")],
|
|
500
|
+
name: Annotated[str, typer.Argument(help="Tag to add (without the leading #).")],
|
|
501
|
+
db_path: DbPathOption = DEFAULT_DB_PATH,
|
|
502
|
+
) -> None:
|
|
503
|
+
"""Add a tag to a note."""
|
|
504
|
+
db = _open_db(db_path)
|
|
505
|
+
try:
|
|
506
|
+
note = _require_note(db, note_id)
|
|
507
|
+
if _has_tag(note, name):
|
|
508
|
+
console.print(f"Note {note.id} already has tag {name!r}")
|
|
509
|
+
return
|
|
510
|
+
actions.add_text(note.id, _tag_marker(name), mode="append")
|
|
511
|
+
_verify(
|
|
512
|
+
lambda: (n := db.get_note(note.id)) is not None and _has_tag(n, name),
|
|
513
|
+
f"Tagged note {note.id} with {name!r}",
|
|
514
|
+
"tag did not appear; is Bear able to run?",
|
|
515
|
+
)
|
|
516
|
+
finally:
|
|
517
|
+
db.close()
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
@note_app.command()
|
|
521
|
+
def untag(
|
|
522
|
+
note_id: Annotated[str, typer.Argument(help="Note identifier.")],
|
|
523
|
+
name: Annotated[str, typer.Argument(help="Tag to remove (without the leading #).")],
|
|
524
|
+
db_path: DbPathOption = DEFAULT_DB_PATH,
|
|
525
|
+
) -> None:
|
|
526
|
+
"""Remove a tag from a note (rewrites the note text without the tag marker)."""
|
|
527
|
+
db = _open_db(db_path)
|
|
528
|
+
try:
|
|
529
|
+
note = _require_note(db, note_id)
|
|
530
|
+
if not _has_tag(note, name) or note.text is None:
|
|
531
|
+
console.print(
|
|
532
|
+
f"[red]Error:[/red] note {note.id} has no tag {name!r} (tags: {', '.join(note.tags) or 'none'})"
|
|
533
|
+
)
|
|
534
|
+
raise typer.Exit(1)
|
|
535
|
+
# Strip both marker forms; don't touch longer tags sharing the prefix
|
|
536
|
+
# (removing "work" must leave "#work/ideas" and "#workout" alone).
|
|
537
|
+
escaped = re.escape(name)
|
|
538
|
+
new_text = re.sub(rf"[ \t]?#{escaped}#", "", note.text, flags=re.IGNORECASE)
|
|
539
|
+
new_text = re.sub(rf"[ \t]?#{escaped}(?![\w/-])", "", new_text, flags=re.IGNORECASE)
|
|
540
|
+
if new_text == note.text:
|
|
541
|
+
console.print(f"[red]Error:[/red] could not locate the #{name} marker in the note text")
|
|
542
|
+
raise typer.Exit(1)
|
|
543
|
+
new_text = new_text.rstrip("\n") + "\n"
|
|
544
|
+
actions.add_text(note.id, new_text, mode="replace_all")
|
|
545
|
+
_verify(
|
|
546
|
+
lambda: (n := db.get_note(note.id)) is not None and not _has_tag(n, name),
|
|
547
|
+
f"Removed tag {name!r} from note {note.id}",
|
|
548
|
+
"tag was not removed; is Bear able to run?",
|
|
549
|
+
)
|
|
550
|
+
finally:
|
|
551
|
+
db.close()
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
@note_app.command("open")
|
|
555
|
+
def open_note(
|
|
556
|
+
note_id: Annotated[str, typer.Argument(help="Note identifier.")],
|
|
557
|
+
new_window: Annotated[bool, typer.Option("--new-window", "-w", help="Open in a separate window.")] = False,
|
|
558
|
+
db_path: DbPathOption = DEFAULT_DB_PATH,
|
|
559
|
+
) -> None:
|
|
560
|
+
"""Open a note in the Bear app."""
|
|
561
|
+
db = _open_db(db_path)
|
|
562
|
+
try:
|
|
563
|
+
note = _require_note(db, note_id)
|
|
564
|
+
finally:
|
|
565
|
+
db.close()
|
|
566
|
+
actions.open_note(note.id, new_window=new_window)
|
|
567
|
+
console.print(f"Opened note {note.id} in Bear")
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
MAX_ATTACH_BYTES = 500_000 # the file travels base64-encoded inside a URL; macOS caps arg size at ~1 MB
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
@note_app.command()
|
|
574
|
+
def attach(
|
|
575
|
+
note_id: Annotated[str, typer.Argument(help="Note identifier.")],
|
|
576
|
+
file: Annotated[Path, typer.Argument(help="File to attach (appended at the end of the note).")],
|
|
577
|
+
db_path: DbPathOption = DEFAULT_DB_PATH,
|
|
578
|
+
) -> None:
|
|
579
|
+
"""Attach a file to a note."""
|
|
580
|
+
if not file.is_file():
|
|
581
|
+
console.print(f"[red]Error:[/red] {file} is not a file")
|
|
582
|
+
raise typer.Exit(1)
|
|
583
|
+
data = file.read_bytes()
|
|
584
|
+
if len(data) > MAX_ATTACH_BYTES:
|
|
585
|
+
console.print(
|
|
586
|
+
f"[red]Error:[/red] {file.name} is {len(data)} bytes; attachments are limited to "
|
|
587
|
+
f"{MAX_ATTACH_BYTES} bytes (the file is passed base64-encoded through a URL)"
|
|
588
|
+
)
|
|
589
|
+
raise typer.Exit(1)
|
|
590
|
+
db = _open_db(db_path)
|
|
591
|
+
try:
|
|
592
|
+
note = _require_note(db, note_id)
|
|
593
|
+
before = len(note.attachments)
|
|
594
|
+
actions.add_file(note.id, file.name, base64.b64encode(data).decode())
|
|
595
|
+
_verify(
|
|
596
|
+
lambda: (n := db.get_note(note.id)) is not None and len(n.attachments) > before,
|
|
597
|
+
f"Attached {file.name} to note {note.id}",
|
|
598
|
+
"attachment did not appear; is Bear able to run?",
|
|
599
|
+
)
|
|
600
|
+
finally:
|
|
601
|
+
db.close()
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
@note_app.command()
|
|
605
|
+
def rename(
|
|
606
|
+
note_id: Annotated[str, typer.Argument(help="Note identifier.")],
|
|
607
|
+
new_title: Annotated[str, typer.Argument(help="New title for the note.")],
|
|
608
|
+
db_path: DbPathOption = DEFAULT_DB_PATH,
|
|
609
|
+
) -> None:
|
|
610
|
+
"""Change a note's title (first line), keeping the body."""
|
|
611
|
+
db = _open_db(db_path)
|
|
612
|
+
try:
|
|
613
|
+
note = _require_note(db, note_id)
|
|
614
|
+
if note.text is None:
|
|
615
|
+
console.print(f"[red]Error:[/red] note {note.id} is encrypted; cannot rename")
|
|
616
|
+
raise typer.Exit(1)
|
|
617
|
+
head, sep, body = note.text.partition("\n")
|
|
618
|
+
if head.startswith("# "):
|
|
619
|
+
new_text = f"# {new_title}{sep}{body}"
|
|
620
|
+
else:
|
|
621
|
+
new_text = f"# {new_title}\n{note.text}"
|
|
622
|
+
actions.add_text(note.id, new_text, mode="replace_all")
|
|
623
|
+
_verify(
|
|
624
|
+
lambda: (n := db.get_note(note.id)) is not None and n.title == new_title,
|
|
625
|
+
f"Renamed note {note.id} to {new_title!r}",
|
|
626
|
+
"title did not change; is Bear able to run?",
|
|
627
|
+
)
|
|
628
|
+
finally:
|
|
629
|
+
db.close()
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
@note_app.command()
|
|
633
|
+
def replace(
|
|
634
|
+
note_id: Annotated[str, typer.Argument(help="Note identifier.")],
|
|
635
|
+
text: Annotated[str | None, typer.Option("--text", help="New body (reads stdin if piped).")] = None,
|
|
636
|
+
db_path: DbPathOption = DEFAULT_DB_PATH,
|
|
637
|
+
) -> None:
|
|
638
|
+
"""Replace a note's body with new text, keeping the title. Destructive."""
|
|
639
|
+
body = _text_or_stdin(text)
|
|
640
|
+
if body is None:
|
|
641
|
+
console.print("[red]Error:[/red] provide --text or pipe content on stdin")
|
|
642
|
+
raise typer.Exit(2)
|
|
643
|
+
db = _open_db(db_path)
|
|
644
|
+
try:
|
|
645
|
+
before = _require_note(db, note_id)
|
|
646
|
+
actions.add_text(before.id, body, mode="replace")
|
|
647
|
+
_verify(
|
|
648
|
+
lambda: (
|
|
649
|
+
(n := db.get_note(before.id)) is not None
|
|
650
|
+
and n.modified is not None
|
|
651
|
+
and (before.modified is None or n.modified > before.modified)
|
|
652
|
+
),
|
|
653
|
+
f"Replaced body of note {before.id}",
|
|
654
|
+
"note was not modified; is Bear able to run?",
|
|
655
|
+
)
|
|
656
|
+
finally:
|
|
657
|
+
db.close()
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
@tag_app.command("rename")
|
|
661
|
+
def rename_tag(
|
|
662
|
+
name: Annotated[str, typer.Argument(help="Existing tag name.")],
|
|
663
|
+
new_name: Annotated[str, typer.Argument(help="New tag name.")],
|
|
664
|
+
db_path: DbPathOption = DEFAULT_DB_PATH,
|
|
665
|
+
) -> None:
|
|
666
|
+
"""Rename a tag across all notes."""
|
|
667
|
+
db = _open_db(db_path)
|
|
668
|
+
try:
|
|
669
|
+
existing = {t.lower() for t, _ in db.list_tags()}
|
|
670
|
+
if name.lower() not in existing:
|
|
671
|
+
console.print(f"[red]Error:[/red] no tag named {name!r}")
|
|
672
|
+
raise typer.Exit(1)
|
|
673
|
+
actions.rename_tag(name, new_name)
|
|
674
|
+
_verify(
|
|
675
|
+
lambda: new_name.lower() in {t.lower() for t, _ in db.list_tags()},
|
|
676
|
+
f"Renamed tag {name!r} to {new_name!r}",
|
|
677
|
+
"tag was not renamed; is Bear able to run?",
|
|
678
|
+
)
|
|
679
|
+
finally:
|
|
680
|
+
db.close()
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
@tag_app.command("delete")
|
|
684
|
+
def delete_tag(
|
|
685
|
+
name: Annotated[str, typer.Argument(help="Tag to delete from all notes.")],
|
|
686
|
+
yes: Annotated[bool, typer.Option("--yes", "-y", help="Skip the confirmation prompt.")] = False,
|
|
687
|
+
db_path: DbPathOption = DEFAULT_DB_PATH,
|
|
688
|
+
) -> None:
|
|
689
|
+
"""Delete a tag from every note that has it."""
|
|
690
|
+
db = _open_db(db_path)
|
|
691
|
+
try:
|
|
692
|
+
counts = {t.lower(): c for t, c in db.list_tags(include_empty=True)}
|
|
693
|
+
if name.lower() not in counts:
|
|
694
|
+
console.print(f"[red]Error:[/red] no tag named {name!r}")
|
|
695
|
+
raise typer.Exit(1)
|
|
696
|
+
if not yes:
|
|
697
|
+
typer.confirm(f"Remove tag '{name}' from {counts[name.lower()]} note(s)?", abort=True)
|
|
698
|
+
actions.delete_tag(name)
|
|
699
|
+
# Bear keeps an empty tag row behind, so verify the count reaches zero.
|
|
700
|
+
_verify(
|
|
701
|
+
lambda: dict((t.lower(), c) for t, c in db.list_tags(include_empty=True)).get(name.lower(), 0) == 0,
|
|
702
|
+
f"Deleted tag {name!r}",
|
|
703
|
+
"tag was not deleted; is Bear able to run?",
|
|
704
|
+
)
|
|
705
|
+
finally:
|
|
706
|
+
db.close()
|
|
707
|
+
|
|
708
|
+
|
|
709
|
+
@note_app.command()
|
|
710
|
+
def search(
|
|
711
|
+
query: Annotated[str, typer.Argument(help="Search terms, matched against titles, tags, and text.")],
|
|
712
|
+
fuzzy: Annotated[bool, typer.Option("--fuzzy", help="Typo-tolerant matching, ranked by score.")] = False,
|
|
713
|
+
limit: Annotated[int, typer.Option("--limit", "-n", help="Maximum number of results.")] = 10,
|
|
714
|
+
min_score: Annotated[float, typer.Option("--min-score", help="Minimum match score, 0-100 (fuzzy only).")] = 60.0,
|
|
715
|
+
tag_filter: Annotated[str | None, typer.Option("--tag", "-t", help="Restrict to notes with this tag.")] = None,
|
|
716
|
+
trashed: Annotated[bool, typer.Option("--trashed", help="Include trashed notes.")] = False,
|
|
717
|
+
archived: Annotated[bool, typer.Option("--archived", help="Include archived notes.")] = False,
|
|
718
|
+
fmt: Annotated[
|
|
719
|
+
OutputFormat,
|
|
720
|
+
typer.Option("--format", "-f", help="Output format: table, json, or text."),
|
|
721
|
+
] = OutputFormat.table,
|
|
722
|
+
db_path: DbPathOption = DEFAULT_DB_PATH,
|
|
723
|
+
) -> None:
|
|
724
|
+
"""Search notes by title, tags, and content."""
|
|
725
|
+
db = _open_db(db_path)
|
|
726
|
+
try:
|
|
727
|
+
notes = db.list_notes(
|
|
728
|
+
limit=None,
|
|
729
|
+
tag=tag_filter,
|
|
730
|
+
include_trashed=trashed,
|
|
731
|
+
include_archived=archived,
|
|
732
|
+
with_text=True,
|
|
733
|
+
)
|
|
734
|
+
finally:
|
|
735
|
+
db.close()
|
|
736
|
+
|
|
737
|
+
if fuzzy:
|
|
738
|
+
results = search_notes(notes, query, min_score=min_score)[:limit]
|
|
739
|
+
else:
|
|
740
|
+
results = naive_search(notes, query)[:limit]
|
|
741
|
+
|
|
742
|
+
if fmt is OutputFormat.json:
|
|
743
|
+
payload = [
|
|
744
|
+
{**_note_to_dict(r.note), "snippet": r.snippet}
|
|
745
|
+
| ({"score": round(r.score, 1)} if r.score is not None else {})
|
|
746
|
+
for r in results
|
|
747
|
+
]
|
|
748
|
+
print(json.dumps(payload, indent=2, ensure_ascii=False))
|
|
749
|
+
return
|
|
750
|
+
if fmt is OutputFormat.text:
|
|
751
|
+
for r in results:
|
|
752
|
+
score = f"\t{r.score:.0f}" if r.score is not None else ""
|
|
753
|
+
print(f"{r.note.id}{score}\t{r.note.title}")
|
|
754
|
+
return
|
|
755
|
+
|
|
756
|
+
if not results:
|
|
757
|
+
console.print("No matches.")
|
|
758
|
+
return
|
|
759
|
+
|
|
760
|
+
table = Table(box=box.ROUNDED, header_style="bold")
|
|
761
|
+
if fuzzy:
|
|
762
|
+
table.add_column("Score", justify="right")
|
|
763
|
+
table.add_column("ID", style="dim", no_wrap=True)
|
|
764
|
+
table.add_column("Title", overflow="ellipsis", max_width=40)
|
|
765
|
+
table.add_column("Match", style="green", overflow="ellipsis", max_width=50)
|
|
766
|
+
if not fuzzy:
|
|
767
|
+
table.add_column("Modified", no_wrap=True)
|
|
768
|
+
for r in results:
|
|
769
|
+
row = [r.note.id, r.note.title, r.snippet]
|
|
770
|
+
if fuzzy:
|
|
771
|
+
row.insert(0, f"{r.score:.0f}" if r.score is not None else "")
|
|
772
|
+
else:
|
|
773
|
+
row.append(r.note.modified.strftime("%Y-%m-%d %H:%M") if r.note.modified else "")
|
|
774
|
+
table.add_row(*row)
|
|
775
|
+
console.print(table)
|
|
776
|
+
|
|
777
|
+
|
|
778
|
+
# Top-level aliases for the most-used commands, shown in their own help panel.
|
|
779
|
+
def _alias(name: str, target: str, func: Callable) -> None:
|
|
780
|
+
summary = (func.__doc__ or "").strip().splitlines()[0]
|
|
781
|
+
app.command(name, help=f"{summary} (alias for `bearcli {target}`)", rich_help_panel="Shortcuts")(func)
|
|
782
|
+
|
|
783
|
+
|
|
784
|
+
_alias("list", "note list", list_notes)
|
|
785
|
+
_alias("search", "note search", search)
|
|
786
|
+
_alias("get", "note get", get)
|
|
787
|
+
_alias("open", "note open", open_note)
|
|
788
|
+
_alias("create", "note create", create)
|