bearcli 1.4.0__tar.gz → 1.5.0__tar.gz

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.

Potentially problematic release.


This version of bearcli might be problematic. Click here for more details.

@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: bearcli
3
- Version: 1.4.0
3
+ Version: 1.5.0
4
4
  Summary: The missing CLI for Bear notes — read, search, export, and manage your notes from the terminal
5
5
  Keywords: bear,notes,cli,markdown,macos
6
6
  Author: Michel Tricot
@@ -88,11 +88,13 @@ bearcli stats # counts, words, top tags, notes pe
88
88
  ### Terminal UI
89
89
 
90
90
  `bearcli ui` is a full Bear client in the terminal: the note list with live
91
- filtering on the left, preview and inline markdown editor on the right.
92
- `/` search - `Enter` edit - `n`/`c` new note - `t`/`T` tag/untag (with
93
- autocompletion) - `o` open in Bear - `a` archive - `d` trash - `r` reload.
94
- Notes with detected secrets are marked 🚨; encrypted notes show 🔒 and keep
95
- their content in Bear.
91
+ filtering on the left, markdown preview and inline editor on the right.
92
+ `/` search - `enter`/`e` edit (`ctrl+s` saves, with an unsaved-changes
93
+ guard) - `n`/`c` new note - `t`/`T` tag/untag - `o` open in Bear - `a`
94
+ archive - `d` trash - `1`/`2`/`3` notes/archive/trash views - `j`/`k`
95
+ navigation - `?` shows the full key map. Notes with detected secrets get a
96
+ red title, a 🚨 badge, and the secret values highlighted in the preview and
97
+ editor; encrypted notes show 🔒 and keep their content in Bear.
96
98
 
97
99
  ### Search
98
100
 
@@ -64,11 +64,13 @@ bearcli stats # counts, words, top tags, notes pe
64
64
  ### Terminal UI
65
65
 
66
66
  `bearcli ui` is a full Bear client in the terminal: the note list with live
67
- filtering on the left, preview and inline markdown editor on the right.
68
- `/` search - `Enter` edit - `n`/`c` new note - `t`/`T` tag/untag (with
69
- autocompletion) - `o` open in Bear - `a` archive - `d` trash - `r` reload.
70
- Notes with detected secrets are marked 🚨; encrypted notes show 🔒 and keep
71
- their content in Bear.
67
+ filtering on the left, markdown preview and inline editor on the right.
68
+ `/` search - `enter`/`e` edit (`ctrl+s` saves, with an unsaved-changes
69
+ guard) - `n`/`c` new note - `t`/`T` tag/untag - `o` open in Bear - `a`
70
+ archive - `d` trash - `1`/`2`/`3` notes/archive/trash views - `j`/`k`
71
+ navigation - `?` shows the full key map. Notes with detected secrets get a
72
+ red title, a 🚨 badge, and the secret values highlighted in the preview and
73
+ editor; encrypted notes show 🔒 and keep their content in Bear.
72
74
 
73
75
  ### Search
74
76
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "bearcli"
3
- version = "1.4.0"
3
+ version = "1.5.0"
4
4
  description = "The missing CLI for Bear notes — read, search, export, and manage your notes from the terminal"
5
5
  readme = "README.md"
6
6
  keywords = ["bear", "notes", "cli", "markdown", "macos"]
@@ -101,9 +101,5 @@ def ui(
101
101
  """Bear in the terminal: search, edit, create, tag, and organize notes."""
102
102
  from bearcli.tui import run_ui
103
103
 
104
- db = _open_db(db_path)
105
- try:
106
- notes = db.list_notes(limit=None, tag=tag_filter, with_text=True)
107
- finally:
108
- db.close()
109
- run_ui(notes, fuzzy=fuzzy, db_path=db_path, tag_filter=tag_filter)
104
+ _open_db(db_path).close() # fail fast with a clear message if the db is missing
105
+ run_ui(fuzzy=fuzzy, db_path=db_path, tag_filter=tag_filter)
@@ -20,7 +20,7 @@ from textual.app import App, ComposeResult
20
20
  from textual.binding import Binding
21
21
  from textual.containers import Horizontal, Vertical, VerticalScroll
22
22
  from textual.screen import ModalScreen
23
- from textual.widgets import Footer, Input, Label, OptionList, Static, TextArea
23
+ from textual.widgets import Input, Label, LoadingIndicator, OptionList, Static, TextArea
24
24
  from textual.widgets.option_list import Option
25
25
 
26
26
  from bearcli import actions, ops
@@ -98,6 +98,19 @@ def _highlighted(value: str, query: str, base_style: str = "") -> Text:
98
98
  class SecretTextArea(TextArea):
99
99
  """TextArea that renders detected secret values on a light red background."""
100
100
 
101
+ async def _on_key(self, event: events.Key) -> None:
102
+ # With tab_behavior="indent", TextArea claims escape as its
103
+ # focus-escape key (in _on_key, not via bindings); route it to the
104
+ # app's editor-exit instead.
105
+ if event.key == "escape":
106
+ event.stop()
107
+ event.prevent_default()
108
+ app = self.app
109
+ assert isinstance(app, BearUI)
110
+ app.action_back_or_quit()
111
+ return
112
+ await super()._on_key(event)
113
+
101
114
  def __init__(self, *args, **kwargs):
102
115
  super().__init__(*args, **kwargs)
103
116
  self.secret_values: list[str] = []
@@ -124,6 +137,32 @@ class _TagSuggestions(OptionList):
124
137
  super().action_cursor_down()
125
138
 
126
139
 
140
+ class ConfirmDiscard(ModalScreen[str | None]):
141
+ """Confirmation shown when leaving the editor with unsaved changes."""
142
+
143
+ BINDINGS = [
144
+ Binding("s", "answer('save')", "Save"),
145
+ Binding("y", "answer('discard')", "Discard"),
146
+ Binding("n,escape", "answer('keep')", "Keep editing"),
147
+ ]
148
+ CSS = """
149
+ ConfirmDiscard { align: center middle; }
150
+ #confirm-box {
151
+ width: 52; height: auto; padding: 1 2;
152
+ border: round $warning; background: $panel;
153
+ }
154
+ #confirm-keys { color: $text-muted; margin-top: 1; }
155
+ """
156
+
157
+ def compose(self) -> ComposeResult:
158
+ with Vertical(id="confirm-box"):
159
+ yield Label("You have unsaved changes.")
160
+ yield Label("[b]s[/b] save [b]y[/b] discard [b]n[/b]/[b]esc[/b] keep editing", id="confirm-keys")
161
+
162
+ def action_answer(self, answer: str) -> None:
163
+ self.dismiss(answer)
164
+
165
+
127
166
  class TagScreen(ModalScreen[str | None]):
128
167
  """Tag prompt with autocompletion over known tags."""
129
168
 
@@ -179,6 +218,97 @@ class TagScreen(ModalScreen[str | None]):
179
218
  self.dismiss(None)
180
219
 
181
220
 
221
+ BROWSE_KEYS_BEAR = [
222
+ ("enter/e", "Edit"),
223
+ ("n/c", "New"),
224
+ ("t", "Tag"),
225
+ ("o", "Open in Bear"),
226
+ ("a", "Archive"),
227
+ ("d", "Trash"),
228
+ ]
229
+ BROWSE_KEYS_TUI = [
230
+ ("/", "Search"),
231
+ ("1/2/3", "View"),
232
+ ("tab", "Pane"),
233
+ ("?", "Help"),
234
+ ("esc", "Quit"),
235
+ ]
236
+ EDIT_KEYS_BEAR = [("ctrl+s", "Save to Bear")]
237
+ EDIT_KEYS_TUI = [("tab", "Indent"), ("esc", "Discard")]
238
+
239
+
240
+ def _keybar_markup(keys: list[tuple[str, str]]) -> str:
241
+ return " ".join(f"[bold]{key}[/bold] [dim]{label}[/dim]" for key, label in keys)
242
+
243
+
244
+ class KeyBar(Horizontal):
245
+ """Bottom key bar: Bear actions on the left, TUI controls on the right."""
246
+
247
+ DEFAULT_CSS = """
248
+ KeyBar { dock: bottom; height: 1; background: $panel; padding: 0 1; }
249
+ KeyBar > #kb-left { width: auto; }
250
+ KeyBar > #kb-right { width: 1fr; text-align: right; }
251
+ """
252
+
253
+ def compose(self) -> ComposeResult:
254
+ yield Static(id="kb-left")
255
+ yield Static(id="kb-right")
256
+
257
+ def show(self, bear: list[tuple[str, str]], tui: list[tuple[str, str]]) -> None:
258
+ self.query_one("#kb-left", Static).update(_keybar_markup(bear))
259
+ self.query_one("#kb-right", Static).update(_keybar_markup(tui))
260
+
261
+
262
+ HELP_ROWS = [
263
+ ("Navigate", ""),
264
+ ("↑ or k", "previous note"),
265
+ ("↓ or j", "next note"),
266
+ ("/", "focus search"),
267
+ ("tab", "switch pane"),
268
+ ("1 / 2 / 3", "notes / archive / trash view"),
269
+ ("Act on the selected note", ""),
270
+ ("enter or e", "edit in the right panel"),
271
+ ("n or c", "new note"),
272
+ ("t / T", "add / remove a tag"),
273
+ ("o", "open in Bear"),
274
+ ("a", "archive"),
275
+ ("d", "move to trash"),
276
+ ("r", "reload from Bear"),
277
+ ("While editing", ""),
278
+ ("ctrl+s", "save to Bear"),
279
+ ("esc", "discard changes"),
280
+ ]
281
+
282
+
283
+ class HelpScreen(ModalScreen[None]):
284
+ """Key map overlay."""
285
+
286
+ BINDINGS = [Binding("escape,question_mark", "close", "Close")]
287
+ CSS = """
288
+ HelpScreen { align: center middle; }
289
+ #help-box {
290
+ width: 62; height: auto; padding: 1 2;
291
+ border: round $accent; background: $panel;
292
+ }
293
+ """
294
+
295
+ def compose(self) -> ComposeResult:
296
+ table = RichTable.grid(padding=(0, 2))
297
+ table.add_column(style="bold", no_wrap=True)
298
+ table.add_column()
299
+ for key, description in HELP_ROWS:
300
+ if not description:
301
+ if table.row_count:
302
+ table.add_row("", "") # blank line between sections
303
+ table.add_row(Text(key, "dim italic"), "")
304
+ else:
305
+ table.add_row(key, description)
306
+ yield Static(table, id="help-box")
307
+
308
+ def action_close(self) -> None:
309
+ self.dismiss(None)
310
+
311
+
182
312
  class BearUI(App):
183
313
  """Browse, edit, and organize Bear notes from the terminal."""
184
314
 
@@ -188,17 +318,19 @@ class BearUI(App):
188
318
  CSS = """
189
319
  #query { dock: top; margin: 0 1; border: round $primary; }
190
320
  #query:focus { border: round $accent; }
191
- #results { width: 34%; border: round $panel-lighten-2; }
192
- #results:focus { border: round $accent; }
321
+ #list-pane { width: 34%; border: round $panel-lighten-2; }
322
+ #list-pane:focus-within { border: round $accent; }
323
+ #results { height: 1fr; border: none; background: transparent; }
324
+ #list-pane LoadingIndicator { height: 1fr; }
193
325
  #side { width: 66%; }
194
326
  #preview-pane { height: 1fr; border: round $panel-lighten-2; padding: 0 1; }
327
+ #preview-pane:focus { border: round $accent; }
195
328
  #editor { height: 1fr; border: round $accent; display: none; }
196
- Horizontal { height: 1fr; margin: 0 1; }
329
+ #main { height: 1fr; margin: 0 1; }
197
330
  """
198
331
 
199
332
  BINDINGS = [
200
- Binding("escape", "back_or_quit", "Quit"),
201
- Binding("/", "focus_search", "Search"),
333
+ # Bear actions (left of the footer)
202
334
  Binding("e", "edit_selected", "Edit", key_display="enter/e"),
203
335
  Binding("enter", "edit_selected", "Edit", show=False),
204
336
  Binding("n", "new_note", "New", key_display="n/c"),
@@ -206,25 +338,38 @@ class BearUI(App):
206
338
  Binding("t", "add_tag", "Tag"),
207
339
  Binding("T", "remove_tag", "Untag", show=False),
208
340
  Binding("o", "open_in_bear", "Open in Bear"),
209
- Binding("r", "refresh", "Refresh", show=False),
210
341
  Binding("a", "archive_note", "Archive"),
211
342
  Binding("d", "trash_note", "Trash"),
343
+ Binding("r", "refresh", "Refresh", show=False),
344
+ Binding("j", "cursor_down", "Next note", show=False),
345
+ Binding("k", "cursor_up", "Previous note", show=False),
212
346
  Binding("ctrl+s", "save_edit", "Save to Bear"),
213
- Binding("tab", "focus_next", "Switch pane", show=False),
347
+ # TUI controls (right of the footer)
348
+ Binding("/", "focus_search", "Search"),
349
+ Binding("1", "switch_view('notes')", "View", key_display="1/2/3"),
350
+ Binding("2", "switch_view('archive')", "Archive view", show=False),
351
+ Binding("3", "switch_view('trash')", "Trash view", show=False),
352
+ Binding("tab", "focus_next", "Switch pane", priority=True),
353
+ Binding("question_mark", "help", "Help", key_display="?"),
354
+ Binding("escape", "back_or_quit", "Quit"),
214
355
  ]
215
356
 
216
357
  def __init__(
217
358
  self,
218
- notes: list[Note],
359
+ notes: list[Note] | None = None,
219
360
  fuzzy: bool = False,
220
361
  db_path: Path = DEFAULT_DB_PATH,
221
362
  tag_filter: str | None = None,
222
363
  ):
223
364
  super().__init__()
224
- self.notes = notes
365
+ self._preloaded = notes is not None
366
+ self._focus_list_after_load = False
367
+ self._editor_original = ""
368
+ self.notes = notes or []
225
369
  self.fuzzy = fuzzy
226
370
  self.db_path = db_path
227
371
  self.tag_filter = tag_filter
372
+ self.view = "notes" # notes | archive | trash
228
373
  self.search_query = ""
229
374
  self.shown: list[Note] = []
230
375
  self.editing: Note | None = None
@@ -237,13 +382,14 @@ class BearUI(App):
237
382
 
238
383
  def compose(self) -> ComposeResult:
239
384
  yield Input(placeholder="Search notes…", id="query")
240
- with Horizontal():
241
- yield OptionList(id="results")
385
+ with Horizontal(id="main"):
386
+ with Vertical(id="list-pane"):
387
+ yield OptionList(id="results")
242
388
  with Vertical(id="side"):
243
389
  with VerticalScroll(id="preview-pane"):
244
390
  yield Static(id="preview-content")
245
- yield SecretTextArea(language="markdown", id="editor")
246
- yield Footer()
391
+ yield SecretTextArea(language="markdown", id="editor", tab_behavior="indent")
392
+ yield KeyBar()
247
393
 
248
394
  @property
249
395
  def edit_mode(self) -> bool:
@@ -260,6 +406,10 @@ class BearUI(App):
260
406
  "trash_note",
261
407
  "focus_search",
262
408
  "refresh",
409
+ "switch_view",
410
+ "focus_next",
411
+ "cursor_down",
412
+ "cursor_up",
263
413
  }
264
414
  if self.edit_mode and action in browse_only:
265
415
  return False
@@ -270,9 +420,27 @@ class BearUI(App):
270
420
  # ── searching / listing ──────────────────────────────────────────────
271
421
 
272
422
  def on_mount(self) -> None:
273
- self._show_results("", [SearchResult(note=n, snippet="") for n in self.notes])
423
+ self.query_one(KeyBar).show(BROWSE_KEYS_BEAR, BROWSE_KEYS_TUI)
274
424
  self.query_one("#results", OptionList).focus()
275
- self._scan_secrets()
425
+ if self._preloaded:
426
+ self._show_results("", [SearchResult(note=n, snippet="") for n in self.notes])
427
+ self._scan_secrets()
428
+ else:
429
+ self._set_list_loading(True)
430
+ self._rehydrate()
431
+
432
+ def _set_list_loading(self, on: bool) -> None:
433
+ results = self.query_one("#results", OptionList)
434
+ pane = self.query_one("#list-pane")
435
+ if on:
436
+ self._focus_list_after_load = not (isinstance(self.focused, Input) and self.focused.value)
437
+ results.display = False
438
+ if not pane.query("LoadingIndicator"):
439
+ pane.mount(LoadingIndicator())
440
+ else:
441
+ for indicator in pane.query(LoadingIndicator):
442
+ indicator.remove()
443
+ results.display = True
276
444
 
277
445
  @work(exclusive=True, thread=True, group="scan")
278
446
  def _scan_secrets(self) -> None:
@@ -280,6 +448,14 @@ class BearUI(App):
280
448
 
281
449
  def _apply_secret_values(self, values: dict[str, dict[str, str]]) -> None:
282
450
  self.secret_values = values
451
+ results = self.query_one("#results", OptionList)
452
+ self._set_list_loading(False)
453
+ if self._focus_list_after_load:
454
+ # The hidden list dropped focus into the search box during the
455
+ # load; take it back unless the user started typing.
456
+ self._focus_list_after_load = False
457
+ if not self.query_one("#query", Input).value:
458
+ results.focus()
283
459
  self._run_filter(self.search_query)
284
460
 
285
461
  @work(thread=True, group="refresh")
@@ -290,7 +466,7 @@ class BearUI(App):
290
466
  fresh = db.get_note(note_id)
291
467
  finally:
292
468
  db.close()
293
- if fresh is None or fresh.trashed or fresh.archived:
469
+ if fresh is None or not self._in_current_view(fresh):
294
470
  self.notes = [n for n in self.notes if n.id != note_id]
295
471
  self.secret_values.pop(note_id, None)
296
472
  else:
@@ -307,10 +483,11 @@ class BearUI(App):
307
483
 
308
484
  @work(exclusive=True, thread=True, group="rehydrate")
309
485
  def _rehydrate(self) -> None:
310
- """Reload every note from the database - manual refresh, also catches edits made in Bear."""
486
+ """Reload the current view from the database - also catches edits made in Bear."""
311
487
  db = BearDB(self.db_path)
312
488
  try:
313
- notes = db.list_notes(limit=None, tag=self.tag_filter, with_text=True)
489
+ only = {"archive": "archived", "trash": "trashed"}.get(self.view)
490
+ notes = db.list_notes(limit=None, tag=self.tag_filter, with_text=True, only=only)
314
491
  finally:
315
492
  db.close()
316
493
  self.notes = notes
@@ -365,7 +542,8 @@ class BearUI(App):
365
542
  result_list = self.query_one("#results", OptionList)
366
543
  result_list.clear_options()
367
544
  result_list.add_options(options)
368
- result_list.border_title = f"{len(self.shown)} / {len(self.notes)} notes"
545
+ prefix = {"notes": "", "archive": "Archive · ", "trash": "Trash · "}[self.view]
546
+ self.query_one("#list-pane").border_title = f"{prefix}{len(self.shown)} / {len(self.notes)} notes"
369
547
  select_id, self._select_id = self._select_id, None
370
548
  index = next((i for i, n in enumerate(self.shown) if n.id == select_id), None) if select_id else None
371
549
  if self.shown:
@@ -441,12 +619,14 @@ class BearUI(App):
441
619
  editor = self.query_one("#editor", SecretTextArea)
442
620
  editor.secret_values = list(secrets or [])
443
621
  editor.text = text
622
+ self._editor_original = text
444
623
  editor.border_title = title
445
624
  self.query_one("#preview-pane").styles.display = "none"
446
625
  editor.styles.display = "block"
447
626
  editor.focus()
448
627
  if cursor:
449
628
  editor.cursor_location = cursor
629
+ self.query_one(KeyBar).show(EDIT_KEYS_BEAR, EDIT_KEYS_TUI)
450
630
  self.refresh_bindings()
451
631
 
452
632
  def _exit_editor(self) -> None:
@@ -457,6 +637,7 @@ class BearUI(App):
457
637
  self.query_one("#results", OptionList).focus()
458
638
  if note := self._selected():
459
639
  self.query_one("#preview-content", Static).update(self._preview(note))
640
+ self.query_one(KeyBar).show(BROWSE_KEYS_BEAR, BROWSE_KEYS_TUI)
460
641
  self.refresh_bindings()
461
642
 
462
643
  def action_edit_selected(self) -> None:
@@ -490,7 +671,17 @@ class BearUI(App):
490
671
 
491
672
  def action_back_or_quit(self) -> None:
492
673
  if self.edit_mode:
493
- self._exit_editor()
674
+ if self.query_one("#editor", SecretTextArea).text != self._editor_original:
675
+
676
+ def on_answer(answer: str | None) -> None:
677
+ if answer == "discard":
678
+ self._exit_editor()
679
+ elif answer == "save":
680
+ self.action_save_edit()
681
+
682
+ self.push_screen(ConfirmDiscard(), on_answer)
683
+ else:
684
+ self._exit_editor()
494
685
  elif isinstance(self.focused, Input):
495
686
  self.query_one("#results", OptionList).focus()
496
687
  else:
@@ -520,9 +711,29 @@ class BearUI(App):
520
711
 
521
712
  self.push_screen(TagScreen(f"Remove tag from “{note.title}”", note.tags), on_done)
522
713
 
714
+ def action_switch_view(self, view: str) -> None:
715
+ if view == self.view:
716
+ return
717
+ self.view = view
718
+ self._select_id = None
719
+ self.notify({"notes": "Notes", "archive": "Archive", "trash": "Trash"}[view] + " view")
720
+ self._set_list_loading(True)
721
+ self._rehydrate()
722
+
723
+ def action_help(self) -> None:
724
+ self.push_screen(HelpScreen())
725
+
726
+ def action_cursor_down(self) -> None:
727
+ if len(self.screen_stack) == 1:
728
+ self.query_one("#results", OptionList).action_cursor_down()
729
+
730
+ def action_cursor_up(self) -> None:
731
+ if len(self.screen_stack) == 1:
732
+ self.query_one("#results", OptionList).action_cursor_up()
733
+
523
734
  def action_refresh(self) -> None:
735
+ self._set_list_loading(True)
524
736
  self._rehydrate()
525
- self.notify("Reloading from Bear…")
526
737
 
527
738
  def action_archive_note(self) -> None:
528
739
  self._file_away("archive")
@@ -575,7 +786,17 @@ class BearUI(App):
575
786
  message = f"Tagged with {tag!r}" if add else f"Removed tag {tag!r}"
576
787
  self._after_write(note.id, fresh, message, "Tag" if add else "Untag", edit_after=True)
577
788
 
789
+ def _in_current_view(self, note: Note) -> bool:
790
+ if self.view == "trash":
791
+ return note.trashed
792
+ if self.view == "archive":
793
+ return note.archived and not note.trashed
794
+ return not note.trashed and not note.archived
795
+
578
796
  def _file_away(self, operation: str) -> None:
797
+ if (operation == "trash" and self.view == "trash") or (operation == "archive" and self.view == "archive"):
798
+ self.notify(f"Already in the {self.view} view", severity="warning")
799
+ return
579
800
  if note := self._selected():
580
801
  self._file_away_worker(note, operation)
581
802
 
@@ -615,9 +836,8 @@ class BearUI(App):
615
836
 
616
837
 
617
838
  def run_ui(
618
- notes: list[Note],
619
839
  fuzzy: bool = False,
620
840
  db_path: Path = DEFAULT_DB_PATH,
621
841
  tag_filter: str | None = None,
622
842
  ) -> None:
623
- BearUI(notes, fuzzy=fuzzy, db_path=db_path, tag_filter=tag_filter).run()
843
+ BearUI(fuzzy=fuzzy, db_path=db_path, tag_filter=tag_filter).run()
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes