bugcap 0.2.2__tar.gz → 0.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.
- {bugcap-0.2.2/src/bugcap.egg-info → bugcap-0.4.0}/PKG-INFO +7 -7
- {bugcap-0.2.2 → bugcap-0.4.0}/README.md +6 -6
- {bugcap-0.2.2 → bugcap-0.4.0}/pyproject.toml +1 -1
- bugcap-0.4.0/src/bugcap/__init__.py +1 -0
- {bugcap-0.2.2 → bugcap-0.4.0}/src/bugcap/agent_api.py +2 -1
- {bugcap-0.2.2 → bugcap-0.4.0}/src/bugcap/cli.py +48 -5
- {bugcap-0.2.2 → bugcap-0.4.0}/src/bugcap/dashboard/api.py +4 -2
- {bugcap-0.2.2 → bugcap-0.4.0}/src/bugcap/dashboard/static/app.js +2 -1
- bugcap-0.4.0/src/bugcap/drafts.py +134 -0
- bugcap-0.4.0/src/bugcap/live.py +564 -0
- bugcap-0.4.0/src/bugcap/live_session.py +300 -0
- {bugcap-0.2.2 → bugcap-0.4.0}/src/bugcap/mcp_server.py +2 -2
- {bugcap-0.2.2 → bugcap-0.4.0}/src/bugcap/refs.py +67 -10
- {bugcap-0.2.2 → bugcap-0.4.0}/src/bugcap/service.py +36 -10
- {bugcap-0.2.2 → bugcap-0.4.0}/src/bugcap/store.py +10 -0
- {bugcap-0.2.2 → bugcap-0.4.0}/src/bugcap/sync.py +26 -6
- {bugcap-0.2.2 → bugcap-0.4.0/src/bugcap.egg-info}/PKG-INFO +7 -7
- {bugcap-0.2.2 → bugcap-0.4.0}/src/bugcap.egg-info/SOURCES.txt +2 -0
- {bugcap-0.2.2 → bugcap-0.4.0}/tests/test_agent_api.py +1 -1
- {bugcap-0.2.2 → bugcap-0.4.0}/tests/test_cli_attach.py +8 -8
- {bugcap-0.2.2 → bugcap-0.4.0}/tests/test_cli_media.py +7 -7
- bugcap-0.4.0/tests/test_cli_no_image_delete.py +95 -0
- {bugcap-0.2.2 → bugcap-0.4.0}/tests/test_dashboard_mutations.py +1 -1
- bugcap-0.4.0/tests/test_drafts.py +77 -0
- {bugcap-0.2.2 → bugcap-0.4.0}/tests/test_github_media_sync.py +18 -2
- bugcap-0.4.0/tests/test_image_refs.py +82 -0
- bugcap-0.4.0/tests/test_live_session.py +289 -0
- {bugcap-0.2.2 → bugcap-0.4.0}/tests/test_refs_validate_rewrite.py +1 -1
- {bugcap-0.2.2 → bugcap-0.4.0}/tests/test_service_media.py +1 -1
- bugcap-0.2.2/src/bugcap/__init__.py +0 -1
- bugcap-0.2.2/src/bugcap/drafts.py +0 -83
- bugcap-0.2.2/src/bugcap/live.py +0 -292
- bugcap-0.2.2/src/bugcap/live_session.py +0 -125
- bugcap-0.2.2/tests/test_drafts.py +0 -43
- bugcap-0.2.2/tests/test_live_session.py +0 -125
- {bugcap-0.2.2 → bugcap-0.4.0}/LICENSE +0 -0
- {bugcap-0.2.2 → bugcap-0.4.0}/setup.cfg +0 -0
- {bugcap-0.2.2 → bugcap-0.4.0}/src/bugcap/backends.py +0 -0
- {bugcap-0.2.2 → bugcap-0.4.0}/src/bugcap/capture.py +0 -0
- {bugcap-0.2.2 → bugcap-0.4.0}/src/bugcap/config.py +0 -0
- {bugcap-0.2.2 → bugcap-0.4.0}/src/bugcap/dashboard/__init__.py +0 -0
- {bugcap-0.2.2 → bugcap-0.4.0}/src/bugcap/dashboard/media.py +0 -0
- {bugcap-0.2.2 → bugcap-0.4.0}/src/bugcap/dashboard/server.py +0 -0
- {bugcap-0.2.2 → bugcap-0.4.0}/src/bugcap/dashboard/static/app.css +0 -0
- {bugcap-0.2.2 → bugcap-0.4.0}/src/bugcap/dashboard/static/index.html +0 -0
- {bugcap-0.2.2 → bugcap-0.4.0}/src/bugcap/errors.py +0 -0
- {bugcap-0.2.2 → bugcap-0.4.0}/src/bugcap/ghcli.py +0 -0
- {bugcap-0.2.2 → bugcap-0.4.0}/src/bugcap/ingest.py +0 -0
- {bugcap-0.2.2 → bugcap-0.4.0}/src/bugcap/logs.py +0 -0
- {bugcap-0.2.2 → bugcap-0.4.0}/src/bugcap/paths.py +0 -0
- {bugcap-0.2.2 → bugcap-0.4.0}/src/bugcap/recorder.py +0 -0
- {bugcap-0.2.2 → bugcap-0.4.0}/src/bugcap/repo.py +0 -0
- {bugcap-0.2.2 → bugcap-0.4.0}/src/bugcap/tomlio.py +0 -0
- {bugcap-0.2.2 → bugcap-0.4.0}/src/bugcap/validation.py +0 -0
- {bugcap-0.2.2 → bugcap-0.4.0}/src/bugcap.egg-info/dependency_links.txt +0 -0
- {bugcap-0.2.2 → bugcap-0.4.0}/src/bugcap.egg-info/entry_points.txt +0 -0
- {bugcap-0.2.2 → bugcap-0.4.0}/src/bugcap.egg-info/requires.txt +0 -0
- {bugcap-0.2.2 → bugcap-0.4.0}/src/bugcap.egg-info/top_level.txt +0 -0
- {bugcap-0.2.2 → bugcap-0.4.0}/tests/test_backends.py +0 -0
- {bugcap-0.2.2 → bugcap-0.4.0}/tests/test_capture.py +0 -0
- {bugcap-0.2.2 → bugcap-0.4.0}/tests/test_cli_dashboard.py +0 -0
- {bugcap-0.2.2 → bugcap-0.4.0}/tests/test_cli_init_capture_list.py +0 -0
- {bugcap-0.2.2 → bugcap-0.4.0}/tests/test_cli_live_headless.py +0 -0
- {bugcap-0.2.2 → bugcap-0.4.0}/tests/test_cli_record.py +0 -0
- {bugcap-0.2.2 → bugcap-0.4.0}/tests/test_cli_setup.py +0 -0
- {bugcap-0.2.2 → bugcap-0.4.0}/tests/test_cli_triage.py +0 -0
- {bugcap-0.2.2 → bugcap-0.4.0}/tests/test_dashboard_api.py +0 -0
- {bugcap-0.2.2 → bugcap-0.4.0}/tests/test_dashboard_media.py +0 -0
- {bugcap-0.2.2 → bugcap-0.4.0}/tests/test_dashboard_security.py +0 -0
- {bugcap-0.2.2 → bugcap-0.4.0}/tests/test_dashboard_ui_static.py +0 -0
- {bugcap-0.2.2 → bugcap-0.4.0}/tests/test_ghcli.py +0 -0
- {bugcap-0.2.2 → bugcap-0.4.0}/tests/test_github_pull.py +0 -0
- {bugcap-0.2.2 → bugcap-0.4.0}/tests/test_github_sync.py +0 -0
- {bugcap-0.2.2 → bugcap-0.4.0}/tests/test_ingest.py +0 -0
- {bugcap-0.2.2 → bugcap-0.4.0}/tests/test_ingest_url.py +0 -0
- {bugcap-0.2.2 → bugcap-0.4.0}/tests/test_mcp.py +0 -0
- {bugcap-0.2.2 → bugcap-0.4.0}/tests/test_mcp_logging.py +0 -0
- {bugcap-0.2.2 → bugcap-0.4.0}/tests/test_paths.py +0 -0
- {bugcap-0.2.2 → bugcap-0.4.0}/tests/test_recorder_argv.py +0 -0
- {bugcap-0.2.2 → bugcap-0.4.0}/tests/test_recorder_devices.py +0 -0
- {bugcap-0.2.2 → bugcap-0.4.0}/tests/test_recorder_media_real.py +0 -0
- {bugcap-0.2.2 → bugcap-0.4.0}/tests/test_recorder_run.py +0 -0
- {bugcap-0.2.2 → bugcap-0.4.0}/tests/test_refs.py +0 -0
- {bugcap-0.2.2 → bugcap-0.4.0}/tests/test_regression_output.py +0 -0
- {bugcap-0.2.2 → bugcap-0.4.0}/tests/test_repo_config.py +0 -0
- {bugcap-0.2.2 → bugcap-0.4.0}/tests/test_repo_values.py +0 -0
- {bugcap-0.2.2 → bugcap-0.4.0}/tests/test_service_refs.py +0 -0
- {bugcap-0.2.2 → bugcap-0.4.0}/tests/test_store_migration.py +0 -0
- {bugcap-0.2.2 → bugcap-0.4.0}/tests/test_sync_consent.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: bugcap
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: Local-first, agent-readable bug capture: annotated screenshots + notes, stored locally, synced to trackers on your own schedule.
|
|
5
5
|
Author: Konrad Tagnon Amen ALAHASSA
|
|
6
6
|
License: MIT
|
|
@@ -63,13 +63,13 @@ bugcap keeps the whole loop in one place:
|
|
|
63
63
|
| 🔁 **Sync when ready** | Push to a GitHub issue with the usual inline image, plus a plain copy agents can read. |
|
|
64
64
|
| 🎞️ **Record what happens** | Short screen recordings as animated GIF, video or keyframes, with size caps. |
|
|
65
65
|
| 🖥️ **Triage in a browser** | A local dashboard on `127.0.0.1` to browse, filter and set statuses. |
|
|
66
|
-
| 🪟 **Live mode** | An always-on-top window
|
|
66
|
+
| 🪟 **Live mode** | An always-on-top window (top-right) to capture or record, stage several items, then file a new bug, add to an existing one, or log a note-only bug. |
|
|
67
67
|
|
|
68
68
|
## Quick start
|
|
69
69
|
|
|
70
70
|
```bash
|
|
71
|
-
# install
|
|
72
|
-
pipx install 'bugcap[mcp]
|
|
71
|
+
# install from PyPI
|
|
72
|
+
pipx install 'bugcap[mcp]'
|
|
73
73
|
|
|
74
74
|
bugcap setup --yes # detects or installs a capture tool
|
|
75
75
|
cd ~/code/myproject && bugcap init # scope reports to this repo
|
|
@@ -128,8 +128,8 @@ Requires Python 3.10 or newer. The core CLI uses only the standard library (plus
|
|
|
128
128
|
Python 3.11). The MCP server is an optional extra.
|
|
129
129
|
|
|
130
130
|
```bash
|
|
131
|
-
pipx install 'bugcap[mcp]
|
|
132
|
-
uv tool install 'bugcap[mcp]
|
|
131
|
+
pipx install 'bugcap[mcp]' # recommended
|
|
132
|
+
uv tool install 'bugcap[mcp]'
|
|
133
133
|
pip install -e '.[dev,mcp]' # development
|
|
134
134
|
```
|
|
135
135
|
|
|
@@ -146,7 +146,7 @@ pull and sync, MCP, and where data lives per OS.
|
|
|
146
146
|
|
|
147
147
|
Notes on the more involved behaviour:
|
|
148
148
|
|
|
149
|
-
-
|
|
149
|
+
- **References.** In a note, `@i1` is image 1, `@v2` a video, `@g3` an animated GIF, `@f4` a frame set, and `@login-error` a labelled item. `#3` points at report 3 (a missing one is kept as text with a warning). Media references are validated on save, and relabelling or removing a referenced item needs `--force`.
|
|
150
150
|
- **Repo values** (`init`, `sync`, `config repo set`) are checked for format and, with `gh`, for existence. Changing the repo identity offers to move existing reports.
|
|
151
151
|
- **Media sync** over `[sync] max_upload_mb` (default 25) is skipped with a warning.
|
|
152
152
|
|
|
@@ -47,13 +47,13 @@ bugcap keeps the whole loop in one place:
|
|
|
47
47
|
| 🔁 **Sync when ready** | Push to a GitHub issue with the usual inline image, plus a plain copy agents can read. |
|
|
48
48
|
| 🎞️ **Record what happens** | Short screen recordings as animated GIF, video or keyframes, with size caps. |
|
|
49
49
|
| 🖥️ **Triage in a browser** | A local dashboard on `127.0.0.1` to browse, filter and set statuses. |
|
|
50
|
-
| 🪟 **Live mode** | An always-on-top window
|
|
50
|
+
| 🪟 **Live mode** | An always-on-top window (top-right) to capture or record, stage several items, then file a new bug, add to an existing one, or log a note-only bug. |
|
|
51
51
|
|
|
52
52
|
## Quick start
|
|
53
53
|
|
|
54
54
|
```bash
|
|
55
|
-
# install
|
|
56
|
-
pipx install 'bugcap[mcp]
|
|
55
|
+
# install from PyPI
|
|
56
|
+
pipx install 'bugcap[mcp]'
|
|
57
57
|
|
|
58
58
|
bugcap setup --yes # detects or installs a capture tool
|
|
59
59
|
cd ~/code/myproject && bugcap init # scope reports to this repo
|
|
@@ -112,8 +112,8 @@ Requires Python 3.10 or newer. The core CLI uses only the standard library (plus
|
|
|
112
112
|
Python 3.11). The MCP server is an optional extra.
|
|
113
113
|
|
|
114
114
|
```bash
|
|
115
|
-
pipx install 'bugcap[mcp]
|
|
116
|
-
uv tool install 'bugcap[mcp]
|
|
115
|
+
pipx install 'bugcap[mcp]' # recommended
|
|
116
|
+
uv tool install 'bugcap[mcp]'
|
|
117
117
|
pip install -e '.[dev,mcp]' # development
|
|
118
118
|
```
|
|
119
119
|
|
|
@@ -130,7 +130,7 @@ pull and sync, MCP, and where data lives per OS.
|
|
|
130
130
|
|
|
131
131
|
Notes on the more involved behaviour:
|
|
132
132
|
|
|
133
|
-
-
|
|
133
|
+
- **References.** In a note, `@i1` is image 1, `@v2` a video, `@g3` an animated GIF, `@f4` a frame set, and `@login-error` a labelled item. `#3` points at report 3 (a missing one is kept as text with a warning). Media references are validated on save, and relabelling or removing a referenced item needs `--force`.
|
|
134
134
|
- **Repo values** (`init`, `sync`, `config repo set`) are checked for format and, with `gh`, for existence. Changing the repo identity offers to move existing reports.
|
|
135
135
|
- **Media sync** over `[sync] max_upload_mb` (default 25) is skipped with a warning.
|
|
136
136
|
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "bugcap"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.4.0"
|
|
8
8
|
description = "Local-first, agent-readable bug capture: annotated screenshots + notes, stored locally, synced to trackers on your own schedule."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.4.0"
|
|
@@ -120,7 +120,8 @@ def update_notes(store: Store, id: int, notes: str) -> dict:
|
|
|
120
120
|
except ServiceError as exc:
|
|
121
121
|
return {"error": exc.message, **exc.as_dict()}
|
|
122
122
|
view = service.get_report_view(store, id)
|
|
123
|
-
|
|
123
|
+
warnings = service.report_ref_warnings(store, report.notes)
|
|
124
|
+
return {"report_id": report.id, "notes": report.notes, "references": view.references, "warnings": warnings}
|
|
124
125
|
|
|
125
126
|
|
|
126
127
|
def _resolve_slug(explicit: Optional[str]) -> Optional[str]:
|
|
@@ -42,17 +42,26 @@ def print_ingest(added, rejected) -> None:
|
|
|
42
42
|
"""One line per input: `added #<idx> ...` on stdout, `skipped ...` on stderr."""
|
|
43
43
|
for media in added:
|
|
44
44
|
name = os.path.basename(media.path or "")
|
|
45
|
-
print(f"added
|
|
45
|
+
print(f"added {refs.media_token(media)} {name} ({media.kind}, {human_size(media.size_bytes)})")
|
|
46
46
|
for item in rejected:
|
|
47
47
|
print(f"skipped {item['source']}: {item['reason']}", file=sys.stderr)
|
|
48
48
|
|
|
49
49
|
|
|
50
50
|
# --- capture / list / show ----------------------------------------------------
|
|
51
51
|
|
|
52
|
+
def _warn_report_refs(store: Store, notes) -> None:
|
|
53
|
+
"""Soft warnings for `#N` references to reports that do not exist (never a failure)."""
|
|
54
|
+
for line in service.report_ref_warnings(store, notes or ""):
|
|
55
|
+
print(line, file=sys.stderr)
|
|
56
|
+
|
|
57
|
+
|
|
52
58
|
def cmd_capture(args: argparse.Namespace) -> int:
|
|
53
59
|
cfg = current_repo()
|
|
54
60
|
captured: Optional[str] = None
|
|
55
|
-
if
|
|
61
|
+
if args.no_image and (args.image or args.label):
|
|
62
|
+
print("error: --no-image cannot be combined with --image or --label", file=sys.stderr)
|
|
63
|
+
return 2
|
|
64
|
+
if not args.image and not args.no_image:
|
|
56
65
|
try:
|
|
57
66
|
captured = str(capture.capture_screenshot())
|
|
58
67
|
except CaptureError as exc:
|
|
@@ -89,6 +98,8 @@ def cmd_capture(args: argparse.Namespace) -> int:
|
|
|
89
98
|
except ServiceError as exc:
|
|
90
99
|
print_service_error(exc)
|
|
91
100
|
return 1
|
|
101
|
+
if report is not None:
|
|
102
|
+
_warn_report_refs(store, report.notes)
|
|
92
103
|
|
|
93
104
|
if report is None:
|
|
94
105
|
print_ingest([], result.rejected)
|
|
@@ -103,6 +114,27 @@ def cmd_capture(args: argparse.Namespace) -> int:
|
|
|
103
114
|
return 1 if result.rejected else 0
|
|
104
115
|
|
|
105
116
|
|
|
117
|
+
def cmd_delete(args: argparse.Namespace) -> int:
|
|
118
|
+
with Store() as store:
|
|
119
|
+
report = resolve_report(store, args.id)
|
|
120
|
+
if report is None:
|
|
121
|
+
return 1
|
|
122
|
+
if not args.yes:
|
|
123
|
+
if sys.stdin is None or not sys.stdin.isatty():
|
|
124
|
+
print("error: refusing to delete without confirmation; re-run with --yes", file=sys.stderr)
|
|
125
|
+
return 2
|
|
126
|
+
answer = input(
|
|
127
|
+
f"Delete report #{report.id} {report.title!r} and its {len(report.media)} image(s)? [y/N] "
|
|
128
|
+
).strip().lower()
|
|
129
|
+
if answer != "y":
|
|
130
|
+
print("Nothing deleted.")
|
|
131
|
+
return 1
|
|
132
|
+
service.delete_report(store, args.id)
|
|
133
|
+
|
|
134
|
+
print(f"Deleted report #{report.id}: {report.title}")
|
|
135
|
+
return 0
|
|
136
|
+
|
|
137
|
+
|
|
106
138
|
def cmd_list(args: argparse.Namespace) -> int:
|
|
107
139
|
cfg = current_repo()
|
|
108
140
|
scope = cfg.key if (cfg is not None and not args.all) else None
|
|
@@ -142,7 +174,7 @@ def cmd_show(args: argparse.Namespace) -> int:
|
|
|
142
174
|
for m in detailed:
|
|
143
175
|
where = m.path or f"({len(m.frames)} frames)"
|
|
144
176
|
origin = f" (source: {m.source})" if m.source else ""
|
|
145
|
-
print(f"
|
|
177
|
+
print(f" {refs.media_token(m)} {m.kind:8s} {m.label or '-':12s} {where} {human_size(m.size_bytes)}{origin}")
|
|
146
178
|
print(f" synced_refs:")
|
|
147
179
|
if report.synced_refs:
|
|
148
180
|
for tracker, ref in report.synced_refs.items():
|
|
@@ -440,6 +472,8 @@ def cmd_edit(args: argparse.Namespace) -> int:
|
|
|
440
472
|
print(f"error: {exc}", file=sys.stderr)
|
|
441
473
|
return 2
|
|
442
474
|
|
|
475
|
+
_warn_report_refs(store, report.notes)
|
|
476
|
+
|
|
443
477
|
changed = []
|
|
444
478
|
if args.title is not None:
|
|
445
479
|
changed.append("title")
|
|
@@ -529,6 +563,7 @@ def cmd_attach(args: argparse.Namespace) -> int:
|
|
|
529
563
|
print("warning: the image was attached but the note was not saved.", file=sys.stderr)
|
|
530
564
|
return 1
|
|
531
565
|
current = store.get(args.id)
|
|
566
|
+
_warn_report_refs(store, current.notes)
|
|
532
567
|
if args.image:
|
|
533
568
|
try:
|
|
534
569
|
refs.validate_references(current.notes, current.media)
|
|
@@ -552,7 +587,7 @@ def cmd_images(args: argparse.Namespace) -> int:
|
|
|
552
587
|
if args.action is None:
|
|
553
588
|
for m in report.media:
|
|
554
589
|
where = m.path or f"({len(m.frames)} frames)"
|
|
555
|
-
print(f"
|
|
590
|
+
print(f"{refs.media_token(m)} {m.label or '-':12s} {m.kind:8s} {human_size(m.size_bytes):>9s} {where}")
|
|
556
591
|
return 0
|
|
557
592
|
try:
|
|
558
593
|
if args.action == "relabel":
|
|
@@ -634,8 +669,9 @@ def cmd_record(args: argparse.Namespace) -> int:
|
|
|
634
669
|
recorder.discard(result)
|
|
635
670
|
print_service_error(exc)
|
|
636
671
|
return 1
|
|
672
|
+
_warn_report_refs(store, report.notes)
|
|
637
673
|
print(f"recorded {result.duration:.1f} s, {human_size(result.size_bytes)} ({result.kind}), stopped: {result.reason}")
|
|
638
|
-
print(f"added
|
|
674
|
+
print(f"added {refs.media_token(media)} to report #{report.id}")
|
|
639
675
|
return 0
|
|
640
676
|
|
|
641
677
|
|
|
@@ -828,8 +864,15 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
828
864
|
p.add_argument("--image", action="append", metavar="SRC",
|
|
829
865
|
help="Import an image (path, glob or http(s) URL) instead of launching a capture tool; repeatable.")
|
|
830
866
|
p.add_argument("--label", action="append", help="Label for the --image at the same position (repeatable).")
|
|
867
|
+
p.add_argument("--no-image", dest="no_image", action="store_true",
|
|
868
|
+
help="Save a text-only report: no capture tool and no image.")
|
|
831
869
|
p.set_defaults(func=cmd_capture)
|
|
832
870
|
|
|
871
|
+
p = sub.add_parser("delete", help="Permanently delete a report and its images (not synced GitHub issues).")
|
|
872
|
+
p.add_argument("id", type=int, help="Report id (see `bugcap list`).")
|
|
873
|
+
p.add_argument("--yes", action="store_true", help="Delete without the confirmation prompt.")
|
|
874
|
+
p.set_defaults(func=cmd_delete)
|
|
875
|
+
|
|
833
876
|
p = sub.add_parser("list", help="List reports (current repo only, unless --all).")
|
|
834
877
|
p.add_argument("--all", action="store_true", help="List reports from every repo.")
|
|
835
878
|
p.set_defaults(func=cmd_list)
|
|
@@ -101,5 +101,7 @@ def set_notes(store, report_id: int, body: dict) -> dict:
|
|
|
101
101
|
notes = body.get("notes")
|
|
102
102
|
if not isinstance(notes, str):
|
|
103
103
|
raise ServiceError("bad_query", "body must be {\"notes\": \"...\"}")
|
|
104
|
-
service.set_notes(store, report_id, notes)
|
|
105
|
-
|
|
104
|
+
report = service.set_notes(store, report_id, notes)
|
|
105
|
+
view = get_report(store, report_id)
|
|
106
|
+
view["warnings"] = service.report_ref_warnings(store, report.notes)
|
|
107
|
+
return view
|
|
@@ -94,7 +94,8 @@
|
|
|
94
94
|
var i = document.createElement("img");
|
|
95
95
|
i.src = m.url; i.alt = m.label || ("image " + m.index); fig.appendChild(i);
|
|
96
96
|
}
|
|
97
|
-
var
|
|
97
|
+
var letters = {image: "i", video: "v", animated: "g", frames: "f"};
|
|
98
|
+
var label = caption || ("@" + (letters[m.kind] || "i") + m.index);
|
|
98
99
|
if (m.label) label += " (" + m.label + ")";
|
|
99
100
|
fig.appendChild(el("figcaption", label + " · " + m.kind + " · " + Math.round(m.size_bytes / 1024) + " KB"));
|
|
100
101
|
return fig;
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""Live-mode drafts: unsaved staged media (any number of images / recordings, or none) plus
|
|
2
|
+
whatever was typed, kept on disk until it is saved as a report or discarded. One
|
|
3
|
+
`<uuid>.json` sidecar per draft; its media files sit beside it as `<uuid>-*`. A draft made by
|
|
4
|
+
an older version is just `<uuid>.png` + `<uuid>.json` and still loads."""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import shutil
|
|
9
|
+
import sys
|
|
10
|
+
import uuid
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from datetime import datetime, timezone
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Optional
|
|
15
|
+
|
|
16
|
+
from .paths import drafts_dir
|
|
17
|
+
|
|
18
|
+
FIELDS = ("title", "notes", "tags", "status")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class Draft:
|
|
23
|
+
id: str
|
|
24
|
+
created_at: str
|
|
25
|
+
repo: Optional[str]
|
|
26
|
+
title: str = ""
|
|
27
|
+
notes: str = ""
|
|
28
|
+
tags: list = None # type: ignore[assignment]
|
|
29
|
+
status: str = "open"
|
|
30
|
+
# [{"kind", "path" (absolute or None), "mime", "size_bytes", "frames": [(path, size)]}]
|
|
31
|
+
items: list = field(default_factory=list)
|
|
32
|
+
|
|
33
|
+
def fields(self) -> dict:
|
|
34
|
+
return {"title": self.title, "notes": self.notes, "tags": list(self.tags or []), "status": self.status}
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def png_path(self) -> Optional[Path]:
|
|
38
|
+
"""The first stored file (the screenshot of a single-image draft), if any."""
|
|
39
|
+
for item in self.items:
|
|
40
|
+
if item["path"]:
|
|
41
|
+
return Path(item["path"])
|
|
42
|
+
return None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _move(source, target: Path) -> Path:
|
|
46
|
+
shutil.move(str(source), target)
|
|
47
|
+
return target
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def save_draft(repo: Optional[str], png_path, fields: dict, items: Optional[list] = None) -> Draft:
|
|
51
|
+
"""Move the media into drafts/ and write the sidecar. `items` are dicts shaped like
|
|
52
|
+
`Draft.items`; for a plain screenshot pass `png_path` instead."""
|
|
53
|
+
if items is None:
|
|
54
|
+
items = [{"kind": "image", "path": str(png_path), "mime": "image/png", "size_bytes": 0, "frames": []}] if png_path else []
|
|
55
|
+
draft_id = str(uuid.uuid4())
|
|
56
|
+
directory = drafts_dir()
|
|
57
|
+
listed = []
|
|
58
|
+
for n, item in enumerate(items, start=1):
|
|
59
|
+
path = item.get("path")
|
|
60
|
+
new_path = _move(path, directory / f"{draft_id}-{n}{Path(path).suffix}") if path else None
|
|
61
|
+
frames = []
|
|
62
|
+
for m, (frame_path, size) in enumerate(item.get("frames") or [], start=1):
|
|
63
|
+
frames.append([_move(frame_path, directory / f"{draft_id}-{n}-f{m:03d}.png").name, size])
|
|
64
|
+
listed.append({
|
|
65
|
+
"kind": item["kind"], "file": new_path.name if new_path else None,
|
|
66
|
+
"mime": item.get("mime", "image/png"), "size_bytes": item.get("size_bytes", 0), "frames": frames,
|
|
67
|
+
})
|
|
68
|
+
meta = {
|
|
69
|
+
"created_at": datetime.now(timezone.utc).isoformat(),
|
|
70
|
+
"repo": repo,
|
|
71
|
+
"title": fields.get("title", ""),
|
|
72
|
+
"notes": fields.get("notes", ""),
|
|
73
|
+
"tags": list(fields.get("tags") or []),
|
|
74
|
+
"status": fields.get("status", "open"),
|
|
75
|
+
"media": listed,
|
|
76
|
+
}
|
|
77
|
+
(directory / f"{draft_id}.json").write_text(json.dumps(meta, indent=2))
|
|
78
|
+
return load_draft(draft_id) # type: ignore[return-value]
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _items_from_meta(directory: Path, draft_id: str, meta: dict) -> list:
|
|
82
|
+
if "media" not in meta: # legacy: one screenshot
|
|
83
|
+
png = directory / f"{draft_id}.png"
|
|
84
|
+
if not png.is_file():
|
|
85
|
+
raise ValueError("incomplete draft")
|
|
86
|
+
return [{"kind": "image", "path": str(png), "mime": "image/png", "size_bytes": png.stat().st_size, "frames": []}]
|
|
87
|
+
items = []
|
|
88
|
+
for entry in meta["media"]:
|
|
89
|
+
path = directory / entry["file"] if entry.get("file") else None
|
|
90
|
+
frames = [(str(directory / name), int(size)) for name, size in entry.get("frames", [])]
|
|
91
|
+
files = ([path] if path else []) + [Path(p) for p, _ in frames]
|
|
92
|
+
if not files or not all(f.is_file() for f in files):
|
|
93
|
+
raise ValueError("incomplete draft")
|
|
94
|
+
items.append({
|
|
95
|
+
"kind": str(entry["kind"]), "path": str(path) if path else None, "mime": str(entry.get("mime", "")),
|
|
96
|
+
"size_bytes": int(entry.get("size_bytes", 0)), "frames": frames,
|
|
97
|
+
})
|
|
98
|
+
return items
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def load_draft(draft_id: str) -> Optional[Draft]:
|
|
102
|
+
directory = drafts_dir()
|
|
103
|
+
sidecar = directory / f"{draft_id}.json"
|
|
104
|
+
try:
|
|
105
|
+
meta = json.loads(sidecar.read_text())
|
|
106
|
+
if not isinstance(meta, dict):
|
|
107
|
+
raise ValueError("incomplete draft")
|
|
108
|
+
return Draft(
|
|
109
|
+
draft_id, meta.get("created_at", ""), meta.get("repo"),
|
|
110
|
+
str(meta.get("title", "")), str(meta.get("notes", "")),
|
|
111
|
+
[str(t) for t in meta.get("tags", [])], str(meta.get("status", "open")),
|
|
112
|
+
_items_from_meta(directory, draft_id, meta),
|
|
113
|
+
)
|
|
114
|
+
except (OSError, ValueError, TypeError, KeyError) as exc:
|
|
115
|
+
print(f"warning: skipping unreadable draft {draft_id}: {exc}", file=sys.stderr)
|
|
116
|
+
return None
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def list_drafts(repo: Optional[str]) -> list[Draft]:
|
|
120
|
+
"""Drafts saved for this repo (or without a repo), oldest first."""
|
|
121
|
+
found = []
|
|
122
|
+
for sidecar in sorted(drafts_dir().glob("*.json")):
|
|
123
|
+
draft = load_draft(sidecar.stem)
|
|
124
|
+
if draft is not None and draft.repo == repo:
|
|
125
|
+
found.append(draft)
|
|
126
|
+
return sorted(found, key=lambda d: d.created_at)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def delete_draft(draft_id: str) -> None:
|
|
130
|
+
directory = drafts_dir()
|
|
131
|
+
(directory / f"{draft_id}.json").unlink(missing_ok=True)
|
|
132
|
+
(directory / f"{draft_id}.png").unlink(missing_ok=True)
|
|
133
|
+
for leftover in directory.glob(f"{draft_id}-*"):
|
|
134
|
+
leftover.unlink(missing_ok=True)
|