notesctl 1.0.2__tar.gz → 1.1.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.
Files changed (34) hide show
  1. notesctl-1.1.0/.github/dependabot.yml +20 -0
  2. notesctl-1.1.0/.github/workflows/ci.yml +28 -0
  3. {notesctl-1.0.2 → notesctl-1.1.0}/.github/workflows/publish.yml +13 -8
  4. notesctl-1.1.0/AGENTS.md +8 -0
  5. {notesctl-1.0.2 → notesctl-1.1.0}/CHANGELOG.md +17 -1
  6. {notesctl-1.0.2 → notesctl-1.1.0}/CLAUDE.md +8 -9
  7. notesctl-1.1.0/Makefile +15 -0
  8. {notesctl-1.0.2 → notesctl-1.1.0}/PKG-INFO +12 -16
  9. {notesctl-1.0.2 → notesctl-1.1.0}/README.md +10 -12
  10. {notesctl-1.0.2 → notesctl-1.1.0}/pyproject.toml +3 -31
  11. notesctl-1.1.0/scripts/live-e2e.py +54 -0
  12. {notesctl-1.0.2 → notesctl-1.1.0}/src/notesctl/__init__.py +1 -1
  13. {notesctl-1.0.2 → notesctl-1.1.0}/src/notesctl/cli.py +47 -13
  14. {notesctl-1.0.2 → notesctl-1.1.0}/uv.lock +230 -435
  15. notesctl-1.0.2/.github/workflows/ci.yml +0 -68
  16. notesctl-1.0.2/tests/__init__.py +0 -1
  17. notesctl-1.0.2/tests/test_connection.py +0 -94
  18. notesctl-1.0.2/tests/test_converter.py +0 -216
  19. notesctl-1.0.2/tests/test_models.py +0 -101
  20. notesctl-1.0.2/tests/test_parser.py +0 -68
  21. {notesctl-1.0.2 → notesctl-1.1.0}/.gitignore +0 -0
  22. {notesctl-1.0.2 → notesctl-1.1.0}/LICENSE +0 -0
  23. {notesctl-1.0.2 → notesctl-1.1.0}/proto/notestore.proto +0 -0
  24. {notesctl-1.0.2 → notesctl-1.1.0}/src/notesctl/converter/__init__.py +0 -0
  25. {notesctl-1.0.2 → notesctl-1.1.0}/src/notesctl/converter/markdown.py +0 -0
  26. {notesctl-1.0.2 → notesctl-1.1.0}/src/notesctl/database/__init__.py +0 -0
  27. {notesctl-1.0.2 → notesctl-1.1.0}/src/notesctl/database/connection.py +0 -0
  28. {notesctl-1.0.2 → notesctl-1.1.0}/src/notesctl/database/models.py +0 -0
  29. {notesctl-1.0.2 → notesctl-1.1.0}/src/notesctl/database/queries.py +0 -0
  30. {notesctl-1.0.2 → notesctl-1.1.0}/src/notesctl/exporter.py +0 -0
  31. {notesctl-1.0.2 → notesctl-1.1.0}/src/notesctl/parser/__init__.py +0 -0
  32. {notesctl-1.0.2 → notesctl-1.1.0}/src/notesctl/parser/note_parser.py +0 -0
  33. {notesctl-1.0.2 → notesctl-1.1.0}/src/notesctl/parser/protobuf/__init__.py +0 -0
  34. {notesctl-1.0.2 → notesctl-1.1.0}/src/notesctl/parser/protobuf/notestore_pb2.py +0 -0
@@ -0,0 +1,20 @@
1
+ version: 2
2
+ updates:
3
+ - package-ecosystem: uv
4
+ directory: /
5
+ schedule:
6
+ interval: weekly
7
+ cooldown:
8
+ default-days: 7
9
+ groups:
10
+ python:
11
+ patterns: ["*"]
12
+ - package-ecosystem: github-actions
13
+ directory: /
14
+ schedule:
15
+ interval: weekly
16
+ cooldown:
17
+ default-days: 7
18
+ groups:
19
+ actions:
20
+ patterns: ["*"]
@@ -0,0 +1,28 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [master, main]
6
+ pull_request:
7
+
8
+ permissions:
9
+ contents: read
10
+
11
+ jobs:
12
+ check:
13
+ runs-on: macos-latest
14
+ timeout-minutes: 15
15
+ strategy:
16
+ matrix:
17
+ python: ["3.11", "3.12", "3.13"]
18
+ steps:
19
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
20
+ with:
21
+ persist-credentials: false
22
+ - uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0
23
+ with:
24
+ python-version: ${{ matrix.python }}
25
+ - name: Static checks and package build
26
+ run: make check
27
+ - name: Check installed CLI
28
+ run: uv run --locked --no-config notesctl --version
@@ -10,24 +10,29 @@ on:
10
10
  required: false
11
11
  default: 'false'
12
12
 
13
+ permissions:
14
+ contents: read
15
+
13
16
  jobs:
14
17
  build:
15
18
  runs-on: ubuntu-latest
16
19
 
17
20
  steps:
18
- - uses: actions/checkout@v4
21
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
22
+ with:
23
+ persist-credentials: false
19
24
 
20
25
  - name: Install uv
21
- uses: astral-sh/setup-uv@v5
26
+ uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0
22
27
 
23
28
  - name: Set up Python
24
29
  run: uv python install 3.12
25
30
 
26
31
  - name: Build package
27
- run: uv build
32
+ run: make check
28
33
 
29
34
  - name: Upload artifacts
30
- uses: actions/upload-artifact@v4
35
+ uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
31
36
  with:
32
37
  name: dist
33
38
  path: dist/
@@ -46,13 +51,13 @@ jobs:
46
51
 
47
52
  steps:
48
53
  - name: Download artifacts
49
- uses: actions/download-artifact@v4
54
+ uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
50
55
  with:
51
56
  name: dist
52
57
  path: dist/
53
58
 
54
59
  - name: Publish to Test PyPI
55
- uses: pypa/gh-action-pypi-publish@release/v1
60
+ uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2
56
61
  with:
57
62
  repository-url: https://test.pypi.org/legacy/
58
63
 
@@ -70,10 +75,10 @@ jobs:
70
75
 
71
76
  steps:
72
77
  - name: Download artifacts
73
- uses: actions/download-artifact@v4
78
+ uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
74
79
  with:
75
80
  name: dist
76
81
  path: dist/
77
82
 
78
83
  - name: Publish to PyPI
79
- uses: pypa/gh-action-pypi-publish@release/v1
84
+ uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2
@@ -0,0 +1,8 @@
1
+ # Agent instructions
2
+
3
+ Run `make check` for static checks and package validation.
4
+ Run `make test` for real Apple Notes reads when database access is available.
5
+ Report missing database access as unverified.
6
+ Keep only live end-to-end tests and static checks. Do not add unit or mock tests.
7
+ Preserve read-only access to Apple Notes. Never write to the source database.
8
+ Use feature branches and pull requests.
@@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.1.0] - 2026-10-10
11
+
12
+ - Add version aliases and machine-readable doctor output.
13
+ - Send errors and progress to stderr; preserve read-only source access.
14
+ - Replace synthetic suites with real Notes checks and static package validation.
15
+ - Keep package and CLI version metadata consistent.
16
+
17
+ ## [1.0.3] - 2026-06-08
18
+
19
+ ### Fixed
20
+
21
+ - `export` now exits with a non-zero status when notes are skipped due to errors, so backup and automation workflows no longer treat an incomplete export as successful
22
+ - `list-notes --limit` rejects values below 1, preventing negative limits from hiding notes and printing a misleading "Showing -1 of N notes" count
23
+
10
24
  ## [1.0.2] - 2026-01-17
11
25
 
12
26
  ### Fixed
@@ -50,7 +64,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
50
64
  - Original Notes database is never modified
51
65
  - All queries validated to be SELECT statements only
52
66
 
53
- [Unreleased]: https://github.com/jwmoss/notesctl/compare/v1.0.2...HEAD
67
+ [Unreleased]: https://github.com/jwmoss/notesctl/compare/v1.1.0...HEAD
68
+ [1.1.0]: https://github.com/jwmoss/notesctl/compare/v1.0.3...v1.1.0
69
+ [1.0.3]: https://github.com/jwmoss/notesctl/compare/v1.0.2...v1.0.3
54
70
  [1.0.2]: https://github.com/jwmoss/notesctl/compare/v1.0.1...v1.0.2
55
71
  [1.0.1]: https://github.com/jwmoss/notesctl/compare/v1.0.0...v1.0.1
56
72
  [1.0.0]: https://github.com/jwmoss/notesctl/releases/tag/v1.0.0
@@ -14,15 +14,11 @@ uv run notesctl list-notes
14
14
  uv run notesctl list-folders
15
15
  uv run notesctl stats
16
16
 
17
- # Run tests
18
- uv run pytest # all tests
19
- uv run pytest tests/test_parser.py # single file
20
- uv run pytest -k "test_name" # single test by name
21
-
22
- # Linting and type checking
23
- uv run ruff check src tests # lint
24
- uv run ruff format src tests # format
25
- uv run ty check # type check
17
+ # Static checks and package build
18
+ make check
19
+
20
+ # Actual local Apple Notes reads (requires macOS database access)
21
+ make test
26
22
  ```
27
23
 
28
24
  ## Architecture
@@ -61,3 +57,6 @@ The tool is designed to never modify the original Notes database:
61
57
  ### Protobuf Files
62
58
 
63
59
  The `parser/protobuf/notestore_pb2.py` is auto-generated and excluded from ty/ruff checks. The protobuf schema defines how Apple Notes stores note content (text, formatting, attachments).
60
+
61
+ Keep only real end-to-end CLI tests and static checks. Do not add unit or mock tests.
62
+ Live checks must not write to the source Notes database.
@@ -0,0 +1,15 @@
1
+ .PHONY: check test build
2
+
3
+ check:
4
+ uv lock --check --no-config
5
+ uv sync --locked --all-extras --no-config
6
+ uv run --locked --no-config ruff check src/ scripts/
7
+ uv run --locked --no-config ruff format --check src/ scripts/
8
+ uv run --locked --no-config ty check
9
+ uv build --no-config
10
+
11
+ test:
12
+ uv run --locked --no-config python scripts/live-e2e.py
13
+
14
+ build:
15
+ uv build --no-config
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: notesctl
3
- Version: 1.0.2
3
+ Version: 1.1.0
4
4
  Summary: Export Apple iCloud Notes to Markdown files safely
5
5
  Project-URL: Homepage, https://github.com/jwmoss/notesctl
6
6
  Project-URL: Repository, https://github.com/jwmoss/notesctl
@@ -28,8 +28,6 @@ Requires-Dist: rich>=13.0.0
28
28
  Requires-Dist: typer>=0.9.0
29
29
  Provides-Extra: dev
30
30
  Requires-Dist: grpcio-tools>=1.60.0; extra == 'dev'
31
- Requires-Dist: pytest-cov>=4.0.0; extra == 'dev'
32
- Requires-Dist: pytest>=7.0.0; extra == 'dev'
33
31
  Requires-Dist: ruff>=0.8.0; extra == 'dev'
34
32
  Requires-Dist: ty>=0.0.1; extra == 'dev'
35
33
  Description-Content-Type: text/markdown
@@ -88,21 +86,19 @@ This tool prioritizes safety:
88
86
  ## Development
89
87
 
90
88
  ```bash
91
- # Install dev dependencies
92
- uv pip install -e ".[dev]"
93
-
94
- # Run tests
95
- uv run pytest
89
+ make check # lint, format, types, lock, package build
90
+ make test # real local Apple Notes reads
91
+ ```
96
92
 
97
- # Run linter
98
- uv run ruff check src tests
93
+ Live checks require a populated Apple Notes database and macOS file access. They
94
+ read database copies and preview a Markdown export through the installed CLI.
95
+ They never write to Apple Notes or save exported notes.
96
+ CI runs static and package checks. The project has no unit or mock test suite.
99
97
 
100
- # Run type checker
101
- uv run mypy src
98
+ Use `notesctl --version` or `notesctl version` for the package version.
99
+ Use `notesctl doctor --json` to verify database access and read note counts.
100
+ Command errors use stderr; data and tables use stdout.
102
101
 
103
- # Format code
104
- uv run ruff format src tests
105
- ```
106
102
 
107
103
  ## Requirements
108
104
 
@@ -52,21 +52,19 @@ This tool prioritizes safety:
52
52
  ## Development
53
53
 
54
54
  ```bash
55
- # Install dev dependencies
56
- uv pip install -e ".[dev]"
57
-
58
- # Run tests
59
- uv run pytest
55
+ make check # lint, format, types, lock, package build
56
+ make test # real local Apple Notes reads
57
+ ```
60
58
 
61
- # Run linter
62
- uv run ruff check src tests
59
+ Live checks require a populated Apple Notes database and macOS file access. They
60
+ read database copies and preview a Markdown export through the installed CLI.
61
+ They never write to Apple Notes or save exported notes.
62
+ CI runs static and package checks. The project has no unit or mock test suite.
63
63
 
64
- # Run type checker
65
- uv run mypy src
64
+ Use `notesctl --version` or `notesctl version` for the package version.
65
+ Use `notesctl doctor --json` to verify database access and read note counts.
66
+ Command errors use stderr; data and tables use stdout.
66
67
 
67
- # Format code
68
- uv run ruff format src tests
69
- ```
70
68
 
71
69
  ## Requirements
72
70
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "notesctl"
7
- version = "1.0.2"
7
+ version = "1.1.0"
8
8
  description = "Export Apple iCloud Notes to Markdown files safely"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -39,8 +39,6 @@ Changelog = "https://github.com/jwmoss/notesctl/blob/main/CHANGELOG.md"
39
39
 
40
40
  [project.optional-dependencies]
41
41
  dev = [
42
- "pytest>=7.0.0",
43
- "pytest-cov>=4.0.0",
44
42
  "ruff>=0.8.0",
45
43
  "ty>=0.0.1",
46
44
  "grpcio-tools>=1.60.0",
@@ -55,7 +53,7 @@ packages = ["src/notesctl"]
55
53
  [tool.ruff]
56
54
  line-length = 100
57
55
  target-version = "py311"
58
- src = ["src", "tests"]
56
+ src = ["src"]
59
57
 
60
58
  [tool.ruff.lint]
61
59
  select = [
@@ -103,7 +101,6 @@ ignore = [
103
101
  ]
104
102
 
105
103
  [tool.ruff.lint.per-file-ignores]
106
- "tests/*" = ["S101", "ARG", "PLR2004", "DTZ", "SIM105", "PLC0415", "SLF001", "SIM117"]
107
104
  "*_pb2.py" = ["ALL"]
108
105
 
109
106
  [tool.ruff.lint.isort]
@@ -113,7 +110,7 @@ known-first-party = ["notesctl"]
113
110
  python-version = "3.11"
114
111
 
115
112
  [tool.ty.src]
116
- include = ["src", "tests"]
113
+ include = ["src"]
117
114
  exclude = ["*_pb2.py"]
118
115
 
119
116
  [[tool.ty.overrides]]
@@ -128,28 +125,3 @@ missing-argument = "ignore"
128
125
  too-many-positional-arguments = "ignore"
129
126
  invalid-return-type = "ignore"
130
127
  unresolved-attribute = "ignore"
131
-
132
- [tool.pytest.ini_options]
133
- testpaths = ["tests"]
134
- pythonpath = ["src"]
135
- addopts = [
136
- "-v",
137
- "--tb=short",
138
- "--strict-markers",
139
- ]
140
- markers = [
141
- "slow: marks tests as slow",
142
- ]
143
-
144
- [tool.coverage.run]
145
- source = ["src/notesctl"]
146
- branch = true
147
- omit = ["src/notesctl/parser/protobuf/*"]
148
-
149
- [tool.coverage.report]
150
- exclude_lines = [
151
- "pragma: no cover",
152
- "def __repr__",
153
- "raise NotImplementedError",
154
- "if TYPE_CHECKING:",
155
- ]
@@ -0,0 +1,54 @@
1
+ """Read the real Apple Notes database through the installed CLI."""
2
+
3
+ import json
4
+ import re
5
+ import subprocess
6
+ import sys
7
+ from pathlib import Path
8
+ from tempfile import TemporaryDirectory
9
+
10
+
11
+ def main() -> None:
12
+ """Check database access and an actual note listing without source writes."""
13
+ result = subprocess.run(
14
+ ["notesctl", "doctor", "--json"],
15
+ capture_output=True,
16
+ text=True,
17
+ timeout=60,
18
+ check=True,
19
+ )
20
+ counts = json.loads(result.stdout)
21
+ if not isinstance(counts.get("total_notes"), int) or counts["total_notes"] < 1:
22
+ raise RuntimeError("No real Notes records found; live proof requires a populated database")
23
+ result = subprocess.run(
24
+ ["notesctl", "list-notes", "--limit", "1"],
25
+ capture_output=True,
26
+ text=True,
27
+ timeout=60,
28
+ check=True,
29
+ )
30
+ if "Notes (" not in result.stdout:
31
+ raise RuntimeError("The CLI did not list a real Notes record")
32
+ with TemporaryDirectory(prefix="notesctl-live-") as work_dir:
33
+ output = Path(work_dir) / "export"
34
+ result = subprocess.run(
35
+ ["notesctl", "export", "--dry-run", "--output", str(output)],
36
+ capture_output=True,
37
+ text=True,
38
+ timeout=120,
39
+ check=True,
40
+ )
41
+ exported = re.search(r"Would export (\d+) notes", result.stdout)
42
+ if not exported or int(exported[1]) < 1 or "Failed to export" in result.stderr:
43
+ raise RuntimeError("The live export preview did not convert real Notes records")
44
+ if output.exists():
45
+ raise RuntimeError("The export preview created its output directory")
46
+ print("PASS real Apple Notes CLI reads and export preview")
47
+
48
+
49
+ if __name__ == "__main__":
50
+ try:
51
+ main()
52
+ except (RuntimeError, ValueError, OSError, subprocess.SubprocessError) as error:
53
+ print(f"FAIL {error}; run notesctl doctor to inspect database access", file=sys.stderr)
54
+ sys.exit(1)
@@ -1,3 +1,3 @@
1
1
  """iCloud Notes to Markdown Exporter."""
2
2
 
3
- __version__ = "1.0.1"
3
+ __version__ = "1.1.0"
@@ -1,5 +1,7 @@
1
1
  """Command-line interface for notesctl."""
2
2
 
3
+ import json
4
+ from importlib.metadata import version as package_version
3
5
  from pathlib import Path
4
6
  from typing import Annotated
5
7
 
@@ -14,8 +16,28 @@ app = typer.Typer(
14
16
  name="notesctl",
15
17
  help="Export Apple iCloud Notes to Markdown files.",
16
18
  no_args_is_help=True,
19
+ invoke_without_command=True,
17
20
  )
18
21
  console = Console()
22
+ error_console = Console(stderr=True)
23
+
24
+
25
+ @app.command("version")
26
+ def version() -> None:
27
+ """Print version information."""
28
+ typer.echo(f"notesctl, version {package_version('notesctl')}")
29
+
30
+
31
+ @app.callback()
32
+ def root(
33
+ show_version: Annotated[
34
+ bool, typer.Option("--version", is_eager=True, help="Print version information")
35
+ ] = False,
36
+ ) -> None:
37
+ """Export Apple Notes through read-only database access."""
38
+ if show_version:
39
+ version()
40
+ raise typer.Exit
19
41
 
20
42
 
21
43
  @app.command()
@@ -93,14 +115,14 @@ def export(
93
115
  with Progress(
94
116
  SpinnerColumn(),
95
117
  TextColumn("[progress.description]{task.description}"),
96
- console=console,
118
+ console=error_console,
97
119
  ) as progress:
98
120
  result = exporter.export(options, progress)
99
121
  except FileNotFoundError as e:
100
- console.print(f"[red]Error:[/red] {e}")
122
+ error_console.print(f"[red]Error:[/red] {e}")
101
123
  raise typer.Exit(1) from None
102
124
  except Exception as e:
103
- console.print(f"[red]Error:[/red] {e}")
125
+ error_console.print(f"[red]Error:[/red] {e}")
104
126
  raise typer.Exit(1) from None
105
127
 
106
128
  # Print results
@@ -119,15 +141,18 @@ def export(
119
141
  console.print(f" Attachments copied: {result.attachments_copied}")
120
142
 
121
143
  if result.skipped_encrypted:
122
- console.print(f"[yellow]Skipped {result.skipped_encrypted} encrypted notes[/yellow]")
144
+ error_console.print(f"[yellow]Skipped {result.skipped_encrypted} encrypted notes[/yellow]")
123
145
 
124
146
  if result.skipped_errors:
125
- console.print(f"[red]Failed to export {result.skipped_errors} notes[/red]")
147
+ error_console.print(f"[red]Failed to export {result.skipped_errors} notes[/red]")
126
148
 
127
149
  if result.errors:
128
- console.print("\n[red]Errors:[/red]")
150
+ error_console.print("\n[red]Errors:[/red]")
129
151
  for error in result.errors[:10]:
130
- console.print(f" {error}")
152
+ error_console.print(f" {error}")
153
+
154
+ if not dry_run and (result.skipped_errors or result.errors):
155
+ raise typer.Exit(1)
131
156
 
132
157
 
133
158
  @app.command("list-notes")
@@ -153,6 +178,7 @@ def list_notes(
153
178
  "--limit",
154
179
  "-l",
155
180
  help="Maximum number of notes to show",
181
+ min=1,
156
182
  ),
157
183
  ] = 50,
158
184
  db_path: Annotated[
@@ -170,10 +196,10 @@ def list_notes(
170
196
  try:
171
197
  notes = exporter.list_notes(folder, include_trashed)
172
198
  except FileNotFoundError as e:
173
- console.print(f"[red]Error:[/red] {e}")
199
+ error_console.print(f"[red]Error:[/red] {e}")
174
200
  raise typer.Exit(1) from None
175
201
  except Exception as e:
176
- console.print(f"[red]Error:[/red] {e}")
202
+ error_console.print(f"[red]Error:[/red] {e}")
177
203
  raise typer.Exit(1) from None
178
204
 
179
205
  if not notes:
@@ -227,10 +253,10 @@ def list_folders(
227
253
  try:
228
254
  folders = exporter.list_folders()
229
255
  except FileNotFoundError as e:
230
- console.print(f"[red]Error:[/red] {e}")
256
+ error_console.print(f"[red]Error:[/red] {e}")
231
257
  raise typer.Exit(1) from None
232
258
  except Exception as e:
233
- console.print(f"[red]Error:[/red] {e}")
259
+ error_console.print(f"[red]Error:[/red] {e}")
234
260
  raise typer.Exit(1) from None
235
261
 
236
262
  if not folders:
@@ -247,8 +273,12 @@ def list_folders(
247
273
  console.print(table)
248
274
 
249
275
 
276
+ @app.command("doctor")
250
277
  @app.command("stats")
251
278
  def stats(
279
+ json_output: Annotated[
280
+ bool, typer.Option("--json", help="Print database counts as JSON")
281
+ ] = False,
252
282
  db_path: Annotated[
253
283
  Path | None,
254
284
  typer.Option(
@@ -264,12 +294,16 @@ def stats(
264
294
  try:
265
295
  db_stats = exporter.get_stats()
266
296
  except FileNotFoundError as e:
267
- console.print(f"[red]Error:[/red] {e}")
297
+ error_console.print(f"[red]Error:[/red] {e}")
268
298
  raise typer.Exit(1) from None
269
299
  except Exception as e:
270
- console.print(f"[red]Error:[/red] {e}")
300
+ error_console.print(f"[red]Error:[/red] {e}")
271
301
  raise typer.Exit(1) from None
272
302
 
303
+ if json_output:
304
+ typer.echo(json.dumps(db_stats))
305
+ return
306
+
273
307
  console.print("[bold]Notes Database Statistics[/bold]\n")
274
308
  console.print(f" Total notes: {db_stats['total_notes']}")
275
309
  console.print(f" Encrypted notes: {db_stats['encrypted_notes']}")