bearcli 1.2.1__tar.gz → 1.4.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.
- {bearcli-1.2.1 → bearcli-1.4.0}/PKG-INFO +33 -10
- {bearcli-1.2.1 → bearcli-1.4.0}/README.md +31 -9
- {bearcli-1.2.1 → bearcli-1.4.0}/pyproject.toml +3 -1
- bearcli-1.4.0/src/bearcli/cli/__init__.py +25 -0
- bearcli-1.4.0/src/bearcli/cli/common.py +112 -0
- bearcli-1.4.0/src/bearcli/cli/export.py +118 -0
- bearcli-1.4.0/src/bearcli/cli/misc.py +109 -0
- bearcli-1.2.1/src/bearcli/cli.py → bearcli-1.4.0/src/bearcli/cli/notes.py +43 -384
- bearcli-1.4.0/src/bearcli/cli/tags.py +104 -0
- {bearcli-1.2.1 → bearcli-1.4.0}/src/bearcli/db.py +23 -0
- {bearcli-1.2.1 → bearcli-1.4.0}/src/bearcli/markdown.py +21 -1
- bearcli-1.4.0/src/bearcli/ops.py +98 -0
- bearcli-1.4.0/src/bearcli/tui.py +623 -0
- {bearcli-1.2.1 → bearcli-1.4.0}/LICENSE +0 -0
- {bearcli-1.2.1 → bearcli-1.4.0}/src/bearcli/__init__.py +0 -0
- {bearcli-1.2.1 → bearcli-1.4.0}/src/bearcli/actions.py +0 -0
- {bearcli-1.2.1 → bearcli-1.4.0}/src/bearcli/export.py +0 -0
- {bearcli-1.2.1 → bearcli-1.4.0}/src/bearcli/gitsync.py +0 -0
- {bearcli-1.2.1 → bearcli-1.4.0}/src/bearcli/search.py +0 -0
- {bearcli-1.2.1 → bearcli-1.4.0}/src/bearcli/secrets.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: bearcli
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.4.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
|
|
@@ -14,6 +14,7 @@ Classifier: Programming Language :: Python :: 3.13
|
|
|
14
14
|
Classifier: Topic :: Utilities
|
|
15
15
|
Requires-Dist: detect-secrets>=1.5.0
|
|
16
16
|
Requires-Dist: rapidfuzz>=3.14.5
|
|
17
|
+
Requires-Dist: textual>=8.2.8
|
|
17
18
|
Requires-Dist: typer>=0.27.0
|
|
18
19
|
Requires-Python: >=3.13
|
|
19
20
|
Project-URL: Documentation, https://github.com/michel-tricot/bearcli/blob/main/docs/IMPLEMENTATION.md
|
|
@@ -23,9 +24,9 @@ Description-Content-Type: text/markdown
|
|
|
23
24
|
|
|
24
25
|
<div align="center">
|
|
25
26
|
|
|
26
|
-
# 🐻 bearcli
|
|
27
|
+
# 🐻 `bearcli`
|
|
27
28
|
|
|
28
|
-
**The missing CLI for [Bear](https://bear.app) notes**
|
|
29
|
+
**The missing CLI for [Bear](https://bear.app) notes** - read, search, export, and
|
|
29
30
|
manage your notes from the terminal.
|
|
30
31
|
|
|
31
32
|
[](https://github.com/michel-tricot/bearcli/actions/workflows/ci.yml)
|
|
@@ -57,6 +58,7 @@ bearcli search "quarterly report" # search titles, tags, and content
|
|
|
57
58
|
bearcli get <note-id> # print a note's markdown
|
|
58
59
|
bearcli create "Idea" --tag inbox # create a note
|
|
59
60
|
bearcli export ~/bear-backup # export everything as markdown folders
|
|
61
|
+
bearcli ui # full Bear client in the terminal
|
|
60
62
|
```
|
|
61
63
|
|
|
62
64
|
## Commands
|
|
@@ -79,8 +81,19 @@ bearcli get C44D09DC-... --meta # with YAML-style frontmatter
|
|
|
79
81
|
bearcli get C44D09DC-... -r # rewrite attachment refs to absolute paths
|
|
80
82
|
bearcli get C44D09DC-... --redact-secrets # secrets replaced by placeholders
|
|
81
83
|
bearcli open C44D09DC-... # open in the Bear app
|
|
84
|
+
bearcli ui # Bear in the terminal: search, edit, tag
|
|
85
|
+
bearcli stats # counts, words, top tags, notes per year
|
|
82
86
|
```
|
|
83
87
|
|
|
88
|
+
### Terminal UI
|
|
89
|
+
|
|
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.
|
|
96
|
+
|
|
84
97
|
### Search
|
|
85
98
|
|
|
86
99
|
```sh
|
|
@@ -90,7 +103,7 @@ bearcli search "quarterly planing" --fuzzy # typo-tolerant, ranked by score
|
|
|
90
103
|
|
|
91
104
|
### Write
|
|
92
105
|
|
|
93
|
-
Writes go through Bear's x-callback-url API
|
|
106
|
+
Writes go through Bear's x-callback-url API - the database itself is never
|
|
94
107
|
written. These launch the Bear app if needed.
|
|
95
108
|
|
|
96
109
|
```sh
|
|
@@ -115,19 +128,19 @@ bearcli tag delete old-name # across all notes (asks first)
|
|
|
115
128
|
|
|
116
129
|
### Export
|
|
117
130
|
|
|
118
|
-
Every note becomes a self-contained directory
|
|
119
|
-
attachments
|
|
131
|
+
Every note becomes a self-contained directory - `<slug>/README.md` plus its
|
|
132
|
+
attachments - with a generated index, so GitHub renders the whole export as a
|
|
120
133
|
browsable tree.
|
|
121
134
|
|
|
122
135
|
Before anything is written, the notes are scanned for potential secrets
|
|
123
136
|
(token formats, key blocks, credential assignments, high-entropy strings);
|
|
124
137
|
findings block the export with a list of the affected notes. Override with
|
|
125
138
|
`--allow-secrets` (export as-is) or `--redact-secrets` (replace each secret
|
|
126
|
-
with a `[redacted: <rule>]` placeholder
|
|
139
|
+
with a `[redacted: <rule>]` placeholder - notes in Bear are untouched).
|
|
127
140
|
|
|
128
|
-
> **⚠️ Warning**
|
|
141
|
+
> **⚠️ Warning** - detection and redaction are best-effort: a secret that
|
|
129
142
|
> reads like ordinary text (a password written as prose, an account number)
|
|
130
|
-
> will not be caught. Ideally, don't keep secrets in notes at all
|
|
143
|
+
> will not be caught. Ideally, don't keep secrets in notes at all - use a
|
|
131
144
|
> password manager, or at least Bear's encrypted notes, which never leave
|
|
132
145
|
> the app.
|
|
133
146
|
|
|
@@ -136,7 +149,7 @@ bearcli export ~/bear-backup
|
|
|
136
149
|
bearcli export ~/bear-backup --sync # only rewrite notes that changed
|
|
137
150
|
bearcli export ~/bear-backup --redact-secrets # secrets become [redacted: <rule>]
|
|
138
151
|
|
|
139
|
-
# Mirror to a git repository (clone it first; use a *private* repo
|
|
152
|
+
# Mirror to a git repository (clone it first; use a *private* repo - these are
|
|
140
153
|
# your notes). Bear is the source of truth: remote or manual edits are kept in
|
|
141
154
|
# git history but overwritten in HEAD. Never force-pushes, never gets stuck.
|
|
142
155
|
git clone git@github.com:you/bear-notes.git ~/bear-notes
|
|
@@ -158,6 +171,16 @@ defaults to Bear's standard location and can be overridden with `--db` or the
|
|
|
158
171
|
`BEAR_DB_PATH` environment variable. Encrypted notes are listed but their
|
|
159
172
|
content cannot be read.
|
|
160
173
|
|
|
174
|
+
## Agent skill
|
|
175
|
+
|
|
176
|
+
An [Agent Skill](https://docs.claude.com/en/docs/agents-and-tools/agent-skills)
|
|
177
|
+
teaching AI agents (Claude Code, etc.) how to use `bearcli` ships in
|
|
178
|
+
[`skills/bear-notes`](skills/bear-notes/SKILL.md):
|
|
179
|
+
|
|
180
|
+
```sh
|
|
181
|
+
cp -r skills/bear-notes ~/.claude/skills/ # or a project's .claude/skills/
|
|
182
|
+
```
|
|
183
|
+
|
|
161
184
|
## Development
|
|
162
185
|
|
|
163
186
|
```sh
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
<div align="center">
|
|
2
2
|
|
|
3
|
-
# 🐻 bearcli
|
|
3
|
+
# 🐻 `bearcli`
|
|
4
4
|
|
|
5
|
-
**The missing CLI for [Bear](https://bear.app) notes**
|
|
5
|
+
**The missing CLI for [Bear](https://bear.app) notes** - read, search, export, and
|
|
6
6
|
manage your notes from the terminal.
|
|
7
7
|
|
|
8
8
|
[](https://github.com/michel-tricot/bearcli/actions/workflows/ci.yml)
|
|
@@ -34,6 +34,7 @@ bearcli search "quarterly report" # search titles, tags, and content
|
|
|
34
34
|
bearcli get <note-id> # print a note's markdown
|
|
35
35
|
bearcli create "Idea" --tag inbox # create a note
|
|
36
36
|
bearcli export ~/bear-backup # export everything as markdown folders
|
|
37
|
+
bearcli ui # full Bear client in the terminal
|
|
37
38
|
```
|
|
38
39
|
|
|
39
40
|
## Commands
|
|
@@ -56,8 +57,19 @@ bearcli get C44D09DC-... --meta # with YAML-style frontmatter
|
|
|
56
57
|
bearcli get C44D09DC-... -r # rewrite attachment refs to absolute paths
|
|
57
58
|
bearcli get C44D09DC-... --redact-secrets # secrets replaced by placeholders
|
|
58
59
|
bearcli open C44D09DC-... # open in the Bear app
|
|
60
|
+
bearcli ui # Bear in the terminal: search, edit, tag
|
|
61
|
+
bearcli stats # counts, words, top tags, notes per year
|
|
59
62
|
```
|
|
60
63
|
|
|
64
|
+
### Terminal UI
|
|
65
|
+
|
|
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.
|
|
72
|
+
|
|
61
73
|
### Search
|
|
62
74
|
|
|
63
75
|
```sh
|
|
@@ -67,7 +79,7 @@ bearcli search "quarterly planing" --fuzzy # typo-tolerant, ranked by score
|
|
|
67
79
|
|
|
68
80
|
### Write
|
|
69
81
|
|
|
70
|
-
Writes go through Bear's x-callback-url API
|
|
82
|
+
Writes go through Bear's x-callback-url API - the database itself is never
|
|
71
83
|
written. These launch the Bear app if needed.
|
|
72
84
|
|
|
73
85
|
```sh
|
|
@@ -92,19 +104,19 @@ bearcli tag delete old-name # across all notes (asks first)
|
|
|
92
104
|
|
|
93
105
|
### Export
|
|
94
106
|
|
|
95
|
-
Every note becomes a self-contained directory
|
|
96
|
-
attachments
|
|
107
|
+
Every note becomes a self-contained directory - `<slug>/README.md` plus its
|
|
108
|
+
attachments - with a generated index, so GitHub renders the whole export as a
|
|
97
109
|
browsable tree.
|
|
98
110
|
|
|
99
111
|
Before anything is written, the notes are scanned for potential secrets
|
|
100
112
|
(token formats, key blocks, credential assignments, high-entropy strings);
|
|
101
113
|
findings block the export with a list of the affected notes. Override with
|
|
102
114
|
`--allow-secrets` (export as-is) or `--redact-secrets` (replace each secret
|
|
103
|
-
with a `[redacted: <rule>]` placeholder
|
|
115
|
+
with a `[redacted: <rule>]` placeholder - notes in Bear are untouched).
|
|
104
116
|
|
|
105
|
-
> **⚠️ Warning**
|
|
117
|
+
> **⚠️ Warning** - detection and redaction are best-effort: a secret that
|
|
106
118
|
> reads like ordinary text (a password written as prose, an account number)
|
|
107
|
-
> will not be caught. Ideally, don't keep secrets in notes at all
|
|
119
|
+
> will not be caught. Ideally, don't keep secrets in notes at all - use a
|
|
108
120
|
> password manager, or at least Bear's encrypted notes, which never leave
|
|
109
121
|
> the app.
|
|
110
122
|
|
|
@@ -113,7 +125,7 @@ bearcli export ~/bear-backup
|
|
|
113
125
|
bearcli export ~/bear-backup --sync # only rewrite notes that changed
|
|
114
126
|
bearcli export ~/bear-backup --redact-secrets # secrets become [redacted: <rule>]
|
|
115
127
|
|
|
116
|
-
# Mirror to a git repository (clone it first; use a *private* repo
|
|
128
|
+
# Mirror to a git repository (clone it first; use a *private* repo - these are
|
|
117
129
|
# your notes). Bear is the source of truth: remote or manual edits are kept in
|
|
118
130
|
# git history but overwritten in HEAD. Never force-pushes, never gets stuck.
|
|
119
131
|
git clone git@github.com:you/bear-notes.git ~/bear-notes
|
|
@@ -135,6 +147,16 @@ defaults to Bear's standard location and can be overridden with `--db` or the
|
|
|
135
147
|
`BEAR_DB_PATH` environment variable. Encrypted notes are listed but their
|
|
136
148
|
content cannot be read.
|
|
137
149
|
|
|
150
|
+
## Agent skill
|
|
151
|
+
|
|
152
|
+
An [Agent Skill](https://docs.claude.com/en/docs/agents-and-tools/agent-skills)
|
|
153
|
+
teaching AI agents (Claude Code, etc.) how to use `bearcli` ships in
|
|
154
|
+
[`skills/bear-notes`](skills/bear-notes/SKILL.md):
|
|
155
|
+
|
|
156
|
+
```sh
|
|
157
|
+
cp -r skills/bear-notes ~/.claude/skills/ # or a project's .claude/skills/
|
|
158
|
+
```
|
|
159
|
+
|
|
138
160
|
## Development
|
|
139
161
|
|
|
140
162
|
```sh
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "bearcli"
|
|
3
|
-
version = "1.
|
|
3
|
+
version = "1.4.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"]
|
|
@@ -20,6 +20,7 @@ requires-python = ">=3.13"
|
|
|
20
20
|
dependencies = [
|
|
21
21
|
"detect-secrets>=1.5.0",
|
|
22
22
|
"rapidfuzz>=3.14.5",
|
|
23
|
+
"textual>=8.2.8",
|
|
23
24
|
"typer>=0.27.0",
|
|
24
25
|
]
|
|
25
26
|
|
|
@@ -37,6 +38,7 @@ build-backend = "uv_build"
|
|
|
37
38
|
|
|
38
39
|
[dependency-groups]
|
|
39
40
|
dev = [
|
|
41
|
+
"pytest>=9.1.1",
|
|
40
42
|
"ruff>=0.16.0",
|
|
41
43
|
"ty>=0.0.64",
|
|
42
44
|
]
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Typer CLI for reading notes from the Bear note app."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
|
|
5
|
+
from bearcli.cli import export, misc, notes, tags # noqa: F401 # command registration
|
|
6
|
+
from bearcli.cli.common import app
|
|
7
|
+
from bearcli.cli.notes import create, get, list_notes, open_note, search
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _alias(name: str, target: str, func: Callable) -> None:
|
|
11
|
+
summary = (func.__doc__ or "").strip().splitlines()[0]
|
|
12
|
+
app.command(name, help=f"{summary} (alias for `bearcli {target}`)", rich_help_panel="Shortcuts")(func)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
_alias("list", "note list", list_notes)
|
|
16
|
+
|
|
17
|
+
_alias("search", "note search", search)
|
|
18
|
+
|
|
19
|
+
_alias("get", "note get", get)
|
|
20
|
+
|
|
21
|
+
_alias("open", "note open", open_note)
|
|
22
|
+
|
|
23
|
+
_alias("create", "note create", create)
|
|
24
|
+
|
|
25
|
+
__all__ = ["app"]
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Shared Typer apps, output types, and helpers for the CLI commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from enum import StrEnum
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Annotated
|
|
11
|
+
|
|
12
|
+
import typer
|
|
13
|
+
from rich.console import Console
|
|
14
|
+
|
|
15
|
+
from bearcli import actions
|
|
16
|
+
from bearcli.db import AmbiguousNoteId, BearDB, Note, note_metadata
|
|
17
|
+
|
|
18
|
+
app = typer.Typer(help="Read notes from the Bear note app.", no_args_is_help=True, add_completion=False)
|
|
19
|
+
|
|
20
|
+
note_app = typer.Typer(help="Create, read, and modify notes.", no_args_is_help=True)
|
|
21
|
+
|
|
22
|
+
tag_app = typer.Typer(help="List and manage tags.", no_args_is_help=True)
|
|
23
|
+
|
|
24
|
+
app.add_typer(note_app, name="note")
|
|
25
|
+
|
|
26
|
+
app.add_typer(tag_app, name="tag")
|
|
27
|
+
|
|
28
|
+
console = Console()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class OutputFormat(StrEnum):
|
|
32
|
+
table = "table"
|
|
33
|
+
json = "json"
|
|
34
|
+
text = "text"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class OnlyFilter(StrEnum):
|
|
38
|
+
pinned = "pinned"
|
|
39
|
+
encrypted = "encrypted"
|
|
40
|
+
trashed = "trashed"
|
|
41
|
+
archived = "archived"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _note_to_dict(note: Note, with_text: bool = False) -> dict:
|
|
45
|
+
data = note_metadata(note)
|
|
46
|
+
if with_text:
|
|
47
|
+
data["text"] = note.text
|
|
48
|
+
data["attachments"] = [
|
|
49
|
+
{
|
|
50
|
+
"filename": a.filename,
|
|
51
|
+
"path": str(a.path),
|
|
52
|
+
"size": a.size,
|
|
53
|
+
"exists": a.exists,
|
|
54
|
+
}
|
|
55
|
+
for a in note.attachments
|
|
56
|
+
]
|
|
57
|
+
return data
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
DbPathOption = Annotated[
|
|
61
|
+
Path,
|
|
62
|
+
typer.Option("--db", envvar="BEAR_DB_PATH", help="Path to the Bear SQLite database."),
|
|
63
|
+
]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _open_db(path: Path) -> BearDB:
|
|
67
|
+
try:
|
|
68
|
+
return BearDB(path)
|
|
69
|
+
except FileNotFoundError as exc:
|
|
70
|
+
console.print(f"[red]Error:[/red] {exc}")
|
|
71
|
+
raise typer.Exit(1) from None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _parse_date(value: str | None, option: str) -> datetime | None:
|
|
75
|
+
if value is None:
|
|
76
|
+
return None
|
|
77
|
+
try:
|
|
78
|
+
return datetime.fromisoformat(value)
|
|
79
|
+
except ValueError:
|
|
80
|
+
console.print(
|
|
81
|
+
f"[red]Error:[/red] invalid date for {option}: {value!r} "
|
|
82
|
+
"(expected ISO format, e.g. 2026-07-01 or 2026-07-01T14:30)"
|
|
83
|
+
)
|
|
84
|
+
raise typer.Exit(2) from None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _text_or_stdin(text: str | None) -> str | None:
|
|
88
|
+
if text is None and not sys.stdin.isatty():
|
|
89
|
+
return sys.stdin.read()
|
|
90
|
+
return text
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _require_note(db: BearDB, note_id: str) -> Note:
|
|
94
|
+
try:
|
|
95
|
+
note = db.get_note(note_id)
|
|
96
|
+
except AmbiguousNoteId as exc:
|
|
97
|
+
console.print(f"[red]Error:[/red] note id prefix {exc.prefix!r} matches several notes:")
|
|
98
|
+
for full_id, title in exc.matches:
|
|
99
|
+
console.print(f" {full_id} {title}")
|
|
100
|
+
raise typer.Exit(1) from None
|
|
101
|
+
if note is None:
|
|
102
|
+
console.print(f"[red]Error:[/red] no note with id {note_id!r}")
|
|
103
|
+
raise typer.Exit(1)
|
|
104
|
+
return note
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _verify(ok: Callable[[], bool], success: str, failure: str) -> None:
|
|
108
|
+
if actions.wait_for(ok):
|
|
109
|
+
console.print(success)
|
|
110
|
+
else:
|
|
111
|
+
console.print(f"[red]Error:[/red] {failure}")
|
|
112
|
+
raise typer.Exit(1)
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""The export command."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Annotated
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
from rich import box
|
|
10
|
+
from rich.markup import escape as rich_escape
|
|
11
|
+
from rich.table import Table
|
|
12
|
+
|
|
13
|
+
from bearcli.cli.common import (
|
|
14
|
+
DbPathOption,
|
|
15
|
+
_open_db,
|
|
16
|
+
app,
|
|
17
|
+
console,
|
|
18
|
+
)
|
|
19
|
+
from bearcli.db import DEFAULT_DB_PATH
|
|
20
|
+
from bearcli.export import export_notes
|
|
21
|
+
from bearcli.gitsync import GitError, export_and_push
|
|
22
|
+
from bearcli.secrets import SecretFinding, redaction_map, scan_notes
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _report_secrets(findings: list[SecretFinding]) -> None:
|
|
26
|
+
table = Table(box=box.ROUNDED, header_style="bold")
|
|
27
|
+
table.add_column("Note", overflow="ellipsis", max_width=30)
|
|
28
|
+
table.add_column("ID", style="dim", no_wrap=True)
|
|
29
|
+
table.add_column("Rule", style="yellow")
|
|
30
|
+
table.add_column("Line", justify="right")
|
|
31
|
+
table.add_column("Match", style="red")
|
|
32
|
+
for f in findings:
|
|
33
|
+
table.add_row(f.note_title, f.note_id, f.rule, str(f.line), f.excerpt)
|
|
34
|
+
console.print(table)
|
|
35
|
+
notes = len({f.note_id for f in findings})
|
|
36
|
+
console.print(
|
|
37
|
+
f"[red]Export blocked:[/red] {len(findings)} potential secret(s) in {notes} note(s). "
|
|
38
|
+
"Move them somewhere safe (or into an encrypted note), re-run with --redact-secrets "
|
|
39
|
+
"to export with placeholders, or with --allow-secrets to export as-is."
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@app.command()
|
|
44
|
+
def export(
|
|
45
|
+
dest: Annotated[Path, typer.Argument(help="Destination directory for the markdown files.")],
|
|
46
|
+
sync: Annotated[
|
|
47
|
+
bool,
|
|
48
|
+
typer.Option(
|
|
49
|
+
"--sync",
|
|
50
|
+
help="Only rewrite notes that changed since the last export instead of everything.",
|
|
51
|
+
),
|
|
52
|
+
] = False,
|
|
53
|
+
push: Annotated[
|
|
54
|
+
bool,
|
|
55
|
+
typer.Option(
|
|
56
|
+
"--push",
|
|
57
|
+
help="Treat DEST as a git clone: commit the export and push. Bear is the source of "
|
|
58
|
+
"truth — remote or manual edits are kept in history but overwritten in HEAD.",
|
|
59
|
+
),
|
|
60
|
+
] = False,
|
|
61
|
+
allow_secrets: Annotated[
|
|
62
|
+
bool,
|
|
63
|
+
typer.Option("--allow-secrets", help="Export even if the secret scan finds potential credentials."),
|
|
64
|
+
] = False,
|
|
65
|
+
redact_secrets: Annotated[
|
|
66
|
+
bool,
|
|
67
|
+
typer.Option(
|
|
68
|
+
"--redact-secrets",
|
|
69
|
+
help="Export with detected secrets replaced by a [redacted: <rule>] placeholder "
|
|
70
|
+
"(notes in Bear are untouched).",
|
|
71
|
+
),
|
|
72
|
+
] = False,
|
|
73
|
+
db_path: DbPathOption = DEFAULT_DB_PATH,
|
|
74
|
+
) -> None:
|
|
75
|
+
"""Export all notes as markdown files with frontmatter and attachments."""
|
|
76
|
+
if allow_secrets and redact_secrets:
|
|
77
|
+
console.print("[red]Error:[/red] --allow-secrets and --redact-secrets are mutually exclusive")
|
|
78
|
+
raise typer.Exit(2)
|
|
79
|
+
db = _open_db(db_path)
|
|
80
|
+
try:
|
|
81
|
+
redactions: dict[str, dict[str, str]] | None = None
|
|
82
|
+
if not allow_secrets:
|
|
83
|
+
with console.status("Scanning notes for secrets…", spinner="dots"):
|
|
84
|
+
candidates = db.list_notes(limit=None, include_archived=True, with_text=True)
|
|
85
|
+
findings = scan_notes(candidates)
|
|
86
|
+
if findings and not redact_secrets:
|
|
87
|
+
_report_secrets(findings)
|
|
88
|
+
raise typer.Exit(1)
|
|
89
|
+
if findings:
|
|
90
|
+
redactions = redaction_map(findings)
|
|
91
|
+
with console.status("Exporting…", spinner="dots") as status:
|
|
92
|
+
update = lambda msg: status.update(rich_escape(msg)) # noqa: E731
|
|
93
|
+
if push:
|
|
94
|
+
try:
|
|
95
|
+
result, outcome = export_and_push(db, dest, sync=sync, progress=update, redactions=redactions)
|
|
96
|
+
except GitError as exc:
|
|
97
|
+
console.print(f"[red]Error:[/red] {exc}")
|
|
98
|
+
raise typer.Exit(1) from None
|
|
99
|
+
else:
|
|
100
|
+
result = export_notes(db, dest, sync=sync, progress=update, redactions=redactions)
|
|
101
|
+
finally:
|
|
102
|
+
db.close()
|
|
103
|
+
|
|
104
|
+
parts = [f"{result.written} written"]
|
|
105
|
+
if push:
|
|
106
|
+
parts.append(outcome)
|
|
107
|
+
if sync:
|
|
108
|
+
parts.append(f"{result.unchanged} unchanged")
|
|
109
|
+
if result.removed:
|
|
110
|
+
parts.append(f"{result.removed} removed")
|
|
111
|
+
if result.skipped_encrypted:
|
|
112
|
+
parts.append(f"{result.skipped_encrypted} encrypted skipped")
|
|
113
|
+
if result.index_updated:
|
|
114
|
+
parts.append("index updated")
|
|
115
|
+
if redactions:
|
|
116
|
+
secrets_count = sum(len(v) for v in redactions.values())
|
|
117
|
+
parts.append(f"{secrets_count} secret(s) redacted in {len(redactions)} note(s)")
|
|
118
|
+
console.print(f"Exported to {dest}: " + ", ".join(parts))
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Library-wide commands: stats and the terminal UI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Annotated
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
from rich import box
|
|
10
|
+
from rich.table import Table
|
|
11
|
+
|
|
12
|
+
from bearcli.cli.common import (
|
|
13
|
+
DbPathOption,
|
|
14
|
+
OutputFormat,
|
|
15
|
+
_open_db,
|
|
16
|
+
app,
|
|
17
|
+
console,
|
|
18
|
+
)
|
|
19
|
+
from bearcli.db import DEFAULT_DB_PATH
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@app.command()
|
|
23
|
+
def stats(
|
|
24
|
+
fmt: Annotated[
|
|
25
|
+
OutputFormat,
|
|
26
|
+
typer.Option("--format", "-f", help="Output format: table or json."),
|
|
27
|
+
] = OutputFormat.table,
|
|
28
|
+
db_path: DbPathOption = DEFAULT_DB_PATH,
|
|
29
|
+
) -> None:
|
|
30
|
+
"""Show statistics about the note library."""
|
|
31
|
+
db = _open_db(db_path)
|
|
32
|
+
try:
|
|
33
|
+
notes = db.list_notes(limit=None, include_trashed=True, include_archived=True, with_text=True)
|
|
34
|
+
tag_counts = db.list_tags()
|
|
35
|
+
attachment_count, attachment_bytes = db.attachment_stats()
|
|
36
|
+
finally:
|
|
37
|
+
db.close()
|
|
38
|
+
|
|
39
|
+
active = [n for n in notes if not n.trashed and not n.archived]
|
|
40
|
+
by_year: dict[str, int] = {}
|
|
41
|
+
for n in notes:
|
|
42
|
+
if not n.trashed and n.created:
|
|
43
|
+
year = str(n.created.year)
|
|
44
|
+
by_year[year] = by_year.get(year, 0) + 1
|
|
45
|
+
|
|
46
|
+
data = {
|
|
47
|
+
"notes": len(notes),
|
|
48
|
+
"active": len(active),
|
|
49
|
+
"archived": sum(1 for n in notes if n.archived and not n.trashed),
|
|
50
|
+
"trashed": sum(1 for n in notes if n.trashed),
|
|
51
|
+
"pinned": sum(1 for n in notes if n.pinned and not n.trashed),
|
|
52
|
+
"encrypted": sum(1 for n in notes if n.encrypted and not n.trashed),
|
|
53
|
+
"words": sum(len((n.text or "").split()) for n in notes if not n.trashed),
|
|
54
|
+
"tags": len(tag_counts),
|
|
55
|
+
"attachments": attachment_count,
|
|
56
|
+
"attachment_bytes": attachment_bytes,
|
|
57
|
+
"notes_by_year": dict(sorted(by_year.items())),
|
|
58
|
+
"top_tags": [{"tag": t, "notes": c} for t, c in sorted(tag_counts, key=lambda tc: -tc[1])[:10]],
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if fmt is OutputFormat.json:
|
|
62
|
+
print(json.dumps(data, indent=2, ensure_ascii=False))
|
|
63
|
+
return
|
|
64
|
+
|
|
65
|
+
table = Table(box=box.ROUNDED, show_header=False, width=44)
|
|
66
|
+
table.add_column(style="bold")
|
|
67
|
+
table.add_column(justify="right")
|
|
68
|
+
table.add_row("Notes", str(data["notes"]))
|
|
69
|
+
table.add_row(" active", str(data["active"]))
|
|
70
|
+
table.add_row(" archived", str(data["archived"]))
|
|
71
|
+
table.add_row(" trashed", str(data["trashed"]))
|
|
72
|
+
table.add_row(" pinned", str(data["pinned"]))
|
|
73
|
+
table.add_row(" encrypted", str(data["encrypted"]))
|
|
74
|
+
table.add_row("Words", f"{data['words']:,}")
|
|
75
|
+
table.add_row("Tags", str(data["tags"]))
|
|
76
|
+
table.add_row("Attachments", f"{attachment_count} ({attachment_bytes / 1_000_000:.1f} MB)")
|
|
77
|
+
console.print(table)
|
|
78
|
+
|
|
79
|
+
if data["top_tags"]:
|
|
80
|
+
tag_table = Table(box=box.ROUNDED, header_style="bold", width=44)
|
|
81
|
+
tag_table.add_column("Top tags", style="cyan")
|
|
82
|
+
tag_table.add_column("Notes", justify="right")
|
|
83
|
+
for entry in data["top_tags"]:
|
|
84
|
+
tag_table.add_row(str(entry["tag"]), str(entry["notes"]))
|
|
85
|
+
console.print(tag_table)
|
|
86
|
+
|
|
87
|
+
year_table = Table(box=box.ROUNDED, header_style="bold", width=44)
|
|
88
|
+
year_table.add_column("Created", style="cyan")
|
|
89
|
+
year_table.add_column("Notes", justify="right")
|
|
90
|
+
for year, count in data["notes_by_year"].items():
|
|
91
|
+
year_table.add_row(year, str(count))
|
|
92
|
+
console.print(year_table)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@app.command()
|
|
96
|
+
def ui(
|
|
97
|
+
fuzzy: Annotated[bool, typer.Option("--fuzzy", help="Typo-tolerant ranked filtering.")] = False,
|
|
98
|
+
tag_filter: Annotated[str | None, typer.Option("--tag", "-t", help="Restrict to notes with this tag.")] = None,
|
|
99
|
+
db_path: DbPathOption = DEFAULT_DB_PATH,
|
|
100
|
+
) -> None:
|
|
101
|
+
"""Bear in the terminal: search, edit, create, tag, and organize notes."""
|
|
102
|
+
from bearcli.tui import run_ui
|
|
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)
|