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.
- {bugcap-0.5.1/src/bugcap.egg-info → bugcap-0.6.0}/PKG-INFO +5 -3
- {bugcap-0.5.1 → bugcap-0.6.0}/README.md +4 -2
- {bugcap-0.5.1 → bugcap-0.6.0}/pyproject.toml +1 -1
- bugcap-0.6.0/src/bugcap/__init__.py +1 -0
- {bugcap-0.5.1 → bugcap-0.6.0}/src/bugcap/agent_api.py +10 -1
- {bugcap-0.5.1 → bugcap-0.6.0}/src/bugcap/backends.py +43 -0
- {bugcap-0.5.1 → bugcap-0.6.0}/src/bugcap/cli.py +55 -14
- {bugcap-0.5.1 → bugcap-0.6.0}/src/bugcap/dashboard/media.py +10 -3
- {bugcap-0.5.1 → bugcap-0.6.0}/src/bugcap/dashboard/server.py +5 -1
- {bugcap-0.5.1 → bugcap-0.6.0}/src/bugcap/dashboard/static/app.js +5 -1
- {bugcap-0.5.1 → bugcap-0.6.0}/src/bugcap/ingest.py +93 -10
- {bugcap-0.5.1 → bugcap-0.6.0}/src/bugcap/live.py +68 -8
- {bugcap-0.5.1 → bugcap-0.6.0}/src/bugcap/live_session.py +41 -8
- {bugcap-0.5.1 → bugcap-0.6.0}/src/bugcap/mcp_server.py +28 -5
- {bugcap-0.5.1 → bugcap-0.6.0}/src/bugcap/paths.py +7 -1
- {bugcap-0.5.1 → bugcap-0.6.0}/src/bugcap/refs.py +5 -4
- {bugcap-0.5.1 → bugcap-0.6.0}/src/bugcap/repo.py +65 -2
- {bugcap-0.5.1 → bugcap-0.6.0}/src/bugcap/service.py +27 -6
- {bugcap-0.5.1 → bugcap-0.6.0}/src/bugcap/store.py +38 -4
- {bugcap-0.5.1 → bugcap-0.6.0}/src/bugcap/sync.py +3 -0
- {bugcap-0.5.1 → bugcap-0.6.0/src/bugcap.egg-info}/PKG-INFO +5 -3
- {bugcap-0.5.1 → bugcap-0.6.0}/src/bugcap.egg-info/SOURCES.txt +1 -0
- {bugcap-0.5.1 → bugcap-0.6.0}/tests/test_backends.py +57 -0
- {bugcap-0.5.1 → bugcap-0.6.0}/tests/test_cli_setup.py +29 -0
- {bugcap-0.5.1 → bugcap-0.6.0}/tests/test_cli_triage.py +4 -1
- bugcap-0.6.0/tests/test_file_attachments.py +217 -0
- {bugcap-0.5.1 → bugcap-0.6.0}/tests/test_mcp.py +1 -1
- {bugcap-0.5.1 → bugcap-0.6.0}/tests/test_mcp_logging.py +13 -0
- {bugcap-0.5.1 → bugcap-0.6.0}/tests/test_regression_output.py +16 -0
- {bugcap-0.5.1 → bugcap-0.6.0}/tests/test_repo_config.py +46 -0
- {bugcap-0.5.1 → bugcap-0.6.0}/tests/test_store_migration.py +62 -7
- bugcap-0.5.1/src/bugcap/__init__.py +0 -1
- {bugcap-0.5.1 → bugcap-0.6.0}/LICENSE +0 -0
- {bugcap-0.5.1 → bugcap-0.6.0}/setup.cfg +0 -0
- {bugcap-0.5.1 → bugcap-0.6.0}/src/bugcap/capture.py +0 -0
- {bugcap-0.5.1 → bugcap-0.6.0}/src/bugcap/config.py +0 -0
- {bugcap-0.5.1 → bugcap-0.6.0}/src/bugcap/dashboard/__init__.py +0 -0
- {bugcap-0.5.1 → bugcap-0.6.0}/src/bugcap/dashboard/api.py +0 -0
- {bugcap-0.5.1 → bugcap-0.6.0}/src/bugcap/dashboard/static/app.css +0 -0
- {bugcap-0.5.1 → bugcap-0.6.0}/src/bugcap/dashboard/static/index.html +0 -0
- {bugcap-0.5.1 → bugcap-0.6.0}/src/bugcap/drafts.py +0 -0
- {bugcap-0.5.1 → bugcap-0.6.0}/src/bugcap/errors.py +0 -0
- {bugcap-0.5.1 → bugcap-0.6.0}/src/bugcap/ghcli.py +0 -0
- {bugcap-0.5.1 → bugcap-0.6.0}/src/bugcap/logs.py +0 -0
- {bugcap-0.5.1 → bugcap-0.6.0}/src/bugcap/recorder.py +0 -0
- {bugcap-0.5.1 → bugcap-0.6.0}/src/bugcap/tomlio.py +0 -0
- {bugcap-0.5.1 → bugcap-0.6.0}/src/bugcap/validation.py +0 -0
- {bugcap-0.5.1 → bugcap-0.6.0}/src/bugcap.egg-info/dependency_links.txt +0 -0
- {bugcap-0.5.1 → bugcap-0.6.0}/src/bugcap.egg-info/entry_points.txt +0 -0
- {bugcap-0.5.1 → bugcap-0.6.0}/src/bugcap.egg-info/requires.txt +0 -0
- {bugcap-0.5.1 → bugcap-0.6.0}/src/bugcap.egg-info/top_level.txt +0 -0
- {bugcap-0.5.1 → bugcap-0.6.0}/tests/test_agent_api.py +0 -0
- {bugcap-0.5.1 → bugcap-0.6.0}/tests/test_capture.py +0 -0
- {bugcap-0.5.1 → bugcap-0.6.0}/tests/test_cli_attach.py +0 -0
- {bugcap-0.5.1 → bugcap-0.6.0}/tests/test_cli_dashboard.py +0 -0
- {bugcap-0.5.1 → bugcap-0.6.0}/tests/test_cli_init_capture_list.py +0 -0
- {bugcap-0.5.1 → bugcap-0.6.0}/tests/test_cli_live_headless.py +0 -0
- {bugcap-0.5.1 → bugcap-0.6.0}/tests/test_cli_media.py +0 -0
- {bugcap-0.5.1 → bugcap-0.6.0}/tests/test_cli_no_image_delete.py +0 -0
- {bugcap-0.5.1 → bugcap-0.6.0}/tests/test_cli_record.py +0 -0
- {bugcap-0.5.1 → bugcap-0.6.0}/tests/test_dashboard_api.py +0 -0
- {bugcap-0.5.1 → bugcap-0.6.0}/tests/test_dashboard_media.py +0 -0
- {bugcap-0.5.1 → bugcap-0.6.0}/tests/test_dashboard_mutations.py +0 -0
- {bugcap-0.5.1 → bugcap-0.6.0}/tests/test_dashboard_security.py +0 -0
- {bugcap-0.5.1 → bugcap-0.6.0}/tests/test_dashboard_ui_static.py +0 -0
- {bugcap-0.5.1 → bugcap-0.6.0}/tests/test_drafts.py +0 -0
- {bugcap-0.5.1 → bugcap-0.6.0}/tests/test_ghcli.py +0 -0
- {bugcap-0.5.1 → bugcap-0.6.0}/tests/test_github_media_sync.py +0 -0
- {bugcap-0.5.1 → bugcap-0.6.0}/tests/test_github_pull.py +0 -0
- {bugcap-0.5.1 → bugcap-0.6.0}/tests/test_github_sync.py +0 -0
- {bugcap-0.5.1 → bugcap-0.6.0}/tests/test_image_refs.py +0 -0
- {bugcap-0.5.1 → bugcap-0.6.0}/tests/test_ingest.py +0 -0
- {bugcap-0.5.1 → bugcap-0.6.0}/tests/test_ingest_url.py +0 -0
- {bugcap-0.5.1 → bugcap-0.6.0}/tests/test_live_session.py +0 -0
- {bugcap-0.5.1 → bugcap-0.6.0}/tests/test_paths.py +0 -0
- {bugcap-0.5.1 → bugcap-0.6.0}/tests/test_recorder_argv.py +0 -0
- {bugcap-0.5.1 → bugcap-0.6.0}/tests/test_recorder_devices.py +0 -0
- {bugcap-0.5.1 → bugcap-0.6.0}/tests/test_recorder_media_real.py +0 -0
- {bugcap-0.5.1 → bugcap-0.6.0}/tests/test_recorder_run.py +0 -0
- {bugcap-0.5.1 → bugcap-0.6.0}/tests/test_refs.py +0 -0
- {bugcap-0.5.1 → bugcap-0.6.0}/tests/test_refs_validate_rewrite.py +0 -0
- {bugcap-0.5.1 → bugcap-0.6.0}/tests/test_repo_values.py +0 -0
- {bugcap-0.5.1 → bugcap-0.6.0}/tests/test_service_media.py +0 -0
- {bugcap-0.5.1 → bugcap-0.6.0}/tests/test_service_refs.py +0 -0
- {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.
|
|
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.
|
|
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 =
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
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("--
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
150
|
-
url
|
|
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
|
-
|
|
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=
|
|
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=
|
|
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=
|
|
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=
|
|
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=
|
|
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=
|
|
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
|
-
|
|
390
|
-
|
|
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 {
|