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/repo.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""Per-repo setup: `bugcap init` writes .bugcap.toml so captures there get a tag + GitHub slug."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import re
|
|
5
|
+
import subprocess
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
from . import tomlio
|
|
11
|
+
|
|
12
|
+
CONFIG_NAME = ".bugcap.toml"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class RepoConfigError(RuntimeError):
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class RepoConfig:
|
|
21
|
+
root: Path
|
|
22
|
+
tag: str
|
|
23
|
+
github: Optional[str] = None # "owner/repo"
|
|
24
|
+
images_repo: Optional[str] = None # [sync] owner/repo for committed image copies
|
|
25
|
+
images_path: Optional[str] = None # [sync] directory within images_repo
|
|
26
|
+
images_branch: Optional[str] = None # [sync] branch within images_repo
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def key(self) -> str:
|
|
30
|
+
return self.github or self.tag
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _git(args: list[str], cwd: Path) -> Optional[str]:
|
|
34
|
+
try:
|
|
35
|
+
out = subprocess.run(
|
|
36
|
+
["git", *args], cwd=cwd, capture_output=True, text=True, check=True
|
|
37
|
+
)
|
|
38
|
+
except (OSError, subprocess.CalledProcessError):
|
|
39
|
+
return None
|
|
40
|
+
return out.stdout.strip() or None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def git_root(start: Optional[Path] = None) -> Optional[Path]:
|
|
44
|
+
root = _git(["rev-parse", "--show-toplevel"], start or Path.cwd())
|
|
45
|
+
return Path(root) if root else None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def detect_github_slug(root: Path) -> Optional[str]:
|
|
49
|
+
url = _git(["remote", "get-url", "origin"], root)
|
|
50
|
+
if not url:
|
|
51
|
+
return None
|
|
52
|
+
match = re.search(r"github\.com[:/]([^/\s]+)/([^/\s]+?)(?:\.git)?/?$", url)
|
|
53
|
+
return f"{match.group(1)}/{match.group(2)}" if match else None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def find_config(start: Optional[Path] = None) -> Optional[Path]:
|
|
57
|
+
here = (start or Path.cwd()).resolve()
|
|
58
|
+
for folder in [here, *here.parents]:
|
|
59
|
+
candidate = folder / CONFIG_NAME
|
|
60
|
+
if candidate.is_file():
|
|
61
|
+
return candidate
|
|
62
|
+
return None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _config_from_file(path: Path) -> RepoConfig:
|
|
66
|
+
data = tomlio.load(path)
|
|
67
|
+
sync = data.get("sync") or {}
|
|
68
|
+
return RepoConfig(
|
|
69
|
+
root=path.parent,
|
|
70
|
+
tag=data.get("tag") or path.parent.name,
|
|
71
|
+
github=data.get("github"),
|
|
72
|
+
images_repo=sync.get("images_repo"),
|
|
73
|
+
images_path=sync.get("images_path"),
|
|
74
|
+
images_branch=sync.get("images_branch"),
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def load_repo_config(start: Optional[Path] = None) -> Optional[RepoConfig]:
|
|
79
|
+
path = find_config(start)
|
|
80
|
+
return _config_from_file(path) if path else None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def write_config(cfg: RepoConfig) -> None:
|
|
84
|
+
data: dict = {"tag": cfg.tag}
|
|
85
|
+
if cfg.github:
|
|
86
|
+
data["github"] = cfg.github
|
|
87
|
+
sync = {
|
|
88
|
+
k: v
|
|
89
|
+
for k, v in (
|
|
90
|
+
("images_repo", cfg.images_repo),
|
|
91
|
+
("images_path", cfg.images_path),
|
|
92
|
+
("images_branch", cfg.images_branch),
|
|
93
|
+
)
|
|
94
|
+
if v
|
|
95
|
+
}
|
|
96
|
+
if sync:
|
|
97
|
+
data["sync"] = sync
|
|
98
|
+
tomlio.save(cfg.root / CONFIG_NAME, data)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def build_config(
|
|
102
|
+
root: Path,
|
|
103
|
+
tag: Optional[str] = None,
|
|
104
|
+
github: Optional[str] = None,
|
|
105
|
+
images_repo: Optional[str] = None,
|
|
106
|
+
images_path: Optional[str] = None,
|
|
107
|
+
images_branch: Optional[str] = None,
|
|
108
|
+
) -> RepoConfig:
|
|
109
|
+
"""The config `init` would write (nothing is written)."""
|
|
110
|
+
return RepoConfig(
|
|
111
|
+
root=root,
|
|
112
|
+
tag=tag or root.name,
|
|
113
|
+
github=github or detect_github_slug(root),
|
|
114
|
+
images_repo=images_repo,
|
|
115
|
+
images_path=images_path,
|
|
116
|
+
images_branch=images_branch,
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def init_repo(
|
|
121
|
+
root: Path,
|
|
122
|
+
tag: Optional[str] = None,
|
|
123
|
+
github: Optional[str] = None,
|
|
124
|
+
images_repo: Optional[str] = None,
|
|
125
|
+
images_path: Optional[str] = None,
|
|
126
|
+
images_branch: Optional[str] = None,
|
|
127
|
+
force: bool = False,
|
|
128
|
+
) -> RepoConfig:
|
|
129
|
+
if (root / CONFIG_NAME).exists() and not force:
|
|
130
|
+
raise RepoConfigError(
|
|
131
|
+
f"{CONFIG_NAME} already exists in {root}. Use --force to overwrite."
|
|
132
|
+
)
|
|
133
|
+
cfg = build_config(root, tag, github, images_repo, images_path, images_branch)
|
|
134
|
+
write_config(cfg)
|
|
135
|
+
return cfg
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
SETTABLE_KEYS = ("tag", "github", "images_repo", "images_path", "images_branch")
|
bugcap/service.py
ADDED
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
"""Shared operations used by the CLI, the MCP tools, the dashboard and live mode.
|
|
2
|
+
|
|
3
|
+
All rules that go beyond plain storage (label and reference validation, reference rewrite on
|
|
4
|
+
relabel/remove, media lifecycle) live here so no interface re-implements them. Functions
|
|
5
|
+
take a `Store`, raise `ServiceError`, and never print."""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from typing import Optional
|
|
11
|
+
|
|
12
|
+
from . import ingest, refs
|
|
13
|
+
from .errors import ServiceError
|
|
14
|
+
from .paths import resolve_data_path
|
|
15
|
+
from .store import STATUSES, Media, Report, Store
|
|
16
|
+
|
|
17
|
+
_LABEL_HELP = "start with a letter; then letters, digits, '-' or '_'"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# --- lookups ------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
def _require(store: Store, report_id: int) -> Report:
|
|
23
|
+
report = store.get(report_id)
|
|
24
|
+
if report is None:
|
|
25
|
+
raise ServiceError("not_found", f"no report with id {report_id}")
|
|
26
|
+
return report
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def find_media(media: list[Media], ref: str) -> Media:
|
|
30
|
+
"""Locate a media item by index (`2`) or label (`login-error`, case-insensitive)."""
|
|
31
|
+
text = str(ref).lstrip("@")
|
|
32
|
+
if text.isdigit():
|
|
33
|
+
for item in media:
|
|
34
|
+
if item.idx == int(text):
|
|
35
|
+
return item
|
|
36
|
+
else:
|
|
37
|
+
for item in media:
|
|
38
|
+
if item.label and item.label.lower() == text.lower():
|
|
39
|
+
return item
|
|
40
|
+
raise ServiceError("not_found", f"no image {ref!r} on this report")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# --- labels -------------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
def validate_label(label: str) -> str:
|
|
46
|
+
if not refs.LABEL_RE.match(label):
|
|
47
|
+
if label.isdigit():
|
|
48
|
+
raise ServiceError("invalid_label", f"label {label!r} cannot be only digits (those are indexes)")
|
|
49
|
+
raise ServiceError("invalid_label", f"invalid label {label!r}: {_LABEL_HELP}")
|
|
50
|
+
return label
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _check_new_labels(existing: list[Media], labels: list[Optional[str]]) -> None:
|
|
54
|
+
taken = {m.label.lower(): m for m in existing if m.label}
|
|
55
|
+
for label in labels:
|
|
56
|
+
if not label:
|
|
57
|
+
continue
|
|
58
|
+
validate_label(label)
|
|
59
|
+
clash = taken.get(label.lower())
|
|
60
|
+
if clash is not None:
|
|
61
|
+
raise ServiceError(
|
|
62
|
+
"duplicate_label", f"duplicate label {label!r} (already #{clash.idx})",
|
|
63
|
+
{"label": label, "index": clash.idx},
|
|
64
|
+
)
|
|
65
|
+
taken[label.lower()] = Media(0, 0, 0, label, "image", None, "", 0, None, "")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# --- media: add ---------------------------------------------------------------
|
|
69
|
+
|
|
70
|
+
@dataclass
|
|
71
|
+
class AddResult:
|
|
72
|
+
added: list = field(default_factory=list) # [Media]
|
|
73
|
+
rejected: list = field(default_factory=list) # [{"source","reason","code"}]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _discard(stored_files) -> None:
|
|
77
|
+
for stored in stored_files:
|
|
78
|
+
try:
|
|
79
|
+
stored.abs_path.unlink()
|
|
80
|
+
except OSError:
|
|
81
|
+
pass
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _prepare(existing: list[Media], sources: list[str], labels):
|
|
85
|
+
"""Expand globs and validate labels before any file is copied."""
|
|
86
|
+
items = ingest.expand_sources(list(sources), labels)
|
|
87
|
+
_check_new_labels(existing, [i.label for i in items if i.error is None])
|
|
88
|
+
return items
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _ingest_all(items):
|
|
92
|
+
ok, rejected = [], []
|
|
93
|
+
for item in items:
|
|
94
|
+
try:
|
|
95
|
+
ok.append((item, ingest.ingest_item(item)))
|
|
96
|
+
except ServiceError as exc:
|
|
97
|
+
rejected.append({"source": item.source, "reason": exc.message, "code": exc.code})
|
|
98
|
+
return ok, rejected
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def add_media(store: Store, report_id: int, sources: list[str], labels=None) -> AddResult:
|
|
102
|
+
report = _require(store, report_id)
|
|
103
|
+
items = _prepare(report.media, sources, labels)
|
|
104
|
+
ok, rejected = _ingest_all(items)
|
|
105
|
+
result = AddResult(rejected=rejected)
|
|
106
|
+
try:
|
|
107
|
+
with store.transaction():
|
|
108
|
+
for item, stored in ok:
|
|
109
|
+
result.added.append(
|
|
110
|
+
store.insert_media(
|
|
111
|
+
report_id, kind="image", path=str(stored.abs_path), label=item.label,
|
|
112
|
+
mime=stored.mime, size_bytes=stored.size_bytes, source=item.source,
|
|
113
|
+
)
|
|
114
|
+
)
|
|
115
|
+
except BaseException:
|
|
116
|
+
_discard(s for _, s in ok)
|
|
117
|
+
raise
|
|
118
|
+
return result
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
# --- reports ------------------------------------------------------------------
|
|
122
|
+
|
|
123
|
+
def create_report(
|
|
124
|
+
store: Store,
|
|
125
|
+
title: str,
|
|
126
|
+
notes: str = "",
|
|
127
|
+
tags=None,
|
|
128
|
+
repo: Optional[str] = None,
|
|
129
|
+
status: str = "open",
|
|
130
|
+
body: str = "",
|
|
131
|
+
sources: Optional[list[str]] = None,
|
|
132
|
+
labels=None,
|
|
133
|
+
require_media: bool = False,
|
|
134
|
+
captured: Optional[str] = None,
|
|
135
|
+
captured_label: Optional[str] = None,
|
|
136
|
+
) -> tuple[Report, AddResult]:
|
|
137
|
+
"""Create a report, attaching `sources` as images. Notes are validated against the final
|
|
138
|
+
media; on any failure no report and no copied file is left behind. With `require_media`,
|
|
139
|
+
a report whose inputs were all rejected is not created (the report is returned as None).
|
|
140
|
+
`captured` is a screenshot the capture tool already saved in the store: it becomes image 1
|
|
141
|
+
without being copied."""
|
|
142
|
+
if not (title or "").strip():
|
|
143
|
+
raise ServiceError("invalid_title", "title must not be empty")
|
|
144
|
+
items = _prepare([], sources or [], list(labels or []))
|
|
145
|
+
if captured_label:
|
|
146
|
+
_check_new_labels([], [captured_label] + [i.label for i in items if i.error is None])
|
|
147
|
+
ok, rejected = _ingest_all(items)
|
|
148
|
+
result = AddResult(rejected=rejected)
|
|
149
|
+
if require_media and not ok and captured is None:
|
|
150
|
+
return None, result # type: ignore[return-value]
|
|
151
|
+
try:
|
|
152
|
+
with store.transaction():
|
|
153
|
+
report = store.add(title=title, notes="", tags=tags, repo=repo, body=body, status=status)
|
|
154
|
+
if captured is not None:
|
|
155
|
+
result.added.append(
|
|
156
|
+
store.insert_media(report.id, kind="image", path=captured,
|
|
157
|
+
label=captured_label or None, source="captured")
|
|
158
|
+
)
|
|
159
|
+
for item, stored in ok:
|
|
160
|
+
result.added.append(
|
|
161
|
+
store.insert_media(
|
|
162
|
+
report.id, kind="image", path=str(stored.abs_path), label=item.label,
|
|
163
|
+
mime=stored.mime, size_bytes=stored.size_bytes, source=item.source,
|
|
164
|
+
)
|
|
165
|
+
)
|
|
166
|
+
if notes:
|
|
167
|
+
refs.validate_references(notes, result.added)
|
|
168
|
+
store.update(report.id, notes=notes)
|
|
169
|
+
except BaseException:
|
|
170
|
+
_discard(s for _, s in ok)
|
|
171
|
+
raise
|
|
172
|
+
return store.get(report.id), result
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def attach_captured(store: Store, report_id: int, path: str, label: Optional[str] = None) -> Media:
|
|
176
|
+
"""Attach a screenshot the capture tool already saved in the store (no copy)."""
|
|
177
|
+
report = _require(store, report_id)
|
|
178
|
+
_check_new_labels(report.media, [label])
|
|
179
|
+
return store.insert_media(report_id, kind="image", path=str(path), label=label or None, source="captured")
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def add_recording(
|
|
183
|
+
store: Store,
|
|
184
|
+
report_id: Optional[int],
|
|
185
|
+
file: Optional[str],
|
|
186
|
+
kind: str,
|
|
187
|
+
mime: str,
|
|
188
|
+
size_bytes: int,
|
|
189
|
+
frame_paths: Optional[list] = None,
|
|
190
|
+
title: Optional[str] = None,
|
|
191
|
+
notes: Optional[str] = None,
|
|
192
|
+
tags=None,
|
|
193
|
+
repo: Optional[str] = None,
|
|
194
|
+
) -> tuple[Report, Media]:
|
|
195
|
+
"""Attach a finished recording (video / animated file, or a set of keyframes) to an
|
|
196
|
+
existing report, or to a new one when `report_id` is None. `frame_paths` is
|
|
197
|
+
[(path, size)] for kind `frames`."""
|
|
198
|
+
from datetime import datetime
|
|
199
|
+
|
|
200
|
+
if kind not in ("video", "animated", "frames"):
|
|
201
|
+
raise ServiceError("invalid_image", f"unsupported recording kind {kind!r}")
|
|
202
|
+
if kind == "frames" and not frame_paths:
|
|
203
|
+
raise ServiceError("invalid_image", "a frames recording needs at least one keyframe")
|
|
204
|
+
with store.transaction():
|
|
205
|
+
if report_id is None:
|
|
206
|
+
report = store.add(
|
|
207
|
+
title=title or f"Recording {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}",
|
|
208
|
+
tags=tags, repo=repo,
|
|
209
|
+
)
|
|
210
|
+
else:
|
|
211
|
+
report = _require(store, report_id)
|
|
212
|
+
media = store.insert_media(
|
|
213
|
+
report.id, kind=kind, path=None if kind == "frames" else file, mime=mime,
|
|
214
|
+
size_bytes=size_bytes, source="recorded", frames=frame_paths if kind == "frames" else None,
|
|
215
|
+
)
|
|
216
|
+
if notes:
|
|
217
|
+
refs.validate_references(notes, store.list_media(report.id))
|
|
218
|
+
store.update(report.id, notes=notes)
|
|
219
|
+
return store.get(report.id), media
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def format_note_entry(text: str, media: list, when=None) -> str:
|
|
223
|
+
"""`[2026-10-04 10:15] @3: text`: timestamped and tied to the image(s) it was added with."""
|
|
224
|
+
from datetime import datetime
|
|
225
|
+
|
|
226
|
+
stamp = (when or datetime.now().astimezone()).strftime("%Y-%m-%d %H:%M")
|
|
227
|
+
tokens = " ".join(f"@{m.idx}" for m in media)
|
|
228
|
+
return f"[{stamp}] {tokens}: {text.strip()}" if tokens else f"[{stamp}] {text.strip()}"
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def append_note(store: Store, report_id: int, text: str, media: Optional[list] = None, when=None) -> Report:
|
|
232
|
+
"""Append a note to the report's notes (never replacing them), tied to the media it was
|
|
233
|
+
added with so it can be referenced with `@n`. The new entry is validated like any note."""
|
|
234
|
+
report = _require(store, report_id)
|
|
235
|
+
if not (text or "").strip():
|
|
236
|
+
return report
|
|
237
|
+
entry = format_note_entry(text, media or [], when)
|
|
238
|
+
refs.validate_references(entry, report.media)
|
|
239
|
+
notes = f"{report.notes.rstrip()}\n\n{entry}" if (report.notes or "").strip() else entry
|
|
240
|
+
return store.update(report_id, notes=notes)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def set_status(store: Store, report_id: int, status: str) -> Report:
|
|
244
|
+
_require(store, report_id)
|
|
245
|
+
if status not in STATUSES:
|
|
246
|
+
raise ServiceError(
|
|
247
|
+
"invalid_status", f"invalid status {status!r}; choose one of: {', '.join(STATUSES)}"
|
|
248
|
+
)
|
|
249
|
+
return store.update(report_id, status=status)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def set_tags(store: Store, report_id: int, add=None, remove=None) -> list[str]:
|
|
253
|
+
report = _require(store, report_id)
|
|
254
|
+
tags = list(report.tags)
|
|
255
|
+
for tag in add or []:
|
|
256
|
+
if tag not in tags:
|
|
257
|
+
tags.append(tag)
|
|
258
|
+
tags = [t for t in tags if t not in set(remove or [])]
|
|
259
|
+
return store.set_tags(report_id, tags).tags
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def set_notes(store: Store, report_id: int, notes: str) -> Report:
|
|
263
|
+
report = _require(store, report_id)
|
|
264
|
+
refs.validate_references(notes, report.media)
|
|
265
|
+
return store.update(report_id, notes=notes)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
# --- media: relabel / remove --------------------------------------------------
|
|
269
|
+
|
|
270
|
+
def _referencing(report: Report, item: Media, only_label: bool = False) -> list[refs.Ref]:
|
|
271
|
+
found = refs.references_to(report.notes, item, report.media)
|
|
272
|
+
return [r for r in found if r.kind == "label"] if only_label else found
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _refuse(item: Media, found: list[refs.Ref]) -> ServiceError:
|
|
276
|
+
tokens = [r.token for r in found]
|
|
277
|
+
return ServiceError(
|
|
278
|
+
"referenced_image",
|
|
279
|
+
f"image #{item.idx} is referenced in notes by {', '.join(tokens)}; use --force to rewrite them",
|
|
280
|
+
{"tokens": tokens},
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def relabel_media(store: Store, report_id: int, ref: str, new_label: str, force: bool = False) -> int:
|
|
285
|
+
"""Returns the number of rewritten references."""
|
|
286
|
+
report = _require(store, report_id)
|
|
287
|
+
item = find_media(report.media, ref)
|
|
288
|
+
new_label = new_label or ""
|
|
289
|
+
if new_label:
|
|
290
|
+
others = [m for m in report.media if m.id != item.id]
|
|
291
|
+
_check_new_labels(others, [new_label])
|
|
292
|
+
found = _referencing(report, item, only_label=True) if item.label else []
|
|
293
|
+
if found and not force:
|
|
294
|
+
raise _refuse(item, found)
|
|
295
|
+
mapping = {item.label: f"@{new_label or item.idx}"} if item.label else {}
|
|
296
|
+
with store.transaction():
|
|
297
|
+
store.update_media_label(item.id, new_label or None)
|
|
298
|
+
notes, count = refs.rewrite_references_counted(report.notes, mapping)
|
|
299
|
+
if count:
|
|
300
|
+
store.update(report_id, notes=notes)
|
|
301
|
+
return count
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def remove_media(store: Store, report_id: int, ref: str, force: bool = False) -> int:
|
|
305
|
+
"""Delete an item and replace references to it with plain text; other indexes stay as
|
|
306
|
+
they are (an index is never reused). Returns the number of rewritten references."""
|
|
307
|
+
report = _require(store, report_id)
|
|
308
|
+
item = find_media(report.media, ref)
|
|
309
|
+
found = _referencing(report, item)
|
|
310
|
+
if found and not force:
|
|
311
|
+
raise _refuse(item, found)
|
|
312
|
+
mapping = {str(item.idx): refs.REMOVED_TEXT}
|
|
313
|
+
if item.label:
|
|
314
|
+
mapping[item.label] = refs.REMOVED_TEXT
|
|
315
|
+
with store.transaction():
|
|
316
|
+
store.delete_media(item.id)
|
|
317
|
+
notes, count = refs.rewrite_references_counted(report.notes, mapping)
|
|
318
|
+
if count:
|
|
319
|
+
store.update(report_id, notes=notes)
|
|
320
|
+
_delete_files(item)
|
|
321
|
+
return count
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _delete_files(item: Media) -> None:
|
|
325
|
+
paths = [item.path] if item.path else []
|
|
326
|
+
paths += [f.path for f in item.frames]
|
|
327
|
+
for rel in paths:
|
|
328
|
+
try:
|
|
329
|
+
os.unlink(resolve_data_path(rel))
|
|
330
|
+
except (ValueError, OSError):
|
|
331
|
+
pass # legacy absolute paths and missing files are left alone
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
# --- views --------------------------------------------------------------------
|
|
335
|
+
|
|
336
|
+
def report_segments(notes: str, media: list[Media]) -> list[dict]:
|
|
337
|
+
"""Notes as `{"text": ...}` / `{"ref": {...}}` segments (no HTML), for the dashboard."""
|
|
338
|
+
segments: list[dict] = []
|
|
339
|
+
pos = 0
|
|
340
|
+
text = notes or ""
|
|
341
|
+
|
|
342
|
+
def add_text(chunk: str) -> None:
|
|
343
|
+
if chunk:
|
|
344
|
+
if segments and "text" in segments[-1]:
|
|
345
|
+
segments[-1]["text"] += chunk
|
|
346
|
+
else:
|
|
347
|
+
segments.append({"text": chunk})
|
|
348
|
+
|
|
349
|
+
for ref in refs.parse_references(text):
|
|
350
|
+
if ref.kind == "escape":
|
|
351
|
+
add_text(text[pos : ref.start] + "@")
|
|
352
|
+
else:
|
|
353
|
+
item = refs.resolve(ref, media) if ref.kind in ("index", "label") else None
|
|
354
|
+
if item is None:
|
|
355
|
+
continue
|
|
356
|
+
add_text(text[pos : ref.start])
|
|
357
|
+
segments.append(
|
|
358
|
+
{"ref": {"token": ref.token, "index": item.idx, "media_id": item.id, "kind": item.kind}}
|
|
359
|
+
)
|
|
360
|
+
pos = ref.end
|
|
361
|
+
add_text(text[pos:])
|
|
362
|
+
return segments
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
@dataclass
|
|
366
|
+
class ReportView:
|
|
367
|
+
report: Report
|
|
368
|
+
media: list
|
|
369
|
+
references: list # [{"token", "index", "path"}]
|
|
370
|
+
segments: list
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def get_report_view(store: Store, report_id: int) -> ReportView:
|
|
374
|
+
report = _require(store, report_id)
|
|
375
|
+
resolved = []
|
|
376
|
+
for ref in refs.parse_references(report.notes):
|
|
377
|
+
item = refs.resolve(ref, report.media) if ref.kind in ("index", "label") else None
|
|
378
|
+
if item is not None:
|
|
379
|
+
resolved.append({"token": ref.token, "index": item.idx, "path": item.path})
|
|
380
|
+
return ReportView(report, report.media, resolved, report_segments(report.notes, report.media))
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def list_reports_query(
|
|
384
|
+
store: Store,
|
|
385
|
+
repo: Optional[str] = None,
|
|
386
|
+
tags: Optional[list] = None,
|
|
387
|
+
status: Optional[str] = None,
|
|
388
|
+
q: Optional[str] = None,
|
|
389
|
+
limit: int = 200,
|
|
390
|
+
offset: int = 0,
|
|
391
|
+
) -> tuple[int, list[dict]]:
|
|
392
|
+
"""Filtered, paged report summaries (newest first). Tags use AND semantics; `q` is a
|
|
393
|
+
case-insensitive substring match over title and notes."""
|
|
394
|
+
if status and status not in STATUSES:
|
|
395
|
+
raise ServiceError("bad_query", f"status must be one of {', '.join(STATUSES)}")
|
|
396
|
+
if not isinstance(limit, int) or not 1 <= limit <= 1000:
|
|
397
|
+
raise ServiceError("bad_query", "limit must be between 1 and 1000")
|
|
398
|
+
if not isinstance(offset, int) or offset < 0:
|
|
399
|
+
raise ServiceError("bad_query", "offset must be 0 or more")
|
|
400
|
+
needle = (q or "").lower()
|
|
401
|
+
wanted = set(tags or [])
|
|
402
|
+
matched = []
|
|
403
|
+
for report in store.list(repo=repo):
|
|
404
|
+
if status and report.status != status:
|
|
405
|
+
continue
|
|
406
|
+
if wanted and not wanted <= set(report.tags):
|
|
407
|
+
continue
|
|
408
|
+
if needle and needle not in report.title.lower() and needle not in (report.notes or "").lower():
|
|
409
|
+
continue
|
|
410
|
+
matched.append(report)
|
|
411
|
+
items = [
|
|
412
|
+
{
|
|
413
|
+
"id": r.id, "title": r.title, "status": r.status, "tags": r.tags, "repo": r.repo,
|
|
414
|
+
"created_at": r.created_at, "media_count": len(r.media),
|
|
415
|
+
}
|
|
416
|
+
for r in matched[offset : offset + limit]
|
|
417
|
+
]
|
|
418
|
+
return len(matched), items
|