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.
Files changed (89) hide show
  1. {bugcap-0.2.2/src/bugcap.egg-info → bugcap-0.4.0}/PKG-INFO +7 -7
  2. {bugcap-0.2.2 → bugcap-0.4.0}/README.md +6 -6
  3. {bugcap-0.2.2 → bugcap-0.4.0}/pyproject.toml +1 -1
  4. bugcap-0.4.0/src/bugcap/__init__.py +1 -0
  5. {bugcap-0.2.2 → bugcap-0.4.0}/src/bugcap/agent_api.py +2 -1
  6. {bugcap-0.2.2 → bugcap-0.4.0}/src/bugcap/cli.py +48 -5
  7. {bugcap-0.2.2 → bugcap-0.4.0}/src/bugcap/dashboard/api.py +4 -2
  8. {bugcap-0.2.2 → bugcap-0.4.0}/src/bugcap/dashboard/static/app.js +2 -1
  9. bugcap-0.4.0/src/bugcap/drafts.py +134 -0
  10. bugcap-0.4.0/src/bugcap/live.py +564 -0
  11. bugcap-0.4.0/src/bugcap/live_session.py +300 -0
  12. {bugcap-0.2.2 → bugcap-0.4.0}/src/bugcap/mcp_server.py +2 -2
  13. {bugcap-0.2.2 → bugcap-0.4.0}/src/bugcap/refs.py +67 -10
  14. {bugcap-0.2.2 → bugcap-0.4.0}/src/bugcap/service.py +36 -10
  15. {bugcap-0.2.2 → bugcap-0.4.0}/src/bugcap/store.py +10 -0
  16. {bugcap-0.2.2 → bugcap-0.4.0}/src/bugcap/sync.py +26 -6
  17. {bugcap-0.2.2 → bugcap-0.4.0/src/bugcap.egg-info}/PKG-INFO +7 -7
  18. {bugcap-0.2.2 → bugcap-0.4.0}/src/bugcap.egg-info/SOURCES.txt +2 -0
  19. {bugcap-0.2.2 → bugcap-0.4.0}/tests/test_agent_api.py +1 -1
  20. {bugcap-0.2.2 → bugcap-0.4.0}/tests/test_cli_attach.py +8 -8
  21. {bugcap-0.2.2 → bugcap-0.4.0}/tests/test_cli_media.py +7 -7
  22. bugcap-0.4.0/tests/test_cli_no_image_delete.py +95 -0
  23. {bugcap-0.2.2 → bugcap-0.4.0}/tests/test_dashboard_mutations.py +1 -1
  24. bugcap-0.4.0/tests/test_drafts.py +77 -0
  25. {bugcap-0.2.2 → bugcap-0.4.0}/tests/test_github_media_sync.py +18 -2
  26. bugcap-0.4.0/tests/test_image_refs.py +82 -0
  27. bugcap-0.4.0/tests/test_live_session.py +289 -0
  28. {bugcap-0.2.2 → bugcap-0.4.0}/tests/test_refs_validate_rewrite.py +1 -1
  29. {bugcap-0.2.2 → bugcap-0.4.0}/tests/test_service_media.py +1 -1
  30. bugcap-0.2.2/src/bugcap/__init__.py +0 -1
  31. bugcap-0.2.2/src/bugcap/drafts.py +0 -83
  32. bugcap-0.2.2/src/bugcap/live.py +0 -292
  33. bugcap-0.2.2/src/bugcap/live_session.py +0 -125
  34. bugcap-0.2.2/tests/test_drafts.py +0 -43
  35. bugcap-0.2.2/tests/test_live_session.py +0 -125
  36. {bugcap-0.2.2 → bugcap-0.4.0}/LICENSE +0 -0
  37. {bugcap-0.2.2 → bugcap-0.4.0}/setup.cfg +0 -0
  38. {bugcap-0.2.2 → bugcap-0.4.0}/src/bugcap/backends.py +0 -0
  39. {bugcap-0.2.2 → bugcap-0.4.0}/src/bugcap/capture.py +0 -0
  40. {bugcap-0.2.2 → bugcap-0.4.0}/src/bugcap/config.py +0 -0
  41. {bugcap-0.2.2 → bugcap-0.4.0}/src/bugcap/dashboard/__init__.py +0 -0
  42. {bugcap-0.2.2 → bugcap-0.4.0}/src/bugcap/dashboard/media.py +0 -0
  43. {bugcap-0.2.2 → bugcap-0.4.0}/src/bugcap/dashboard/server.py +0 -0
  44. {bugcap-0.2.2 → bugcap-0.4.0}/src/bugcap/dashboard/static/app.css +0 -0
  45. {bugcap-0.2.2 → bugcap-0.4.0}/src/bugcap/dashboard/static/index.html +0 -0
  46. {bugcap-0.2.2 → bugcap-0.4.0}/src/bugcap/errors.py +0 -0
  47. {bugcap-0.2.2 → bugcap-0.4.0}/src/bugcap/ghcli.py +0 -0
  48. {bugcap-0.2.2 → bugcap-0.4.0}/src/bugcap/ingest.py +0 -0
  49. {bugcap-0.2.2 → bugcap-0.4.0}/src/bugcap/logs.py +0 -0
  50. {bugcap-0.2.2 → bugcap-0.4.0}/src/bugcap/paths.py +0 -0
  51. {bugcap-0.2.2 → bugcap-0.4.0}/src/bugcap/recorder.py +0 -0
  52. {bugcap-0.2.2 → bugcap-0.4.0}/src/bugcap/repo.py +0 -0
  53. {bugcap-0.2.2 → bugcap-0.4.0}/src/bugcap/tomlio.py +0 -0
  54. {bugcap-0.2.2 → bugcap-0.4.0}/src/bugcap/validation.py +0 -0
  55. {bugcap-0.2.2 → bugcap-0.4.0}/src/bugcap.egg-info/dependency_links.txt +0 -0
  56. {bugcap-0.2.2 → bugcap-0.4.0}/src/bugcap.egg-info/entry_points.txt +0 -0
  57. {bugcap-0.2.2 → bugcap-0.4.0}/src/bugcap.egg-info/requires.txt +0 -0
  58. {bugcap-0.2.2 → bugcap-0.4.0}/src/bugcap.egg-info/top_level.txt +0 -0
  59. {bugcap-0.2.2 → bugcap-0.4.0}/tests/test_backends.py +0 -0
  60. {bugcap-0.2.2 → bugcap-0.4.0}/tests/test_capture.py +0 -0
  61. {bugcap-0.2.2 → bugcap-0.4.0}/tests/test_cli_dashboard.py +0 -0
  62. {bugcap-0.2.2 → bugcap-0.4.0}/tests/test_cli_init_capture_list.py +0 -0
  63. {bugcap-0.2.2 → bugcap-0.4.0}/tests/test_cli_live_headless.py +0 -0
  64. {bugcap-0.2.2 → bugcap-0.4.0}/tests/test_cli_record.py +0 -0
  65. {bugcap-0.2.2 → bugcap-0.4.0}/tests/test_cli_setup.py +0 -0
  66. {bugcap-0.2.2 → bugcap-0.4.0}/tests/test_cli_triage.py +0 -0
  67. {bugcap-0.2.2 → bugcap-0.4.0}/tests/test_dashboard_api.py +0 -0
  68. {bugcap-0.2.2 → bugcap-0.4.0}/tests/test_dashboard_media.py +0 -0
  69. {bugcap-0.2.2 → bugcap-0.4.0}/tests/test_dashboard_security.py +0 -0
  70. {bugcap-0.2.2 → bugcap-0.4.0}/tests/test_dashboard_ui_static.py +0 -0
  71. {bugcap-0.2.2 → bugcap-0.4.0}/tests/test_ghcli.py +0 -0
  72. {bugcap-0.2.2 → bugcap-0.4.0}/tests/test_github_pull.py +0 -0
  73. {bugcap-0.2.2 → bugcap-0.4.0}/tests/test_github_sync.py +0 -0
  74. {bugcap-0.2.2 → bugcap-0.4.0}/tests/test_ingest.py +0 -0
  75. {bugcap-0.2.2 → bugcap-0.4.0}/tests/test_ingest_url.py +0 -0
  76. {bugcap-0.2.2 → bugcap-0.4.0}/tests/test_mcp.py +0 -0
  77. {bugcap-0.2.2 → bugcap-0.4.0}/tests/test_mcp_logging.py +0 -0
  78. {bugcap-0.2.2 → bugcap-0.4.0}/tests/test_paths.py +0 -0
  79. {bugcap-0.2.2 → bugcap-0.4.0}/tests/test_recorder_argv.py +0 -0
  80. {bugcap-0.2.2 → bugcap-0.4.0}/tests/test_recorder_devices.py +0 -0
  81. {bugcap-0.2.2 → bugcap-0.4.0}/tests/test_recorder_media_real.py +0 -0
  82. {bugcap-0.2.2 → bugcap-0.4.0}/tests/test_recorder_run.py +0 -0
  83. {bugcap-0.2.2 → bugcap-0.4.0}/tests/test_refs.py +0 -0
  84. {bugcap-0.2.2 → bugcap-0.4.0}/tests/test_regression_output.py +0 -0
  85. {bugcap-0.2.2 → bugcap-0.4.0}/tests/test_repo_config.py +0 -0
  86. {bugcap-0.2.2 → bugcap-0.4.0}/tests/test_repo_values.py +0 -0
  87. {bugcap-0.2.2 → bugcap-0.4.0}/tests/test_service_refs.py +0 -0
  88. {bugcap-0.2.2 → bugcap-0.4.0}/tests/test_store_migration.py +0 -0
  89. {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.2.2
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 for reporting many bugs in a row. |
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 (from GitHub until the PyPI release is live)
72
- pipx install 'bugcap[mcp] @ git+https://github.com/amenalahassa/bugcap'
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] @ git+https://github.com/amenalahassa/bugcap' # recommended
132
- uv tool install 'bugcap[mcp] @ git+https://github.com/amenalahassa/bugcap'
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
- - **`@` references.** `@1` or `@login-error` in a note points at an image. They are validated on save, and relabelling or removing a referenced image needs `--force`.
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 for reporting many bugs in a row. |
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 (from GitHub until the PyPI release is live)
56
- pipx install 'bugcap[mcp] @ git+https://github.com/amenalahassa/bugcap'
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] @ git+https://github.com/amenalahassa/bugcap' # recommended
116
- uv tool install 'bugcap[mcp] @ git+https://github.com/amenalahassa/bugcap'
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
- - **`@` references.** `@1` or `@login-error` in a note points at an image. They are validated on save, and relabelling or removing a referenced image needs `--force`.
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.2.2"
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
- return {"report_id": report.id, "notes": report.notes, "references": view.references}
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 #{media.idx} {name} ({media.kind}, {human_size(media.size_bytes)})")
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 not args.image:
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" #{m.idx} {m.kind:8s} {m.label or '-':12s} {where} {human_size(m.size_bytes)}{origin}")
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"#{m.idx} {m.label or '-':12s} {m.kind:8s} {human_size(m.size_bytes):>9s} {where}")
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 #{media.idx} to report #{report.id}")
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
- return get_report(store, report_id)
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 label = caption || ("#" + m.index);
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)