bugcap 0.2.2__py3-none-any.whl
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/__init__.py +1 -0
- bugcap/agent_api.py +221 -0
- bugcap/backends.py +133 -0
- bugcap/capture.py +84 -0
- bugcap/cli.py +942 -0
- bugcap/config.py +86 -0
- bugcap/dashboard/__init__.py +3 -0
- bugcap/dashboard/api.py +105 -0
- bugcap/dashboard/media.py +73 -0
- bugcap/dashboard/server.py +213 -0
- bugcap/dashboard/static/app.css +43 -0
- bugcap/dashboard/static/app.js +179 -0
- bugcap/dashboard/static/index.html +45 -0
- bugcap/drafts.py +83 -0
- bugcap/errors.py +30 -0
- bugcap/ghcli.py +175 -0
- bugcap/ingest.py +210 -0
- bugcap/live.py +292 -0
- bugcap/live_session.py +125 -0
- bugcap/logs.py +74 -0
- bugcap/mcp_server.py +221 -0
- bugcap/paths.py +116 -0
- bugcap/recorder.py +390 -0
- bugcap/refs.py +213 -0
- bugcap/repo.py +138 -0
- bugcap/service.py +418 -0
- bugcap/store.py +485 -0
- bugcap/sync.py +424 -0
- bugcap/tomlio.py +37 -0
- bugcap/validation.py +74 -0
- bugcap-0.2.2.dist-info/METADATA +225 -0
- bugcap-0.2.2.dist-info/RECORD +36 -0
- bugcap-0.2.2.dist-info/WHEEL +5 -0
- bugcap-0.2.2.dist-info/entry_points.txt +2 -0
- bugcap-0.2.2.dist-info/licenses/LICENSE +21 -0
- bugcap-0.2.2.dist-info/top_level.txt +1 -0
bugcap/ingest.py
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"""Bring existing images into the store: local paths, globs and http(s) URLs.
|
|
2
|
+
|
|
3
|
+
Every input is validated by its magic bytes (never its extension or Content-Type), copied
|
|
4
|
+
into the images directory under a fresh name (the original path is not kept), and written
|
|
5
|
+
atomically. Network access is limited to plain http(s) GETs with no credentials."""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import glob
|
|
9
|
+
import os
|
|
10
|
+
import socket
|
|
11
|
+
import urllib.error
|
|
12
|
+
import urllib.request
|
|
13
|
+
import uuid
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Literal, Optional
|
|
17
|
+
|
|
18
|
+
from .errors import ServiceError
|
|
19
|
+
from .paths import images_dir, to_data_relative
|
|
20
|
+
|
|
21
|
+
MAX_DOWNLOAD_BYTES = 25 * 1024 * 1024
|
|
22
|
+
DOWNLOAD_TIMEOUT = 20
|
|
23
|
+
MAX_REDIRECTS = 5
|
|
24
|
+
|
|
25
|
+
_EXT = {"image/png": ".png", "image/jpeg": ".jpg", "image/gif": ".gif", "image/webp": ".webp"}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class StoredFile:
|
|
30
|
+
path: str # data-relative (images/<uuid>.<ext>)
|
|
31
|
+
abs_path: Path
|
|
32
|
+
mime: str
|
|
33
|
+
size_bytes: int
|
|
34
|
+
source: str
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class Item:
|
|
39
|
+
"""One concrete input after glob expansion."""
|
|
40
|
+
source: str
|
|
41
|
+
label: Optional[str] = None
|
|
42
|
+
error: Optional[ServiceError] = None # set when expansion itself failed
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class IngestResult:
|
|
47
|
+
added: list = field(default_factory=list) # [{"stored": StoredFile, "source", "label"}]
|
|
48
|
+
rejected: list = field(default_factory=list) # [{"source", "reason", "code"}]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def classify_source(src: str) -> Literal["url", "glob", "path"]:
|
|
52
|
+
lowered = src.lower()
|
|
53
|
+
if lowered.startswith("http://") or lowered.startswith("https://"):
|
|
54
|
+
return "url"
|
|
55
|
+
if any(ch in src for ch in "*?[") and not os.path.exists(os.path.expanduser(src)):
|
|
56
|
+
return "glob"
|
|
57
|
+
return "path"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def sniff_image(data: bytes) -> str:
|
|
61
|
+
"""The image mime type for PNG/JPEG/GIF/WebP magic bytes; ServiceError(invalid_image) otherwise."""
|
|
62
|
+
if data.startswith(b"\x89PNG\r\n\x1a\n") and len(data) >= 33:
|
|
63
|
+
return "image/png"
|
|
64
|
+
if data.startswith(b"\xff\xd8\xff") and len(data) >= 4:
|
|
65
|
+
return "image/jpeg"
|
|
66
|
+
if data[:6] in (b"GIF87a", b"GIF89a") and len(data) >= 10:
|
|
67
|
+
return "image/gif"
|
|
68
|
+
if data[:4] == b"RIFF" and data[8:12] == b"WEBP" and len(data) >= 16:
|
|
69
|
+
return "image/webp"
|
|
70
|
+
raise ServiceError("invalid_image", "not an image (PNG, JPEG, GIF or WebP expected)")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _store(data: bytes, source: str) -> StoredFile:
|
|
74
|
+
mime = sniff_image(data)
|
|
75
|
+
dest = images_dir() / f"{uuid.uuid4()}{_EXT[mime]}"
|
|
76
|
+
tmp = dest.with_name(dest.name + ".part")
|
|
77
|
+
try:
|
|
78
|
+
tmp.write_bytes(data)
|
|
79
|
+
os.replace(tmp, dest)
|
|
80
|
+
finally:
|
|
81
|
+
tmp.unlink(missing_ok=True)
|
|
82
|
+
return StoredFile(to_data_relative(dest), dest, mime, len(data), source)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def import_local(path: Path) -> StoredFile:
|
|
86
|
+
path = Path(path).expanduser()
|
|
87
|
+
if path.is_dir():
|
|
88
|
+
raise ServiceError("invalid_image", "is a directory")
|
|
89
|
+
if not path.is_file():
|
|
90
|
+
raise ServiceError("not_found", "file not found")
|
|
91
|
+
try:
|
|
92
|
+
data = path.read_bytes()
|
|
93
|
+
except OSError as exc:
|
|
94
|
+
raise ServiceError("invalid_image", f"cannot read file: {exc.strerror or exc}")
|
|
95
|
+
return _store(data, str(path))
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class _LimitedRedirects(urllib.request.HTTPRedirectHandler):
|
|
99
|
+
def __init__(self, max_redirects: int):
|
|
100
|
+
self.max_redirections = max_redirects
|
|
101
|
+
|
|
102
|
+
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
|
103
|
+
if not newurl.lower().startswith(("http://", "https://")):
|
|
104
|
+
raise ServiceError("invalid_image", "redirect to a non-http(s) URL refused")
|
|
105
|
+
return super().redirect_request(req, fp, code, msg, headers, newurl)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _clearly_not_image(content_type: str) -> bool:
|
|
109
|
+
"""Reject text/HTML/JSON/XML responses early. Anything else (image/*, octet-stream, a
|
|
110
|
+
missing header) goes on to the magic-byte check, which is the final authority."""
|
|
111
|
+
if not content_type:
|
|
112
|
+
return False
|
|
113
|
+
main, _, sub = content_type.partition("/")
|
|
114
|
+
return main == "text" or any(k in sub for k in ("html", "json", "xml"))
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def download_url(
|
|
118
|
+
url: str,
|
|
119
|
+
*,
|
|
120
|
+
max_bytes: int = MAX_DOWNLOAD_BYTES,
|
|
121
|
+
timeout: float = DOWNLOAD_TIMEOUT,
|
|
122
|
+
max_redirects: int = MAX_REDIRECTS,
|
|
123
|
+
) -> StoredFile:
|
|
124
|
+
if not url.lower().startswith(("http://", "https://")):
|
|
125
|
+
raise ServiceError("invalid_image", "only http(s) URLs are supported")
|
|
126
|
+
request = urllib.request.Request(url, headers={"User-Agent": "bugcap"}, method="GET")
|
|
127
|
+
opener = urllib.request.build_opener(_LimitedRedirects(max_redirects))
|
|
128
|
+
try:
|
|
129
|
+
with opener.open(request, timeout=timeout) as response:
|
|
130
|
+
hint = response.headers.get("Content-Type", "").split(";")[0].strip().lower()
|
|
131
|
+
if _clearly_not_image(hint):
|
|
132
|
+
raise ServiceError("invalid_image", f"not an image (content is {hint})")
|
|
133
|
+
declared = response.headers.get("Content-Length")
|
|
134
|
+
if declared and declared.isdigit() and int(declared) > max_bytes:
|
|
135
|
+
raise ServiceError("too_large", f"larger than {max_bytes // (1024 * 1024)} MB")
|
|
136
|
+
chunks: list[bytes] = []
|
|
137
|
+
total = 0
|
|
138
|
+
while True:
|
|
139
|
+
chunk = response.read(64 * 1024)
|
|
140
|
+
if not chunk:
|
|
141
|
+
break
|
|
142
|
+
total += len(chunk)
|
|
143
|
+
if total > max_bytes:
|
|
144
|
+
raise ServiceError("too_large", f"larger than {max_bytes // (1024 * 1024)} MB")
|
|
145
|
+
chunks.append(chunk)
|
|
146
|
+
except ServiceError:
|
|
147
|
+
raise
|
|
148
|
+
except urllib.error.HTTPError as exc:
|
|
149
|
+
if exc.code in (301, 302, 303, 307, 308):
|
|
150
|
+
raise ServiceError("invalid_image", "too many redirects") from exc
|
|
151
|
+
raise ServiceError("invalid_image", f"HTTP {exc.code}") from exc
|
|
152
|
+
except (socket.timeout, TimeoutError) as exc:
|
|
153
|
+
raise ServiceError("timeout", f"timed out after {timeout:g} s") from exc
|
|
154
|
+
except urllib.error.URLError as exc:
|
|
155
|
+
if isinstance(exc.reason, (socket.timeout, TimeoutError)):
|
|
156
|
+
raise ServiceError("timeout", f"timed out after {timeout:g} s") from exc
|
|
157
|
+
raise ServiceError("invalid_image", f"download failed: {exc.reason}") from exc
|
|
158
|
+
data = b"".join(chunks)
|
|
159
|
+
try:
|
|
160
|
+
return _store(data, url)
|
|
161
|
+
except ServiceError as exc:
|
|
162
|
+
suffix = f" (content is {hint})" if hint else ""
|
|
163
|
+
raise ServiceError(exc.code, exc.message + suffix) from None
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def resolve_glob(pattern: str) -> list[str]:
|
|
167
|
+
"""All matches, sorted by name (directories included; they are rejected per item)."""
|
|
168
|
+
return sorted(glob.glob(os.path.expanduser(pattern)))
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def expand_sources(sources: list[str], labels: Optional[list[Optional[str]]] = None) -> list[Item]:
|
|
172
|
+
"""Expand globs; `labels` pairs up with `sources` by position (empty/None = no label)."""
|
|
173
|
+
items: list[Item] = []
|
|
174
|
+
for position, source in enumerate(sources):
|
|
175
|
+
label = labels[position] if labels and position < len(labels) else None
|
|
176
|
+
label = label or None
|
|
177
|
+
kind = classify_source(source)
|
|
178
|
+
if kind == "glob":
|
|
179
|
+
matches = resolve_glob(source)
|
|
180
|
+
if not matches:
|
|
181
|
+
items.append(Item(source, label, ServiceError("not_found", f"no files match {source}")))
|
|
182
|
+
elif label and len(matches) > 1:
|
|
183
|
+
items.append(Item(source, label, ServiceError(
|
|
184
|
+
"invalid_label", f"label {label!r} cannot apply to {source}, which matches {len(matches)} files")))
|
|
185
|
+
else:
|
|
186
|
+
items.extend(Item(m, label) for m in matches)
|
|
187
|
+
else:
|
|
188
|
+
items.append(Item(source, label))
|
|
189
|
+
return items
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def ingest_item(item: Item) -> StoredFile:
|
|
193
|
+
if item.error is not None:
|
|
194
|
+
raise item.error
|
|
195
|
+
if classify_source(item.source) == "url":
|
|
196
|
+
return download_url(item.source)
|
|
197
|
+
return import_local(Path(item.source))
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def ingest_sources(sources: list[str], labels: Optional[list[Optional[str]]] = None) -> IngestResult:
|
|
201
|
+
"""Process each input independently (a failure never blocks the others)."""
|
|
202
|
+
result = IngestResult()
|
|
203
|
+
for item in expand_sources(sources, labels):
|
|
204
|
+
try:
|
|
205
|
+
stored = ingest_item(item)
|
|
206
|
+
except ServiceError as exc:
|
|
207
|
+
result.rejected.append({"source": item.source, "reason": exc.message, "code": exc.code})
|
|
208
|
+
else:
|
|
209
|
+
result.added.append({"stored": stored, "source": item.source, "label": item.label})
|
|
210
|
+
return result
|
bugcap/live.py
ADDED
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
"""`bugcap live`: a small always-on-top control window for reporting many bugs in a row.
|
|
2
|
+
|
|
3
|
+
Thin Tk shell over `live_session.LiveSession`: Start runs the existing capture backend (the
|
|
4
|
+
control window hides itself so it is not in the screenshot), a details window collects the
|
|
5
|
+
title/notes/tags/status, and Save returns to the ready state."""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
import queue
|
|
10
|
+
import sys
|
|
11
|
+
import threading
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from . import capture, drafts, refs, repo
|
|
15
|
+
from .errors import ServiceError
|
|
16
|
+
from .live_session import LiveSession, LiveState, initial_tags
|
|
17
|
+
from .store import STATUSES
|
|
18
|
+
|
|
19
|
+
TK_HINT = (
|
|
20
|
+
"Live mode needs tkinter. On Linux install your distro's python3-tk package "
|
|
21
|
+
"(e.g. sudo apt install python3-tk)."
|
|
22
|
+
)
|
|
23
|
+
NO_DESKTOP = "Live mode needs a desktop session (no DISPLAY or WAYLAND_DISPLAY is set)."
|
|
24
|
+
THUMB = 420
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def check_environment() -> tuple[int, str]:
|
|
28
|
+
"""(0, "") when a window can be opened, else (3, explanation)."""
|
|
29
|
+
if sys.platform not in ("win32", "darwin") and not (
|
|
30
|
+
os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY")
|
|
31
|
+
):
|
|
32
|
+
return 3, NO_DESKTOP
|
|
33
|
+
try:
|
|
34
|
+
import tkinter # noqa: F401
|
|
35
|
+
except ImportError:
|
|
36
|
+
return 3, TK_HINT
|
|
37
|
+
return 0, ""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def run(repo_dir=None) -> int:
|
|
41
|
+
code, message = check_environment()
|
|
42
|
+
if code:
|
|
43
|
+
print(f"error: {message}", file=sys.stderr)
|
|
44
|
+
return code
|
|
45
|
+
import tkinter as tk
|
|
46
|
+
|
|
47
|
+
try:
|
|
48
|
+
root = tk.Tk()
|
|
49
|
+
except tk.TclError as exc:
|
|
50
|
+
print(f"error: cannot open a window ({exc}). {NO_DESKTOP}", file=sys.stderr)
|
|
51
|
+
return 3
|
|
52
|
+
|
|
53
|
+
cfg = repo.load_repo_config(Path(repo_dir)) if repo_dir else repo.load_repo_config()
|
|
54
|
+
app = _App(root, LiveSession(cfg), cfg)
|
|
55
|
+
app.start_ui()
|
|
56
|
+
root.mainloop()
|
|
57
|
+
return 0
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class _App:
|
|
61
|
+
def __init__(self, root, session: LiveSession, cfg):
|
|
62
|
+
import tkinter as tk
|
|
63
|
+
from tkinter import ttk
|
|
64
|
+
|
|
65
|
+
self.tk, self.ttk = tk, ttk
|
|
66
|
+
self.root, self.session, self.cfg = root, session, cfg
|
|
67
|
+
self.results: "queue.Queue" = queue.Queue()
|
|
68
|
+
self.details = None
|
|
69
|
+
self.widgets: dict = {}
|
|
70
|
+
self.pending_drafts: list = []
|
|
71
|
+
|
|
72
|
+
# --- control window --------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
def start_ui(self) -> None:
|
|
75
|
+
tk, ttk, root = self.tk, self.ttk, self.root
|
|
76
|
+
root.title("bugcap live")
|
|
77
|
+
root.resizable(False, False)
|
|
78
|
+
notice = ""
|
|
79
|
+
try:
|
|
80
|
+
root.attributes("-topmost", True)
|
|
81
|
+
except tk.TclError:
|
|
82
|
+
notice = "Always-on-top is not supported here; the window may be hidden by others."
|
|
83
|
+
frame = ttk.Frame(root, padding=12)
|
|
84
|
+
frame.grid()
|
|
85
|
+
self.start_button = ttk.Button(frame, text="Start", command=self.on_start)
|
|
86
|
+
self.start_button.grid(row=0, column=0, sticky="ew")
|
|
87
|
+
self.counter = ttk.Label(frame, text="0 saved this session")
|
|
88
|
+
self.counter.grid(row=1, column=0, pady=(8, 0))
|
|
89
|
+
self.status = ttk.Label(frame, text=notice, wraplength=240, foreground="#a15c00")
|
|
90
|
+
self.status.grid(row=2, column=0, pady=(4, 0))
|
|
91
|
+
root.protocol("WM_DELETE_WINDOW", self.on_close_control)
|
|
92
|
+
self.pending_drafts = drafts.list_drafts(self.cfg.key if self.cfg else None)
|
|
93
|
+
root.after(200, self.offer_drafts)
|
|
94
|
+
|
|
95
|
+
def set_status(self, text: str) -> None:
|
|
96
|
+
self.status.configure(text=text)
|
|
97
|
+
|
|
98
|
+
def refresh_counter(self) -> None:
|
|
99
|
+
self.counter.configure(text=f"{self.session.saved_count} saved this session")
|
|
100
|
+
self.start_button.state(["!disabled"] if self.session.state is LiveState.READY else ["disabled"])
|
|
101
|
+
|
|
102
|
+
def on_start(self) -> None:
|
|
103
|
+
if not self.session.start():
|
|
104
|
+
return
|
|
105
|
+
self.refresh_counter()
|
|
106
|
+
self.set_status("Capturing...")
|
|
107
|
+
self.root.withdraw()
|
|
108
|
+
self.root.after(350, self._launch_capture)
|
|
109
|
+
|
|
110
|
+
def _launch_capture(self) -> None:
|
|
111
|
+
def worker():
|
|
112
|
+
try:
|
|
113
|
+
self.results.put((str(capture.capture_screenshot()), None))
|
|
114
|
+
except capture.CaptureError as exc:
|
|
115
|
+
self.results.put((None, str(exc)))
|
|
116
|
+
except Exception as exc: # never lose the window to a backend crash
|
|
117
|
+
self.results.put((None, f"capture failed: {exc}"))
|
|
118
|
+
|
|
119
|
+
threading.Thread(target=worker, daemon=True).start()
|
|
120
|
+
self.root.after(100, self._poll)
|
|
121
|
+
|
|
122
|
+
def _poll(self) -> None:
|
|
123
|
+
try:
|
|
124
|
+
path, message = self.results.get_nowait()
|
|
125
|
+
except queue.Empty:
|
|
126
|
+
self.root.after(100, self._poll)
|
|
127
|
+
return
|
|
128
|
+
self.root.deiconify()
|
|
129
|
+
self.session.capture_done(path, message)
|
|
130
|
+
self.refresh_counter()
|
|
131
|
+
if self.session.state is LiveState.DETAILS:
|
|
132
|
+
self.set_status("")
|
|
133
|
+
self.open_details()
|
|
134
|
+
else:
|
|
135
|
+
self.set_status(self.session.message)
|
|
136
|
+
|
|
137
|
+
# --- drafts ----------------------------------------------------------------
|
|
138
|
+
|
|
139
|
+
def offer_drafts(self) -> None:
|
|
140
|
+
from tkinter import messagebox
|
|
141
|
+
|
|
142
|
+
while self.pending_drafts and self.session.state is LiveState.READY:
|
|
143
|
+
draft = self.pending_drafts.pop(0)
|
|
144
|
+
title = draft.title or "(untitled)"
|
|
145
|
+
answer = messagebox.askyesnocancel(
|
|
146
|
+
"Unsaved draft", f"Restore the unsaved draft {title!r}?\n\nYes: restore No: discard Cancel: keep for later",
|
|
147
|
+
parent=self.root,
|
|
148
|
+
)
|
|
149
|
+
if answer is None:
|
|
150
|
+
continue
|
|
151
|
+
if answer:
|
|
152
|
+
self.session.restore(draft.png_path, draft.id)
|
|
153
|
+
self.refresh_counter()
|
|
154
|
+
self.open_details(draft.fields())
|
|
155
|
+
return
|
|
156
|
+
drafts.delete_draft(draft.id)
|
|
157
|
+
|
|
158
|
+
# --- details window --------------------------------------------------------
|
|
159
|
+
|
|
160
|
+
def open_details(self, prefill: dict | None = None) -> None:
|
|
161
|
+
tk, ttk = self.tk, self.ttk
|
|
162
|
+
win = tk.Toplevel(self.root)
|
|
163
|
+
self.details = win
|
|
164
|
+
win.title("New bug report")
|
|
165
|
+
try:
|
|
166
|
+
win.attributes("-topmost", True)
|
|
167
|
+
except tk.TclError:
|
|
168
|
+
pass
|
|
169
|
+
frame = ttk.Frame(win, padding=12)
|
|
170
|
+
frame.grid()
|
|
171
|
+
w = self.widgets = {}
|
|
172
|
+
|
|
173
|
+
try:
|
|
174
|
+
image = tk.PhotoImage(file=str(self.session.screenshot))
|
|
175
|
+
factor = max(1, -(-max(image.width(), image.height()) // THUMB))
|
|
176
|
+
w["photo"] = image.subsample(factor, factor) if factor > 1 else image
|
|
177
|
+
ttk.Label(frame, image=w["photo"]).grid(row=0, column=0, columnspan=2, pady=(0, 8))
|
|
178
|
+
except tk.TclError:
|
|
179
|
+
ttk.Label(frame, text="(preview unavailable)").grid(row=0, column=0, columnspan=2)
|
|
180
|
+
|
|
181
|
+
prefill = prefill or {}
|
|
182
|
+
ttk.Label(frame, text="Title").grid(row=1, column=0, sticky="w")
|
|
183
|
+
w["title"] = ttk.Entry(frame, width=48)
|
|
184
|
+
w["title"].grid(row=1, column=1, sticky="ew")
|
|
185
|
+
w["title"].insert(0, prefill.get("title", ""))
|
|
186
|
+
ttk.Label(frame, text="Notes").grid(row=2, column=0, sticky="nw")
|
|
187
|
+
w["notes"] = tk.Text(frame, width=48, height=6, wrap="word")
|
|
188
|
+
w["notes"].grid(row=2, column=1, sticky="ew")
|
|
189
|
+
w["notes"].insert("1.0", prefill.get("notes", ""))
|
|
190
|
+
w["notes"].bind("<KeyRelease>", lambda e: self.check_notes())
|
|
191
|
+
ttk.Label(frame, text="Tags").grid(row=3, column=0, sticky="w")
|
|
192
|
+
w["tags"] = ttk.Entry(frame, width=48)
|
|
193
|
+
w["tags"].grid(row=3, column=1, sticky="ew")
|
|
194
|
+
w["tags"].insert(0, ", ".join(initial_tags(self.cfg, prefill)))
|
|
195
|
+
ttk.Label(frame, text="Status").grid(row=4, column=0, sticky="w")
|
|
196
|
+
w["status"] = ttk.Combobox(frame, values=list(STATUSES), state="readonly", width=16)
|
|
197
|
+
w["status"].set(prefill.get("status", "open"))
|
|
198
|
+
w["status"].grid(row=4, column=1, sticky="w")
|
|
199
|
+
ttk.Label(frame, text="Repo").grid(row=5, column=0, sticky="w")
|
|
200
|
+
ttk.Label(frame, text=(self.cfg.key if self.cfg else "(none)")).grid(row=5, column=1, sticky="w")
|
|
201
|
+
w["error"] = ttk.Label(frame, text="", foreground="#b00020", wraplength=420)
|
|
202
|
+
w["error"].grid(row=6, column=0, columnspan=2, sticky="w", pady=(6, 0))
|
|
203
|
+
buttons = ttk.Frame(frame)
|
|
204
|
+
buttons.grid(row=7, column=0, columnspan=2, sticky="e", pady=(8, 0))
|
|
205
|
+
ttk.Button(buttons, text="Discard", command=self.on_discard).grid(row=0, column=0, padx=4)
|
|
206
|
+
ttk.Button(buttons, text="Save", command=self.on_save).grid(row=0, column=1)
|
|
207
|
+
win.protocol("WM_DELETE_WINDOW", self.on_close_details)
|
|
208
|
+
w["title"].focus_set()
|
|
209
|
+
self.check_notes()
|
|
210
|
+
|
|
211
|
+
def fields(self) -> dict:
|
|
212
|
+
w = self.widgets
|
|
213
|
+
return {
|
|
214
|
+
"title": w["title"].get().strip(),
|
|
215
|
+
"notes": w["notes"].get("1.0", "end-1c"),
|
|
216
|
+
"tags": [t.strip() for t in w["tags"].get().split(",") if t.strip()],
|
|
217
|
+
"status": w["status"].get() or "open",
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
def check_notes(self) -> bool:
|
|
221
|
+
"""Show an inline message for an unknown `@` reference (image 1 is the screenshot)."""
|
|
222
|
+
from .store import Media
|
|
223
|
+
|
|
224
|
+
notes = self.widgets["notes"].get("1.0", "end-1c")
|
|
225
|
+
shot = [Media(0, 0, 1, None, "image", None, "image/png", 0, None, "")]
|
|
226
|
+
try:
|
|
227
|
+
refs.validate_references(notes, shot)
|
|
228
|
+
except ServiceError as exc:
|
|
229
|
+
self.widgets["error"].configure(
|
|
230
|
+
text=f"{exc.message} (valid: {', '.join(exc.details.get('valid', []))})"
|
|
231
|
+
)
|
|
232
|
+
return False
|
|
233
|
+
self.widgets["error"].configure(text="")
|
|
234
|
+
return True
|
|
235
|
+
|
|
236
|
+
def close_details(self) -> None:
|
|
237
|
+
if self.details is not None:
|
|
238
|
+
self.details.destroy()
|
|
239
|
+
self.details = None
|
|
240
|
+
self.widgets = {}
|
|
241
|
+
self.refresh_counter()
|
|
242
|
+
|
|
243
|
+
def on_save(self) -> None:
|
|
244
|
+
try:
|
|
245
|
+
report = self.session.save(self.fields())
|
|
246
|
+
except ServiceError as exc:
|
|
247
|
+
self.widgets["error"].configure(text=exc.message)
|
|
248
|
+
return
|
|
249
|
+
self.close_details()
|
|
250
|
+
self.set_status(f"Saved report #{report.id}")
|
|
251
|
+
self.offer_drafts()
|
|
252
|
+
|
|
253
|
+
def on_discard(self) -> None:
|
|
254
|
+
from tkinter import messagebox
|
|
255
|
+
|
|
256
|
+
ok = messagebox.askyesno("Discard", "Discard this capture and everything typed?", parent=self.details)
|
|
257
|
+
if self.session.discard(ok):
|
|
258
|
+
self.close_details()
|
|
259
|
+
self.set_status("Discarded.")
|
|
260
|
+
self.offer_drafts()
|
|
261
|
+
|
|
262
|
+
def _keep_as_draft(self) -> None:
|
|
263
|
+
if self.session.screenshot is None:
|
|
264
|
+
return
|
|
265
|
+
if self.session.draft_id:
|
|
266
|
+
drafts.delete_draft(self.session.draft_id)
|
|
267
|
+
drafts.save_draft(self.cfg.key if self.cfg else None, self.session.screenshot, self.fields())
|
|
268
|
+
self.session.screenshot = None
|
|
269
|
+
self.session.draft_id = None
|
|
270
|
+
self.session.state = LiveState.READY
|
|
271
|
+
|
|
272
|
+
def on_close_details(self) -> None:
|
|
273
|
+
from tkinter import messagebox
|
|
274
|
+
|
|
275
|
+
if self.session.request_close(True) == "needs_confirm":
|
|
276
|
+
ok = messagebox.askyesno("Close", "Save this report as a draft?", parent=self.details)
|
|
277
|
+
if self.session.confirm_close(ok) == "save_draft":
|
|
278
|
+
self._keep_as_draft()
|
|
279
|
+
self.close_details()
|
|
280
|
+
self.set_status("Draft saved.")
|
|
281
|
+
|
|
282
|
+
def on_close_control(self) -> None:
|
|
283
|
+
from tkinter import messagebox
|
|
284
|
+
|
|
285
|
+
if self.session.state is LiveState.DETAILS and self.details is not None:
|
|
286
|
+
decision = self.session.request_close(True)
|
|
287
|
+
if decision == "needs_confirm":
|
|
288
|
+
ok = messagebox.askyesno("Quit", "Save the open report as a draft and quit?", parent=self.details)
|
|
289
|
+
if self.session.confirm_close(ok) != "save_draft":
|
|
290
|
+
return
|
|
291
|
+
self._keep_as_draft()
|
|
292
|
+
self.root.destroy()
|
bugcap/live_session.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""The logic of `bugcap live`, without any GUI: a small state machine plus the save step.
|
|
2
|
+
`live.py` is a thin Tk shell over this, so everything here is testable without a display."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from enum import Enum
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Callable, Optional
|
|
9
|
+
|
|
10
|
+
from . import service
|
|
11
|
+
from .store import Report, Store
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class LiveState(Enum):
|
|
15
|
+
READY = "ready"
|
|
16
|
+
CAPTURING = "capturing"
|
|
17
|
+
DETAILS = "details"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def initial_tags(repo_cfg, prefill: Optional[dict] = None) -> list:
|
|
21
|
+
"""Tags shown when the details window opens: a restored draft's own tags, otherwise
|
|
22
|
+
the launch repo's tag."""
|
|
23
|
+
if prefill and prefill.get("tags") is not None:
|
|
24
|
+
return list(prefill["tags"])
|
|
25
|
+
return [repo_cfg.tag] if repo_cfg is not None else []
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class LiveSession:
|
|
29
|
+
def __init__(self, repo_cfg=None, store_factory: Callable[[], Store] = Store):
|
|
30
|
+
self.repo_cfg = repo_cfg
|
|
31
|
+
self.store_factory = store_factory
|
|
32
|
+
self.state = LiveState.READY
|
|
33
|
+
self.saved_count = 0
|
|
34
|
+
self.screenshot: Optional[Path] = None
|
|
35
|
+
self.draft_id: Optional[str] = None
|
|
36
|
+
self.message = ""
|
|
37
|
+
|
|
38
|
+
# --- transitions -----------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
def start(self) -> bool:
|
|
41
|
+
"""Begin a capture. Ignored (False) unless READY, so a double click cannot start twice."""
|
|
42
|
+
if self.state is not LiveState.READY:
|
|
43
|
+
return False
|
|
44
|
+
self.state = LiveState.CAPTURING
|
|
45
|
+
self.message = ""
|
|
46
|
+
return True
|
|
47
|
+
|
|
48
|
+
def capture_done(self, path, message: Optional[str] = None) -> None:
|
|
49
|
+
if self.state is not LiveState.CAPTURING:
|
|
50
|
+
return
|
|
51
|
+
if path is None or not Path(path).is_file():
|
|
52
|
+
self.state = LiveState.READY
|
|
53
|
+
self.message = message or "Nothing was captured."
|
|
54
|
+
self.screenshot = None
|
|
55
|
+
return
|
|
56
|
+
self.screenshot = Path(path)
|
|
57
|
+
self.draft_id = None
|
|
58
|
+
self.state = LiveState.DETAILS
|
|
59
|
+
|
|
60
|
+
def restore(self, path, draft_id: str) -> None:
|
|
61
|
+
"""Open the details step for a recovered draft."""
|
|
62
|
+
if self.state is LiveState.READY:
|
|
63
|
+
self.screenshot = Path(path)
|
|
64
|
+
self.draft_id = draft_id
|
|
65
|
+
self.state = LiveState.DETAILS
|
|
66
|
+
|
|
67
|
+
def save(self, fields: dict) -> Report:
|
|
68
|
+
"""Save the report (screenshot is image 1). Raises ServiceError (empty title, bad
|
|
69
|
+
`@` reference, ...); the state stays DETAILS so the user can fix it."""
|
|
70
|
+
if self.state is not LiveState.DETAILS or self.screenshot is None:
|
|
71
|
+
raise RuntimeError("nothing to save")
|
|
72
|
+
tags = list(fields.get("tags") or [])
|
|
73
|
+
repo_key = None
|
|
74
|
+
if self.repo_cfg is not None:
|
|
75
|
+
repo_key = self.repo_cfg.key
|
|
76
|
+
if self.repo_cfg.tag not in tags:
|
|
77
|
+
tags.append(self.repo_cfg.tag)
|
|
78
|
+
with self.store_factory() as store:
|
|
79
|
+
report, _ = service.create_report(
|
|
80
|
+
store,
|
|
81
|
+
title=(fields.get("title") or "").strip(),
|
|
82
|
+
notes=fields.get("notes") or "",
|
|
83
|
+
tags=tags,
|
|
84
|
+
repo=repo_key,
|
|
85
|
+
status=fields.get("status") or "open",
|
|
86
|
+
sources=[str(self.screenshot)],
|
|
87
|
+
)
|
|
88
|
+
self._drop_screenshot()
|
|
89
|
+
self.saved_count += 1
|
|
90
|
+
self.state = LiveState.READY
|
|
91
|
+
self.message = f"Saved report #{report.id}"
|
|
92
|
+
return report
|
|
93
|
+
|
|
94
|
+
def discard(self, confirmed: bool) -> bool:
|
|
95
|
+
if self.state is not LiveState.DETAILS or not confirmed:
|
|
96
|
+
return False
|
|
97
|
+
self._drop_screenshot()
|
|
98
|
+
self.state = LiveState.READY
|
|
99
|
+
self.message = "Discarded."
|
|
100
|
+
return True
|
|
101
|
+
|
|
102
|
+
def request_close(self, has_unsaved: bool) -> str:
|
|
103
|
+
"""'close' when nothing would be lost, otherwise 'needs_confirm'."""
|
|
104
|
+
if self.state is LiveState.DETAILS and has_unsaved:
|
|
105
|
+
return "needs_confirm"
|
|
106
|
+
return "close"
|
|
107
|
+
|
|
108
|
+
def confirm_close(self, confirmed: bool) -> str:
|
|
109
|
+
"""After 'needs_confirm': 'save_draft' keeps the work, 'stay' keeps the window open."""
|
|
110
|
+
return "save_draft" if confirmed else "stay"
|
|
111
|
+
|
|
112
|
+
# --- helpers ---------------------------------------------------------------
|
|
113
|
+
|
|
114
|
+
def _drop_screenshot(self) -> None:
|
|
115
|
+
from . import drafts
|
|
116
|
+
|
|
117
|
+
if self.screenshot is not None:
|
|
118
|
+
try:
|
|
119
|
+
os.unlink(self.screenshot)
|
|
120
|
+
except OSError:
|
|
121
|
+
pass
|
|
122
|
+
if self.draft_id:
|
|
123
|
+
drafts.delete_draft(self.draft_id)
|
|
124
|
+
self.screenshot = None
|
|
125
|
+
self.draft_id = None
|