bugcap 0.5.1__tar.gz → 0.6.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 (85) hide show
  1. {bugcap-0.5.1/src/bugcap.egg-info → bugcap-0.6.0}/PKG-INFO +5 -3
  2. {bugcap-0.5.1 → bugcap-0.6.0}/README.md +4 -2
  3. {bugcap-0.5.1 → bugcap-0.6.0}/pyproject.toml +1 -1
  4. bugcap-0.6.0/src/bugcap/__init__.py +1 -0
  5. {bugcap-0.5.1 → bugcap-0.6.0}/src/bugcap/agent_api.py +10 -1
  6. {bugcap-0.5.1 → bugcap-0.6.0}/src/bugcap/backends.py +43 -0
  7. {bugcap-0.5.1 → bugcap-0.6.0}/src/bugcap/cli.py +55 -14
  8. {bugcap-0.5.1 → bugcap-0.6.0}/src/bugcap/dashboard/media.py +10 -3
  9. {bugcap-0.5.1 → bugcap-0.6.0}/src/bugcap/dashboard/server.py +5 -1
  10. {bugcap-0.5.1 → bugcap-0.6.0}/src/bugcap/dashboard/static/app.js +5 -1
  11. {bugcap-0.5.1 → bugcap-0.6.0}/src/bugcap/ingest.py +93 -10
  12. {bugcap-0.5.1 → bugcap-0.6.0}/src/bugcap/live.py +68 -8
  13. {bugcap-0.5.1 → bugcap-0.6.0}/src/bugcap/live_session.py +41 -8
  14. {bugcap-0.5.1 → bugcap-0.6.0}/src/bugcap/mcp_server.py +28 -5
  15. {bugcap-0.5.1 → bugcap-0.6.0}/src/bugcap/paths.py +7 -1
  16. {bugcap-0.5.1 → bugcap-0.6.0}/src/bugcap/refs.py +5 -4
  17. {bugcap-0.5.1 → bugcap-0.6.0}/src/bugcap/repo.py +65 -2
  18. {bugcap-0.5.1 → bugcap-0.6.0}/src/bugcap/service.py +27 -6
  19. {bugcap-0.5.1 → bugcap-0.6.0}/src/bugcap/store.py +38 -4
  20. {bugcap-0.5.1 → bugcap-0.6.0}/src/bugcap/sync.py +3 -0
  21. {bugcap-0.5.1 → bugcap-0.6.0/src/bugcap.egg-info}/PKG-INFO +5 -3
  22. {bugcap-0.5.1 → bugcap-0.6.0}/src/bugcap.egg-info/SOURCES.txt +1 -0
  23. {bugcap-0.5.1 → bugcap-0.6.0}/tests/test_backends.py +57 -0
  24. {bugcap-0.5.1 → bugcap-0.6.0}/tests/test_cli_setup.py +29 -0
  25. {bugcap-0.5.1 → bugcap-0.6.0}/tests/test_cli_triage.py +4 -1
  26. bugcap-0.6.0/tests/test_file_attachments.py +217 -0
  27. {bugcap-0.5.1 → bugcap-0.6.0}/tests/test_mcp.py +1 -1
  28. {bugcap-0.5.1 → bugcap-0.6.0}/tests/test_mcp_logging.py +13 -0
  29. {bugcap-0.5.1 → bugcap-0.6.0}/tests/test_regression_output.py +16 -0
  30. {bugcap-0.5.1 → bugcap-0.6.0}/tests/test_repo_config.py +46 -0
  31. {bugcap-0.5.1 → bugcap-0.6.0}/tests/test_store_migration.py +62 -7
  32. bugcap-0.5.1/src/bugcap/__init__.py +0 -1
  33. {bugcap-0.5.1 → bugcap-0.6.0}/LICENSE +0 -0
  34. {bugcap-0.5.1 → bugcap-0.6.0}/setup.cfg +0 -0
  35. {bugcap-0.5.1 → bugcap-0.6.0}/src/bugcap/capture.py +0 -0
  36. {bugcap-0.5.1 → bugcap-0.6.0}/src/bugcap/config.py +0 -0
  37. {bugcap-0.5.1 → bugcap-0.6.0}/src/bugcap/dashboard/__init__.py +0 -0
  38. {bugcap-0.5.1 → bugcap-0.6.0}/src/bugcap/dashboard/api.py +0 -0
  39. {bugcap-0.5.1 → bugcap-0.6.0}/src/bugcap/dashboard/static/app.css +0 -0
  40. {bugcap-0.5.1 → bugcap-0.6.0}/src/bugcap/dashboard/static/index.html +0 -0
  41. {bugcap-0.5.1 → bugcap-0.6.0}/src/bugcap/drafts.py +0 -0
  42. {bugcap-0.5.1 → bugcap-0.6.0}/src/bugcap/errors.py +0 -0
  43. {bugcap-0.5.1 → bugcap-0.6.0}/src/bugcap/ghcli.py +0 -0
  44. {bugcap-0.5.1 → bugcap-0.6.0}/src/bugcap/logs.py +0 -0
  45. {bugcap-0.5.1 → bugcap-0.6.0}/src/bugcap/recorder.py +0 -0
  46. {bugcap-0.5.1 → bugcap-0.6.0}/src/bugcap/tomlio.py +0 -0
  47. {bugcap-0.5.1 → bugcap-0.6.0}/src/bugcap/validation.py +0 -0
  48. {bugcap-0.5.1 → bugcap-0.6.0}/src/bugcap.egg-info/dependency_links.txt +0 -0
  49. {bugcap-0.5.1 → bugcap-0.6.0}/src/bugcap.egg-info/entry_points.txt +0 -0
  50. {bugcap-0.5.1 → bugcap-0.6.0}/src/bugcap.egg-info/requires.txt +0 -0
  51. {bugcap-0.5.1 → bugcap-0.6.0}/src/bugcap.egg-info/top_level.txt +0 -0
  52. {bugcap-0.5.1 → bugcap-0.6.0}/tests/test_agent_api.py +0 -0
  53. {bugcap-0.5.1 → bugcap-0.6.0}/tests/test_capture.py +0 -0
  54. {bugcap-0.5.1 → bugcap-0.6.0}/tests/test_cli_attach.py +0 -0
  55. {bugcap-0.5.1 → bugcap-0.6.0}/tests/test_cli_dashboard.py +0 -0
  56. {bugcap-0.5.1 → bugcap-0.6.0}/tests/test_cli_init_capture_list.py +0 -0
  57. {bugcap-0.5.1 → bugcap-0.6.0}/tests/test_cli_live_headless.py +0 -0
  58. {bugcap-0.5.1 → bugcap-0.6.0}/tests/test_cli_media.py +0 -0
  59. {bugcap-0.5.1 → bugcap-0.6.0}/tests/test_cli_no_image_delete.py +0 -0
  60. {bugcap-0.5.1 → bugcap-0.6.0}/tests/test_cli_record.py +0 -0
  61. {bugcap-0.5.1 → bugcap-0.6.0}/tests/test_dashboard_api.py +0 -0
  62. {bugcap-0.5.1 → bugcap-0.6.0}/tests/test_dashboard_media.py +0 -0
  63. {bugcap-0.5.1 → bugcap-0.6.0}/tests/test_dashboard_mutations.py +0 -0
  64. {bugcap-0.5.1 → bugcap-0.6.0}/tests/test_dashboard_security.py +0 -0
  65. {bugcap-0.5.1 → bugcap-0.6.0}/tests/test_dashboard_ui_static.py +0 -0
  66. {bugcap-0.5.1 → bugcap-0.6.0}/tests/test_drafts.py +0 -0
  67. {bugcap-0.5.1 → bugcap-0.6.0}/tests/test_ghcli.py +0 -0
  68. {bugcap-0.5.1 → bugcap-0.6.0}/tests/test_github_media_sync.py +0 -0
  69. {bugcap-0.5.1 → bugcap-0.6.0}/tests/test_github_pull.py +0 -0
  70. {bugcap-0.5.1 → bugcap-0.6.0}/tests/test_github_sync.py +0 -0
  71. {bugcap-0.5.1 → bugcap-0.6.0}/tests/test_image_refs.py +0 -0
  72. {bugcap-0.5.1 → bugcap-0.6.0}/tests/test_ingest.py +0 -0
  73. {bugcap-0.5.1 → bugcap-0.6.0}/tests/test_ingest_url.py +0 -0
  74. {bugcap-0.5.1 → bugcap-0.6.0}/tests/test_live_session.py +0 -0
  75. {bugcap-0.5.1 → bugcap-0.6.0}/tests/test_paths.py +0 -0
  76. {bugcap-0.5.1 → bugcap-0.6.0}/tests/test_recorder_argv.py +0 -0
  77. {bugcap-0.5.1 → bugcap-0.6.0}/tests/test_recorder_devices.py +0 -0
  78. {bugcap-0.5.1 → bugcap-0.6.0}/tests/test_recorder_media_real.py +0 -0
  79. {bugcap-0.5.1 → bugcap-0.6.0}/tests/test_recorder_run.py +0 -0
  80. {bugcap-0.5.1 → bugcap-0.6.0}/tests/test_refs.py +0 -0
  81. {bugcap-0.5.1 → bugcap-0.6.0}/tests/test_refs_validate_rewrite.py +0 -0
  82. {bugcap-0.5.1 → bugcap-0.6.0}/tests/test_repo_values.py +0 -0
  83. {bugcap-0.5.1 → bugcap-0.6.0}/tests/test_service_media.py +0 -0
  84. {bugcap-0.5.1 → bugcap-0.6.0}/tests/test_service_refs.py +0 -0
  85. {bugcap-0.5.1 → bugcap-0.6.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.5.1
3
+ Version: 0.6.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
@@ -83,6 +83,8 @@ bugcap dashboard --open # browse and triage in the browser
83
83
  The [usage guide](https://amenalahassa.github.io/bugcap/usage.html) covers every command: images
84
84
  and labels, recording, live mode, `@` references, GitHub pull and sync, and the MCP tools.
85
85
 
86
+ If a report ID is not found, the error suggests `bugcap list --all` to see report IDs across all repos.
87
+
86
88
  ## Connect your agent (MCP)
87
89
 
88
90
  Requires the `mcp` extra. Then give your agent access to the reports:
@@ -98,7 +100,7 @@ Or in a project's `.mcp.json`:
98
100
  ```
99
101
 
100
102
  The server exposes six tools: `list_reports`, `get_report` (returns the screenshot), `request_screenshot`,
101
- `pull_issues`, `attach_image` and `update_notes`. Logs go to `logs/mcp-server.log` in the data
103
+ `pull_issues`, `attach_image`, `attach_file` and `update_notes`. Logs go to `logs/mcp-server.log` in the data
102
104
  directory, never to stdout.
103
105
 
104
106
  Ask your agent something like *"list the open bugcap reports for this repo and fix the one with
@@ -147,7 +149,7 @@ pull and sync, MCP, and where data lives per OS.
147
149
 
148
150
  Notes on the more involved behaviour:
149
151
 
150
- - **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`.
152
+ - **References.** In a note, `@i1` is image 1, `@v2` a video, `@g3` an animated GIF, `@f4` a frame set, `@d5` an attached file (`attach --file`, any type), 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`.
151
153
  - **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.
152
154
  - **Media sync** over `[sync] max_upload_mb` (default 25) is skipped with a warning.
153
155
 
@@ -66,6 +66,8 @@ bugcap dashboard --open # browse and triage in the browser
66
66
  The [usage guide](https://amenalahassa.github.io/bugcap/usage.html) covers every command: images
67
67
  and labels, recording, live mode, `@` references, GitHub pull and sync, and the MCP tools.
68
68
 
69
+ If a report ID is not found, the error suggests `bugcap list --all` to see report IDs across all repos.
70
+
69
71
  ## Connect your agent (MCP)
70
72
 
71
73
  Requires the `mcp` extra. Then give your agent access to the reports:
@@ -81,7 +83,7 @@ Or in a project's `.mcp.json`:
81
83
  ```
82
84
 
83
85
  The server exposes six tools: `list_reports`, `get_report` (returns the screenshot), `request_screenshot`,
84
- `pull_issues`, `attach_image` and `update_notes`. Logs go to `logs/mcp-server.log` in the data
86
+ `pull_issues`, `attach_image`, `attach_file` and `update_notes`. Logs go to `logs/mcp-server.log` in the data
85
87
  directory, never to stdout.
86
88
 
87
89
  Ask your agent something like *"list the open bugcap reports for this repo and fix the one with
@@ -130,7 +132,7 @@ pull and sync, MCP, and where data lives per OS.
130
132
 
131
133
  Notes on the more involved behaviour:
132
134
 
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`.
135
+ - **References.** In a note, `@i1` is image 1, `@v2` a video, `@g3` an animated GIF, `@f4` a frame set, `@d5` an attached file (`attach --file`, any type), 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
136
  - **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
137
  - **Media sync** over `[sync] max_upload_mb` (default 25) is skipped with a warning.
136
138
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "bugcap"
7
- version = "0.5.1"
7
+ version = "0.6.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.6.0"
@@ -97,10 +97,19 @@ def get_report(store: Store, id: int) -> dict:
97
97
 
98
98
  def attach_image(store: Store, id: int, sources: list[str], labels: Optional[list] = None) -> dict:
99
99
  """Attach images (paths, globs, http(s) URLs) to a report; partial batches are reported."""
100
+ return _attach(store, id, sources, labels, service.add_media)
101
+
102
+
103
+ def attach_file(store: Store, id: int, sources: list[str], labels: Optional[list] = None) -> dict:
104
+ """Attach files of any type (paths, globs, http(s) URLs) to a report."""
105
+ return _attach(store, id, sources, labels, service.add_files)
106
+
107
+
108
+ def _attach(store: Store, id: int, sources: list[str], labels, add) -> dict:
100
109
  if not sources or len(sources) > 20:
101
110
  return {"code": "bad_query", "error": "sources must contain 1 to 20 entries"}
102
111
  try:
103
- result = service.add_media(store, id, list(sources), labels)
112
+ result = add(store, id, list(sources), labels)
104
113
  except ServiceError as exc:
105
114
  return {"error": exc.message, **exc.as_dict()}
106
115
  return {
@@ -95,8 +95,51 @@ def by_name(name: str) -> Backend:
95
95
  raise KeyError(name)
96
96
 
97
97
 
98
+ def _sync_path_from_registry() -> None:
99
+ """Pick up a freshly-installed Windows tool without a new terminal.
100
+
101
+ winget/scoop/choco update the user/machine PATH in the registry, but an
102
+ already-running process (and any shell that was already open when the
103
+ install happened) keeps its original PATH until it restarts. That made
104
+ `bugcap live` report "No capture tool found" right after `bugcap setup`
105
+ had just installed flameshot in the same session. Re-reading PATH from
106
+ the registry before every detection closes that gap. Best-effort: any
107
+ failure here just leaves PATH as-is.
108
+ """
109
+ if sys.platform != "win32":
110
+ return
111
+ try:
112
+ import winreg
113
+ except ImportError:
114
+ return
115
+ try:
116
+ chunks = [os.environ.get("PATH", "")]
117
+ for hive, subkey in (
118
+ (winreg.HKEY_CURRENT_USER, r"Environment"),
119
+ (winreg.HKEY_LOCAL_MACHINE, r"SYSTEM\CurrentControlSet\Control\Session Manager\Environment"),
120
+ ):
121
+ try:
122
+ with winreg.OpenKey(hive, subkey) as key:
123
+ value, _ = winreg.QueryValueEx(key, "Path")
124
+ chunks.append(value)
125
+ except OSError:
126
+ continue
127
+ seen = set()
128
+ merged = []
129
+ for chunk in chunks:
130
+ for part in chunk.split(os.pathsep):
131
+ key = part.lower()
132
+ if part and key not in seen:
133
+ seen.add(key)
134
+ merged.append(part)
135
+ os.environ["PATH"] = os.pathsep.join(merged)
136
+ except OSError:
137
+ pass
138
+
139
+
98
140
  def detect() -> Optional[Backend]:
99
141
  """First available backend, in preference order."""
142
+ _sync_path_from_registry()
100
143
  return next((b for b in BACKENDS if b.available()), None)
101
144
 
102
145
 
@@ -24,7 +24,10 @@ def resolve_report(store: Store, report_id: int):
24
24
  """Return the report, or print the standard not-found error and return None."""
25
25
  report = store.get(report_id)
26
26
  if report is None:
27
- print(f"error: no report with id {report_id}", file=sys.stderr)
27
+ print(
28
+ f"error: no report with id {report_id}; run `bugcap list --all` to see report ids",
29
+ file=sys.stderr,
30
+ )
28
31
  return report
29
32
 
30
33
 
@@ -40,7 +43,7 @@ def print_service_error(exc: ServiceError) -> None:
40
43
  def print_ingest(added, rejected) -> None:
41
44
  """One line per input: `added #<idx> ...` on stdout, `skipped ...` on stderr."""
42
45
  for media in added:
43
- name = os.path.basename(media.path or "")
46
+ name = os.path.basename((media.source if media.kind == "file" and media.source else media.path) or "")
44
47
  print(f"added {refs.media_token(media)} {name} ({media.kind}, {human_size(media.size_bytes)})")
45
48
  for item in rejected:
46
49
  print(f"skipped {item['source']}: {item['reason']}", file=sys.stderr)
@@ -225,7 +228,13 @@ def _setup_capture(args: argparse.Namespace) -> int:
225
228
  if rc != 0:
226
229
  print(f"error: install command failed (exit {rc}).", file=sys.stderr)
227
230
  return 1
228
- print(f"{rec.name} installed.")
231
+ if backends.detect() is not None:
232
+ print(f"{rec.name} installed.")
233
+ return 0
234
+ print(
235
+ f"{rec.name} installed, but this terminal can't see it on PATH yet.\n"
236
+ f"Close and reopen your terminal (or start a new one), then run `bugcap setup` again."
237
+ )
229
238
  return 0
230
239
 
231
240
 
@@ -536,14 +545,22 @@ def cmd_attach(args: argparse.Namespace) -> int:
536
545
  added: list = []
537
546
  rejected: list = []
538
547
  shot_path = None
539
- if args.image:
540
- try:
541
- result = service.add_media(store, args.id, list(args.image), labels)
542
- except ServiceError as exc:
543
- print_service_error(exc)
544
- return 1
545
- added, rejected = result.added, result.rejected
546
- print_ingest(added, rejected)
548
+ if args.image or args.file:
549
+ batches = (
550
+ (args.image, service.add_media, labels),
551
+ (args.file, service.add_files, list(args.file_label or [])),
552
+ )
553
+ for sources, add, batch_labels in batches:
554
+ if not sources:
555
+ continue
556
+ try:
557
+ result = add(store, args.id, list(sources), batch_labels)
558
+ except ServiceError as exc:
559
+ print_service_error(exc)
560
+ return 1
561
+ added += result.added
562
+ rejected += result.rejected
563
+ print_ingest(result.added, result.rejected)
547
564
  else:
548
565
  try:
549
566
  shot_path = capture.capture_screenshot()
@@ -568,7 +585,7 @@ def cmd_attach(args: argparse.Namespace) -> int:
568
585
  return 1
569
586
  current = store.get(args.id)
570
587
  _warn_report_refs(store, current.notes)
571
- if args.image:
588
+ if args.image or args.file:
572
589
  try:
573
590
  refs.validate_references(current.notes, current.media)
574
591
  except ServiceError as exc:
@@ -901,12 +918,16 @@ def build_parser() -> argparse.ArgumentParser:
901
918
  p.add_argument("tags", nargs="+")
902
919
  p.set_defaults(func=cmd_tag)
903
920
 
904
- p = sub.add_parser("attach", help="Attach a screenshot (captured or imported) to a report.")
921
+ p = sub.add_parser("attach", help="Attach a screenshot (captured or imported) or any file to a report.")
905
922
  p.add_argument("id", type=int)
906
923
  p.add_argument("--image", action="append", metavar="SRC",
907
924
  help="Import an image (path, glob or http(s) URL) instead of launching a capture tool; repeatable.")
908
925
  p.add_argument("--label", action="append", help="Label for the --image at the same position (repeatable).")
909
- p.add_argument("--note", help="Text appended to the report's notes, tied to the image(s) added here.")
926
+ p.add_argument("--file", action="append", metavar="SRC",
927
+ help="Attach a file of any type (path, glob or http(s) URL); repeatable. "
928
+ "Refer to it in notes as @d1, @d2, ... or by label.")
929
+ p.add_argument("--file-label", action="append", help="Label for the --file at the same position (repeatable).")
930
+ p.add_argument("--note", help="Text appended to the report's notes, tied to the item(s) added here.")
910
931
  p.set_defaults(func=cmd_attach)
911
932
 
912
933
  p = sub.add_parser("images", help="List a report's images, or relabel/remove one.")
@@ -982,7 +1003,27 @@ def build_parser() -> argparse.ArgumentParser:
982
1003
  return parser
983
1004
 
984
1005
 
1006
+ def _use_utf8_streams() -> None:
1007
+ """Make stdout/stderr tolerant of report text the console's codepage can't show.
1008
+
1009
+ Report titles and notes are free-form user text and may contain characters
1010
+ (accents, emoji, …) outside the active console codepage. On Windows that
1011
+ codepage is often cp1252, so a bare `print()` of such text raises
1012
+ UnicodeEncodeError and crashes the CLI (e.g. `bugcap show`). Reconfiguring
1013
+ to UTF-8 with 'replace' keeps the command running and shows readable output
1014
+ instead of a traceback.
1015
+ """
1016
+ for stream in (sys.stdout, sys.stderr):
1017
+ reconfigure = getattr(stream, "reconfigure", None)
1018
+ if reconfigure is not None:
1019
+ try:
1020
+ reconfigure(encoding="utf-8", errors="replace")
1021
+ except (ValueError, OSError):
1022
+ pass
1023
+
1024
+
985
1025
  def main(argv: list[str] | None = None) -> int:
1026
+ _use_utf8_streams()
986
1027
  parser = build_parser()
987
1028
  args = parser.parse_args(argv)
988
1029
  return args.func(args)
@@ -31,8 +31,10 @@ def parse_range(header: str | None, size: int):
31
31
  return start, min(end, size - 1)
32
32
 
33
33
 
34
- def serve_media(handler, rel_path, mime: str, range_header: str | None) -> None:
35
- """`rel_path` is the stored data-relative path; it must resolve inside images/ or media/."""
34
+ def serve_media(handler, rel_path, mime: str, range_header: str | None, download_name: str | None = None) -> None:
35
+ """`rel_path` is the stored data-relative path; it must resolve inside the media store.
36
+ With `download_name` (attached files of any type) the bytes are always sent as an opaque
37
+ download, never rendered, so an uploaded HTML or SVG file cannot run on the dashboard origin."""
36
38
  try:
37
39
  path = resolve_data_path(rel_path)
38
40
  if not path.is_file():
@@ -53,7 +55,12 @@ def serve_media(handler, rel_path, mime: str, range_header: str | None) -> None:
53
55
  start, end = rng if rng else (0, size - 1)
54
56
  handler.send_response(206 if rng else 200)
55
57
  handler.send_security_headers()
56
- handler.send_header("Content-Type", mime)
58
+ if download_name is not None:
59
+ safe = re.sub(r"[^A-Za-z0-9._-]", "_", download_name)[:100] or "file"
60
+ handler.send_header("Content-Type", "application/octet-stream")
61
+ handler.send_header("Content-Disposition", f'attachment; filename="{safe}"')
62
+ else:
63
+ handler.send_header("Content-Type", mime)
57
64
  handler.send_header("Accept-Ranges", "bytes")
58
65
  handler.send_header("Cache-Control", "private, max-age=3600")
59
66
  handler.send_header("Content-Length", str(end - start + 1) if size else "0")
@@ -7,6 +7,7 @@ from __future__ import annotations
7
7
 
8
8
  import hmac
9
9
  import json
10
+ import os
10
11
  import re
11
12
  import secrets
12
13
  import sys
@@ -182,7 +183,10 @@ class Handler(BaseHTTPRequestHandler):
182
183
  if frame_no is None:
183
184
  if media.kind == "frames" or not media.path:
184
185
  return self.send_json(404, {"code": "not_found", "message": "media not found"})
185
- return serve_media(self, media.path, media.mime, self.headers.get("Range"))
186
+ name = None
187
+ if media.kind == "file":
188
+ name = os.path.basename((media.source or "").replace("\\", "/")) or os.path.basename(media.path)
189
+ return serve_media(self, media.path, media.mime, self.headers.get("Range"), name)
186
190
  frame = next((f for f in media.frames if f.frame_no == frame_no), None)
187
191
  if frame is None:
188
192
  return self.send_json(404, {"code": "not_found", "message": "frame not found"})
@@ -90,11 +90,15 @@
90
90
  var img = document.createElement("img");
91
91
  img.src = f.url; img.alt = "frame " + f.frame_no; fig.appendChild(img);
92
92
  });
93
+ } else if (m.kind === "file") {
94
+ var a = document.createElement("a");
95
+ a.href = m.url; a.download = ""; a.textContent = "Download " + (m.label || ("file " + m.index));
96
+ fig.appendChild(a);
93
97
  } else {
94
98
  var i = document.createElement("img");
95
99
  i.src = m.url; i.alt = m.label || ("image " + m.index); fig.appendChild(i);
96
100
  }
97
- var letters = {image: "i", video: "v", animated: "g", frames: "f"};
101
+ var letters = {image: "i", video: "v", animated: "g", frames: "f", file: "d"};
98
102
  var label = caption || ("@" + (letters[m.kind] || "i") + m.index);
99
103
  if (m.label) label += " (" + m.label + ")";
100
104
  fig.appendChild(el("figcaption", label + " · " + m.kind + " · " + Math.round(m.size_bytes / 1024) + " KB"));
@@ -9,8 +9,10 @@ from __future__ import annotations
9
9
  import glob
10
10
  import hashlib
11
11
  import os
12
+ import re
12
13
  import socket
13
14
  import urllib.error
15
+ import urllib.parse
14
16
  import urllib.request
15
17
  import uuid
16
18
  from dataclasses import dataclass, field
@@ -18,13 +20,15 @@ from pathlib import Path
18
20
  from typing import Literal, Optional
19
21
 
20
22
  from .errors import ServiceError
21
- from .paths import images_dir, to_data_relative
23
+ from .paths import files_dir, images_dir, to_data_relative
24
+ from .store import guess_mime
22
25
 
23
26
  MAX_DOWNLOAD_BYTES = 25 * 1024 * 1024
24
27
  DOWNLOAD_TIMEOUT = 20
25
28
  MAX_REDIRECTS = 5
26
29
 
27
30
  _EXT = {"image/png": ".png", "image/jpeg": ".jpg", "image/gif": ".gif", "image/webp": ".webp"}
31
+ _SUFFIX_RE = re.compile(r"^\.[a-z0-9]{1,16}$")
28
32
 
29
33
 
30
34
  @dataclass
@@ -146,13 +150,9 @@ def _clearly_not_image(content_type: str) -> bool:
146
150
  return main == "text" or any(k in sub for k in ("html", "json", "xml"))
147
151
 
148
152
 
149
- def download_url(
150
- url: str,
151
- *,
152
- max_bytes: int = MAX_DOWNLOAD_BYTES,
153
- timeout: float = DOWNLOAD_TIMEOUT,
154
- max_redirects: int = MAX_REDIRECTS,
155
- ) -> StoredFile:
153
+ def _fetch(url: str, *, max_bytes: int, timeout: float, max_redirects: int, images_only: bool) -> tuple[bytes, str]:
154
+ """GET `url`; returns (body, content-type hint). With `images_only`, text/HTML/JSON/XML
155
+ responses are refused early."""
156
156
  if not url.lower().startswith(("http://", "https://")):
157
157
  raise ServiceError("invalid_image", "only http(s) URLs are supported")
158
158
  request = urllib.request.Request(url, headers={"User-Agent": "bugcap"}, method="GET")
@@ -160,7 +160,7 @@ def download_url(
160
160
  try:
161
161
  with opener.open(request, timeout=timeout) as response:
162
162
  hint = response.headers.get("Content-Type", "").split(";")[0].strip().lower()
163
- if _clearly_not_image(hint):
163
+ if images_only and _clearly_not_image(hint):
164
164
  raise ServiceError("invalid_image", f"not an image (content is {hint})")
165
165
  declared = response.headers.get("Content-Length")
166
166
  if declared and declared.isdigit() and int(declared) > max_bytes:
@@ -187,7 +187,17 @@ def download_url(
187
187
  if isinstance(exc.reason, (socket.timeout, TimeoutError)):
188
188
  raise ServiceError("timeout", f"timed out after {timeout:g} s") from exc
189
189
  raise ServiceError("invalid_image", f"download failed: {exc.reason}") from exc
190
- data = b"".join(chunks)
190
+ return b"".join(chunks), hint
191
+
192
+
193
+ def download_url(
194
+ url: str,
195
+ *,
196
+ max_bytes: int = MAX_DOWNLOAD_BYTES,
197
+ timeout: float = DOWNLOAD_TIMEOUT,
198
+ max_redirects: int = MAX_REDIRECTS,
199
+ ) -> StoredFile:
200
+ data, hint = _fetch(url, max_bytes=max_bytes, timeout=timeout, max_redirects=max_redirects, images_only=True)
191
201
  try:
192
202
  return _store(data, url)
193
203
  except ServiceError as exc:
@@ -195,6 +205,71 @@ def download_url(
195
205
  raise ServiceError(exc.code, exc.message + suffix) from None
196
206
 
197
207
 
208
+ # --- any file (attachments) ---------------------------------------------------
209
+
210
+ def _store_file(data: bytes, source: str, name: str) -> StoredFile:
211
+ """Store any bytes under their content hash, keeping a sane extension so the file still
212
+ opens with the right program. Identical content is stored once."""
213
+ suffix = Path(name).suffix.lower()
214
+ if not _SUFFIX_RE.match(suffix):
215
+ suffix = ""
216
+ dest = files_dir() / f"{hashlib.sha256(data).hexdigest()}{suffix}"
217
+ if dest.is_file() and dest.stat().st_size == len(data):
218
+ return StoredFile(to_data_relative(dest), dest, guess_mime(name), len(data), source, reused=True)
219
+ tmp = dest.with_name(f"{dest.name}.{uuid.uuid4().hex}.part")
220
+ try:
221
+ tmp.write_bytes(data)
222
+ os.replace(tmp, dest)
223
+ finally:
224
+ tmp.unlink(missing_ok=True)
225
+ return StoredFile(to_data_relative(dest), dest, guess_mime(name), len(data), source)
226
+
227
+
228
+ def import_local_file(path: Path) -> StoredFile:
229
+ path = Path(path).expanduser()
230
+ if path.is_dir():
231
+ raise ServiceError("invalid_file", "is a directory")
232
+ if not path.is_file():
233
+ raise ServiceError("not_found", "file not found")
234
+ try:
235
+ data = path.read_bytes()
236
+ except OSError as exc:
237
+ raise ServiceError("invalid_file", f"cannot read file: {exc.strerror or exc}")
238
+ return _store_file(data, str(path), path.name)
239
+
240
+
241
+ def download_file(
242
+ url: str,
243
+ *,
244
+ max_bytes: int = MAX_DOWNLOAD_BYTES,
245
+ timeout: float = DOWNLOAD_TIMEOUT,
246
+ max_redirects: int = MAX_REDIRECTS,
247
+ ) -> StoredFile:
248
+ data, _ = _fetch(url, max_bytes=max_bytes, timeout=timeout, max_redirects=max_redirects, images_only=False)
249
+ return _store_file(data, url, urllib.parse.urlparse(url).path)
250
+
251
+
252
+ def adopt_file(path) -> Path:
253
+ """Give a file already copied into the files directory its content-derived name (same
254
+ rules as `adopt`): an identical file already stored wins and the new copy is dropped."""
255
+ path = Path(path)
256
+ if path.parent != files_dir():
257
+ return path
258
+ try:
259
+ data = path.read_bytes()
260
+ except OSError:
261
+ return path
262
+ suffix = path.suffix.lower() if _SUFFIX_RE.match(path.suffix.lower()) else ""
263
+ dest = files_dir() / f"{hashlib.sha256(data).hexdigest()}{suffix}"
264
+ if dest == path:
265
+ return path
266
+ if dest.is_file() and dest.stat().st_size == len(data):
267
+ path.unlink(missing_ok=True)
268
+ return dest
269
+ os.replace(path, dest)
270
+ return dest
271
+
272
+
198
273
  def resolve_glob(pattern: str) -> list[str]:
199
274
  """All matches, sorted by name (directories included; they are rejected per item)."""
200
275
  return sorted(glob.glob(os.path.expanduser(pattern)))
@@ -229,6 +304,14 @@ def ingest_item(item: Item) -> StoredFile:
229
304
  return import_local(Path(item.source))
230
305
 
231
306
 
307
+ def ingest_file_item(item: Item) -> StoredFile:
308
+ if item.error is not None:
309
+ raise item.error
310
+ if classify_source(item.source) == "url":
311
+ return download_file(item.source)
312
+ return import_local_file(Path(item.source))
313
+
314
+
232
315
  def ingest_sources(sources: list[str], labels: Optional[list[Optional[str]]] = None) -> IngestResult:
233
316
  """Process each input independently (a failure never blocks the others)."""
234
317
  result = IngestResult()
@@ -76,6 +76,7 @@ class _App:
76
76
  self.results: "queue.Queue" = queue.Queue()
77
77
  self.details = None
78
78
  self.widgets: dict = {}
79
+ self.repo_choices: list = []
79
80
  self.pending_drafts: list = []
80
81
  self.stop_recording = threading.Event()
81
82
  self.record_started = 0.0
@@ -158,18 +159,22 @@ class _App:
158
159
  self.shot_button.grid(row=1, column=0, sticky="ew", pady=(10, 0), padx=(0, 4))
159
160
  self.record_button = ttk.Button(frame, text="Record", command=self.on_record)
160
161
  self.record_button.grid(row=1, column=1, sticky="ew", pady=(10, 0), padx=(4, 0))
162
+ self.upload_button = ttk.Button(frame, text="Upload image...", command=self.on_upload_image)
163
+ self.upload_button.grid(row=2, column=0, sticky="ew", pady=(6, 0), padx=(0, 4))
164
+ self.file_button = ttk.Button(frame, text="Attach file...", command=self.on_upload_file)
165
+ self.file_button.grid(row=2, column=1, sticky="ew", pady=(6, 0), padx=(4, 0))
161
166
  self.staged_label = ttk.Label(frame, text="", style="Heading.TLabel", wraplength=230)
162
- self.staged_label.grid(row=2, column=0, sticky="w", pady=(12, 0))
167
+ self.staged_label.grid(row=3, column=0, sticky="w", pady=(12, 0))
163
168
  self.clear_button = ttk.Button(frame, text="Clear", command=self.on_clear)
164
- self.clear_button.grid(row=2, column=1, sticky="e", pady=(12, 0))
169
+ self.clear_button.grid(row=3, column=1, sticky="e", pady=(12, 0))
165
170
  self.new_button = ttk.Button(frame, text="New bug (note only)", command=self.on_new)
166
- self.new_button.grid(row=3, column=0, columnspan=2, sticky="ew", pady=(6, 0))
171
+ self.new_button.grid(row=4, column=0, columnspan=2, sticky="ew", pady=(6, 0))
167
172
  self.existing_button = ttk.Button(frame, text="Add to existing bug...", command=self.on_existing)
168
- self.existing_button.grid(row=4, column=0, columnspan=2, sticky="ew", pady=(6, 0))
173
+ self.existing_button.grid(row=5, column=0, columnspan=2, sticky="ew", pady=(6, 0))
169
174
  self.counter = ttk.Label(frame, text="0 saved this session", style="Muted.TLabel")
170
- self.counter.grid(row=5, column=0, columnspan=2, pady=(10, 0), sticky="w")
175
+ self.counter.grid(row=6, column=0, columnspan=2, pady=(10, 0), sticky="w")
171
176
  self.status = ttk.Label(frame, text=notice, wraplength=230, style="Warning.TLabel")
172
- self.status.grid(row=6, column=0, columnspan=2, pady=(4, 0), sticky="w")
177
+ self.status.grid(row=7, column=0, columnspan=2, pady=(4, 0), sticky="w")
173
178
  root.protocol("WM_DELETE_WINDOW", self.on_close_control)
174
179
  self.refresh()
175
180
  self.anchor_control()
@@ -197,6 +202,8 @@ class _App:
197
202
  else:
198
203
  self.clear_button.grid_remove()
199
204
  self.new_button.configure(text="New bug (note only)")
205
+ self.upload_button.state(["!disabled"] if ready else ["disabled"])
206
+ self.file_button.state(["!disabled"] if ready else ["disabled"])
200
207
  self.clear_button.state(["!disabled"] if ready else ["disabled"])
201
208
  self.new_button.state(["!disabled"] if ready else ["disabled"])
202
209
  self.existing_button.state(["!disabled"] if ready else ["disabled"])
@@ -231,6 +238,29 @@ class _App:
231
238
  threading.Thread(target=worker, daemon=True).start()
232
239
  self.root.after(100, self._poll)
233
240
 
241
+ # --- uploads ---------------------------------------------------------------
242
+
243
+ def on_upload_image(self) -> None:
244
+ self._pick_and_stage("Choose image(s)", self.session.upload_images, [
245
+ ("Images", "*.png *.jpg *.jpeg *.gif *.webp"), ("All files", "*.*")])
246
+
247
+ def on_upload_file(self) -> None:
248
+ self._pick_and_stage("Choose file(s) to attach", self.session.upload_files, [("All files", "*.*")])
249
+
250
+ def _pick_and_stage(self, title: str, stage, filetypes) -> None:
251
+ from tkinter import filedialog, messagebox
252
+
253
+ if self.session.state is not LiveState.READY:
254
+ return
255
+ paths = filedialog.askopenfilenames(title=title, parent=self.root, filetypes=filetypes)
256
+ if not paths:
257
+ return
258
+ errors = stage(paths)
259
+ self.refresh()
260
+ if errors:
261
+ messagebox.showerror("Not added", "\n".join(errors), parent=self.root)
262
+ self.set_status("Staged. Add more, or file a bug." if self.session.staged else "")
263
+
234
264
  # --- recording -------------------------------------------------------------
235
265
 
236
266
  def on_record(self) -> None:
@@ -386,8 +416,20 @@ class _App:
386
416
  w["status"].set(prefill.get("status", "open"))
387
417
  w["status"].grid(row=3, column=1, sticky="w", pady=4)
388
418
  ttk.Label(form, text="Repo", style="Heading.TLabel").grid(row=4, column=0, sticky="w", padx=(0, 12), pady=4)
389
- ttk.Label(form, text=(self.cfg.key if self.cfg else "(none)"), style="Muted.TLabel").grid(
390
- row=4, column=1, sticky="w", pady=4)
419
+ self.repo_choices = repo.known_repos()
420
+ if self.cfg is not None and all(c.root != self.cfg.root for c in self.repo_choices):
421
+ self.repo_choices.append(self.cfg)
422
+ if self.repo_choices:
423
+ current_key = self.cfg.key if self.cfg else ""
424
+ w["repo"] = ttk.Combobox(
425
+ form, values=[c.key for c in self.repo_choices], state="readonly", width=30,
426
+ )
427
+ w["repo"].set(current_key)
428
+ w["repo"].grid(row=4, column=1, sticky="w", pady=4)
429
+ w["repo"].bind("<<ComboboxSelected>>", self.on_repo_change)
430
+ else:
431
+ ttk.Label(form, text="(none - run `bugcap init` in a repo to tag reports)",
432
+ style="Muted.TLabel").grid(row=4, column=1, sticky="w", pady=4)
391
433
  row += 1
392
434
  w["error"] = ttk.Label(frame, text="", style="Error.TLabel", wraplength=420)
393
435
  w["error"].grid(row=row, column=0, columnspan=2, sticky="w", pady=(6, 0))
@@ -402,6 +444,24 @@ class _App:
402
444
  w["title"].focus_set()
403
445
  self.check_notes()
404
446
 
447
+ def on_repo_change(self, _event=None) -> None:
448
+ """The details form's repo picker changed: this report (and its auto tag) now
449
+ belongs to the newly-selected repo instead of the one `live` launched in."""
450
+ selected_key = self.widgets["repo"].get()
451
+ match = next((c for c in self.repo_choices if c.key == selected_key), None)
452
+ if match is None:
453
+ return
454
+ previous = self.cfg
455
+ self.cfg = self.session.repo_cfg = match
456
+ tags_entry = self.widgets["tags"]
457
+ current_tags = [t.strip() for t in tags_entry.get().split(",") if t.strip()]
458
+ if previous is not None and previous.tag in current_tags and previous.tag != match.tag:
459
+ current_tags = [match.tag if t == previous.tag else t for t in current_tags]
460
+ elif match.tag not in current_tags:
461
+ current_tags.append(match.tag)
462
+ tags_entry.delete(0, "end")
463
+ tags_entry.insert(0, ", ".join(current_tags))
464
+
405
465
  def fields(self) -> dict:
406
466
  w = self.widgets
407
467
  return {