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/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