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/store.py
ADDED
|
@@ -0,0 +1,485 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import sqlite3
|
|
5
|
+
from contextlib import contextmanager
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Optional
|
|
10
|
+
|
|
11
|
+
from .paths import absolute_stored_path, db_path, to_data_relative
|
|
12
|
+
|
|
13
|
+
SCHEMA_VERSION = 2
|
|
14
|
+
|
|
15
|
+
MEDIA_KINDS = ("image", "video", "frames", "animated")
|
|
16
|
+
|
|
17
|
+
_MIME = {
|
|
18
|
+
".png": "image/png",
|
|
19
|
+
".jpg": "image/jpeg",
|
|
20
|
+
".jpeg": "image/jpeg",
|
|
21
|
+
".gif": "image/gif",
|
|
22
|
+
".webp": "image/webp",
|
|
23
|
+
".bmp": "image/bmp",
|
|
24
|
+
".mp4": "video/mp4",
|
|
25
|
+
".webm": "video/webm",
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def guess_mime(path) -> str:
|
|
30
|
+
return _MIME.get(Path(str(path)).suffix.lower(), "application/octet-stream")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# Added in schema version 2 (created by _migrate for fresh and upgraded databases).
|
|
34
|
+
MEDIA_SCHEMA = """
|
|
35
|
+
CREATE TABLE IF NOT EXISTS media (
|
|
36
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
37
|
+
report_id INTEGER NOT NULL REFERENCES reports(id) ON DELETE CASCADE,
|
|
38
|
+
idx INTEGER NOT NULL CHECK (idx >= 1),
|
|
39
|
+
label TEXT,
|
|
40
|
+
kind TEXT NOT NULL CHECK (kind IN ('image','video','frames','animated')),
|
|
41
|
+
path TEXT,
|
|
42
|
+
mime TEXT NOT NULL,
|
|
43
|
+
size_bytes INTEGER NOT NULL DEFAULT 0,
|
|
44
|
+
source TEXT,
|
|
45
|
+
created_at TEXT NOT NULL,
|
|
46
|
+
UNIQUE (report_id, idx)
|
|
47
|
+
);
|
|
48
|
+
CREATE UNIQUE INDEX IF NOT EXISTS media_label_unique
|
|
49
|
+
ON media (report_id, lower(label)) WHERE label IS NOT NULL;
|
|
50
|
+
CREATE TABLE IF NOT EXISTS media_frames (
|
|
51
|
+
media_id INTEGER NOT NULL REFERENCES media(id) ON DELETE CASCADE,
|
|
52
|
+
frame_no INTEGER NOT NULL,
|
|
53
|
+
path TEXT NOT NULL,
|
|
54
|
+
size_bytes INTEGER NOT NULL DEFAULT 0,
|
|
55
|
+
PRIMARY KEY (media_id, frame_no)
|
|
56
|
+
);
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
# `reports` table in its version 1 shape; _migrate() brings every database to SCHEMA_VERSION
|
|
60
|
+
# (adding the media tables).
|
|
61
|
+
SCHEMA = """
|
|
62
|
+
CREATE TABLE IF NOT EXISTS reports (
|
|
63
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
64
|
+
created_at TEXT NOT NULL,
|
|
65
|
+
title TEXT NOT NULL,
|
|
66
|
+
body TEXT NOT NULL DEFAULT '',
|
|
67
|
+
notes TEXT NOT NULL DEFAULT '',
|
|
68
|
+
image_paths TEXT NOT NULL DEFAULT '[]',
|
|
69
|
+
tags TEXT NOT NULL DEFAULT '[]',
|
|
70
|
+
status TEXT NOT NULL DEFAULT 'open',
|
|
71
|
+
repo TEXT,
|
|
72
|
+
synced_refs TEXT NOT NULL DEFAULT '{}'
|
|
73
|
+
);
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
# Allowed statuses for `edit --status`; free-text legacy values are tolerated on read.
|
|
77
|
+
STATUSES = ("open", "in-progress", "resolved", "closed", "wontfix")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def normalize_tags(tags) -> list[str]:
|
|
81
|
+
"""Trim, drop empties, de-duplicate while preserving order and case."""
|
|
82
|
+
seen: dict[str, None] = {}
|
|
83
|
+
for tag in tags or []:
|
|
84
|
+
cleaned = str(tag).strip()
|
|
85
|
+
if cleaned and cleaned not in seen:
|
|
86
|
+
seen[cleaned] = None
|
|
87
|
+
return list(seen)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@dataclass
|
|
91
|
+
class MediaFrame:
|
|
92
|
+
media_id: int
|
|
93
|
+
frame_no: int
|
|
94
|
+
path: str
|
|
95
|
+
size_bytes: int = 0
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@dataclass
|
|
99
|
+
class Media:
|
|
100
|
+
id: int
|
|
101
|
+
report_id: int
|
|
102
|
+
idx: int
|
|
103
|
+
label: Optional[str]
|
|
104
|
+
kind: str
|
|
105
|
+
path: Optional[str]
|
|
106
|
+
mime: str
|
|
107
|
+
size_bytes: int
|
|
108
|
+
source: Optional[str]
|
|
109
|
+
created_at: str
|
|
110
|
+
frames: list[MediaFrame] = field(default_factory=list)
|
|
111
|
+
|
|
112
|
+
@property
|
|
113
|
+
def abs_path(self) -> Optional[str]:
|
|
114
|
+
return absolute_stored_path(self.path) if self.path else None
|
|
115
|
+
|
|
116
|
+
@classmethod
|
|
117
|
+
def from_row(cls, row: sqlite3.Row) -> "Media":
|
|
118
|
+
return cls(
|
|
119
|
+
id=row["id"],
|
|
120
|
+
report_id=row["report_id"],
|
|
121
|
+
idx=row["idx"],
|
|
122
|
+
label=row["label"],
|
|
123
|
+
kind=row["kind"],
|
|
124
|
+
path=row["path"],
|
|
125
|
+
mime=row["mime"],
|
|
126
|
+
size_bytes=row["size_bytes"],
|
|
127
|
+
source=row["source"],
|
|
128
|
+
created_at=row["created_at"],
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@dataclass
|
|
133
|
+
class Report:
|
|
134
|
+
id: Optional[int]
|
|
135
|
+
created_at: str
|
|
136
|
+
title: str
|
|
137
|
+
body: str = ""
|
|
138
|
+
notes: str = ""
|
|
139
|
+
image_paths: list[str] = field(default_factory=list)
|
|
140
|
+
tags: list[str] = field(default_factory=list)
|
|
141
|
+
status: str = "open"
|
|
142
|
+
repo: Optional[str] = None
|
|
143
|
+
synced_refs: dict[str, str] = field(default_factory=dict)
|
|
144
|
+
media: list[Media] = field(default_factory=list)
|
|
145
|
+
|
|
146
|
+
@classmethod
|
|
147
|
+
def from_row(cls, row: sqlite3.Row) -> "Report":
|
|
148
|
+
return cls(
|
|
149
|
+
id=row["id"],
|
|
150
|
+
created_at=row["created_at"],
|
|
151
|
+
title=row["title"],
|
|
152
|
+
body=row["body"] if "body" in row.keys() else "",
|
|
153
|
+
notes=row["notes"],
|
|
154
|
+
image_paths=[], # compatibility view, filled from media by Store._hydrate
|
|
155
|
+
tags=json.loads(row["tags"]),
|
|
156
|
+
status=row["status"],
|
|
157
|
+
repo=row["repo"] if "repo" in row.keys() else None,
|
|
158
|
+
synced_refs=json.loads(row["synced_refs"]),
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class Store:
|
|
163
|
+
def __init__(self, path: Optional[Path] = None):
|
|
164
|
+
self.path = path or db_path()
|
|
165
|
+
self._conn = sqlite3.connect(self.path)
|
|
166
|
+
self._conn.row_factory = sqlite3.Row
|
|
167
|
+
self._conn.execute("PRAGMA foreign_keys = ON")
|
|
168
|
+
self._conn.execute(SCHEMA)
|
|
169
|
+
self._depth = 0
|
|
170
|
+
self._migrate()
|
|
171
|
+
self._conn.commit()
|
|
172
|
+
|
|
173
|
+
# --- transactions ----------------------------------------------------------
|
|
174
|
+
|
|
175
|
+
def _commit(self) -> None:
|
|
176
|
+
if self._depth == 0:
|
|
177
|
+
self._conn.commit()
|
|
178
|
+
|
|
179
|
+
@contextmanager
|
|
180
|
+
def transaction(self):
|
|
181
|
+
"""Group several store calls into one atomic unit (re-entrant)."""
|
|
182
|
+
if self._depth == 0:
|
|
183
|
+
self._conn.commit()
|
|
184
|
+
self._conn.execute("BEGIN")
|
|
185
|
+
self._depth += 1
|
|
186
|
+
try:
|
|
187
|
+
yield self
|
|
188
|
+
except BaseException:
|
|
189
|
+
self._depth -= 1
|
|
190
|
+
if self._depth == 0:
|
|
191
|
+
self._conn.execute("ROLLBACK")
|
|
192
|
+
raise
|
|
193
|
+
else:
|
|
194
|
+
self._depth -= 1
|
|
195
|
+
if self._depth == 0:
|
|
196
|
+
self._conn.execute("COMMIT")
|
|
197
|
+
|
|
198
|
+
# --- migration -------------------------------------------------------------
|
|
199
|
+
|
|
200
|
+
def _migrate(self) -> None:
|
|
201
|
+
"""PRAGMA user_version-based migration to SCHEMA_VERSION, idempotent and row-preserving."""
|
|
202
|
+
version = self._conn.execute("PRAGMA user_version").fetchone()[0]
|
|
203
|
+
if version >= SCHEMA_VERSION:
|
|
204
|
+
return
|
|
205
|
+
columns = {r["name"] for r in self._conn.execute("PRAGMA table_info(reports)")}
|
|
206
|
+
try:
|
|
207
|
+
self._conn.execute("BEGIN")
|
|
208
|
+
if version < 1:
|
|
209
|
+
if "repo" not in columns:
|
|
210
|
+
self._conn.execute("ALTER TABLE reports ADD COLUMN repo TEXT")
|
|
211
|
+
if "body" not in columns:
|
|
212
|
+
self._conn.execute("ALTER TABLE reports ADD COLUMN body TEXT NOT NULL DEFAULT ''")
|
|
213
|
+
if version < 2:
|
|
214
|
+
self._migrate_media()
|
|
215
|
+
self._conn.execute(f"PRAGMA user_version = {SCHEMA_VERSION}")
|
|
216
|
+
self._conn.execute("COMMIT")
|
|
217
|
+
except Exception:
|
|
218
|
+
self._conn.execute("ROLLBACK")
|
|
219
|
+
raise
|
|
220
|
+
|
|
221
|
+
def _migrate_media(self) -> None:
|
|
222
|
+
"""v1 -> v2: create the media tables and copy each legacy image_paths entry into them.
|
|
223
|
+
The image_paths column is left untouched."""
|
|
224
|
+
columns = {r["name"] for r in self._conn.execute("PRAGMA table_info(reports)")}
|
|
225
|
+
if "media_seq" not in columns: # high-water mark: media indexes are never reused
|
|
226
|
+
self._conn.execute("ALTER TABLE reports ADD COLUMN media_seq INTEGER NOT NULL DEFAULT 0")
|
|
227
|
+
for statement in MEDIA_SCHEMA.split(";"):
|
|
228
|
+
if statement.strip():
|
|
229
|
+
self._conn.execute(statement)
|
|
230
|
+
rows = self._conn.execute("SELECT id, created_at, image_paths FROM reports").fetchall()
|
|
231
|
+
for row in rows:
|
|
232
|
+
try:
|
|
233
|
+
paths = json.loads(row["image_paths"] or "[]")
|
|
234
|
+
except ValueError:
|
|
235
|
+
paths = []
|
|
236
|
+
for position, legacy in enumerate(paths):
|
|
237
|
+
legacy = str(legacy)
|
|
238
|
+
try:
|
|
239
|
+
size = Path(legacy).stat().st_size
|
|
240
|
+
except OSError:
|
|
241
|
+
size = 0
|
|
242
|
+
self._conn.execute(
|
|
243
|
+
"INSERT INTO media (report_id, idx, kind, path, mime, size_bytes, source, created_at) "
|
|
244
|
+
"VALUES (?, ?, 'image', ?, ?, ?, 'legacy', ?)",
|
|
245
|
+
(row["id"], position + 1, to_data_relative(legacy), guess_mime(legacy), size, row["created_at"]),
|
|
246
|
+
)
|
|
247
|
+
if paths:
|
|
248
|
+
self._conn.execute("UPDATE reports SET media_seq = ? WHERE id = ?", (len(paths), row["id"]))
|
|
249
|
+
|
|
250
|
+
def close(self) -> None:
|
|
251
|
+
self._conn.close()
|
|
252
|
+
|
|
253
|
+
def __enter__(self) -> "Store":
|
|
254
|
+
return self
|
|
255
|
+
|
|
256
|
+
def __exit__(self, *exc) -> None:
|
|
257
|
+
self.close()
|
|
258
|
+
|
|
259
|
+
# --- reports ---------------------------------------------------------------
|
|
260
|
+
|
|
261
|
+
def _hydrate(self, row: sqlite3.Row) -> Report:
|
|
262
|
+
report = Report.from_row(row)
|
|
263
|
+
report.media = self.list_media(report.id)
|
|
264
|
+
report.image_paths = [m.abs_path for m in report.media if m.kind == "image" and m.path]
|
|
265
|
+
return report
|
|
266
|
+
|
|
267
|
+
def add(
|
|
268
|
+
self,
|
|
269
|
+
title: str,
|
|
270
|
+
notes: str = "",
|
|
271
|
+
image_paths: Optional[list[str]] = None,
|
|
272
|
+
tags: Optional[list[str]] = None,
|
|
273
|
+
repo: Optional[str] = None,
|
|
274
|
+
body: str = "",
|
|
275
|
+
status: str = "open",
|
|
276
|
+
synced_refs: Optional[dict[str, str]] = None,
|
|
277
|
+
) -> Report:
|
|
278
|
+
if not (title or "").strip():
|
|
279
|
+
raise ValueError("title must not be empty")
|
|
280
|
+
created_at = datetime.now(timezone.utc).isoformat()
|
|
281
|
+
with self.transaction():
|
|
282
|
+
cur = self._conn.execute(
|
|
283
|
+
"INSERT INTO reports "
|
|
284
|
+
"(created_at, title, body, notes, image_paths, tags, status, repo, synced_refs) "
|
|
285
|
+
"VALUES (?, ?, ?, ?, '[]', ?, ?, ?, ?)",
|
|
286
|
+
(
|
|
287
|
+
created_at,
|
|
288
|
+
title,
|
|
289
|
+
body,
|
|
290
|
+
notes,
|
|
291
|
+
json.dumps(normalize_tags(tags)),
|
|
292
|
+
status,
|
|
293
|
+
repo,
|
|
294
|
+
json.dumps(synced_refs or {}),
|
|
295
|
+
),
|
|
296
|
+
)
|
|
297
|
+
report_id = cur.lastrowid
|
|
298
|
+
for path in image_paths or []:
|
|
299
|
+
self.insert_media(report_id, kind="image", path=str(path), source="legacy")
|
|
300
|
+
return self.get(report_id)
|
|
301
|
+
|
|
302
|
+
def get(self, report_id: int) -> Optional[Report]:
|
|
303
|
+
row = self._conn.execute(
|
|
304
|
+
"SELECT * FROM reports WHERE id = ?", (report_id,)
|
|
305
|
+
).fetchone()
|
|
306
|
+
return self._hydrate(row) if row else None
|
|
307
|
+
|
|
308
|
+
def list(self, repo: Optional[str] = None) -> list[Report]:
|
|
309
|
+
if repo is None:
|
|
310
|
+
rows = self._conn.execute("SELECT * FROM reports ORDER BY id DESC").fetchall()
|
|
311
|
+
else:
|
|
312
|
+
rows = self._conn.execute(
|
|
313
|
+
"SELECT * FROM reports WHERE repo = ? ORDER BY id DESC", (repo,)
|
|
314
|
+
).fetchall()
|
|
315
|
+
return [self._hydrate(row) for row in rows]
|
|
316
|
+
|
|
317
|
+
def update(
|
|
318
|
+
self,
|
|
319
|
+
report_id: int,
|
|
320
|
+
*,
|
|
321
|
+
title: Optional[str] = None,
|
|
322
|
+
notes: Optional[str] = None,
|
|
323
|
+
status: Optional[str] = None,
|
|
324
|
+
body: Optional[str] = None,
|
|
325
|
+
) -> Optional[Report]:
|
|
326
|
+
fields: dict[str, str] = {}
|
|
327
|
+
if title is not None:
|
|
328
|
+
if not title.strip():
|
|
329
|
+
raise ValueError("title must not be empty")
|
|
330
|
+
fields["title"] = title
|
|
331
|
+
if notes is not None:
|
|
332
|
+
fields["notes"] = notes
|
|
333
|
+
if body is not None:
|
|
334
|
+
fields["body"] = body
|
|
335
|
+
if status is not None:
|
|
336
|
+
if status not in STATUSES:
|
|
337
|
+
raise ValueError(
|
|
338
|
+
f"invalid status {status!r}; choose one of: {', '.join(STATUSES)}"
|
|
339
|
+
)
|
|
340
|
+
fields["status"] = status
|
|
341
|
+
if not fields:
|
|
342
|
+
return self.get(report_id)
|
|
343
|
+
assignments = ", ".join(f"{k} = ?" for k in fields)
|
|
344
|
+
self._conn.execute(
|
|
345
|
+
f"UPDATE reports SET {assignments} WHERE id = ?",
|
|
346
|
+
(*fields.values(), report_id),
|
|
347
|
+
)
|
|
348
|
+
self._commit()
|
|
349
|
+
return self.get(report_id)
|
|
350
|
+
|
|
351
|
+
def set_tags(self, report_id: int, tags: list[str]) -> Optional[Report]:
|
|
352
|
+
self._conn.execute(
|
|
353
|
+
"UPDATE reports SET tags = ? WHERE id = ?",
|
|
354
|
+
(json.dumps(normalize_tags(tags)), report_id),
|
|
355
|
+
)
|
|
356
|
+
self._commit()
|
|
357
|
+
return self.get(report_id)
|
|
358
|
+
|
|
359
|
+
def add_image(self, report_id: int, path: str, source: Optional[str] = None) -> Optional[Report]:
|
|
360
|
+
if self.get(report_id) is None:
|
|
361
|
+
return None
|
|
362
|
+
self.insert_media(report_id, kind="image", path=str(path), source=source)
|
|
363
|
+
return self.get(report_id)
|
|
364
|
+
|
|
365
|
+
def set_ref(self, report_id: int, key: str, value: str) -> Optional[Report]:
|
|
366
|
+
report = self.get(report_id)
|
|
367
|
+
if report is None:
|
|
368
|
+
return None
|
|
369
|
+
refs = dict(report.synced_refs)
|
|
370
|
+
refs[key] = value
|
|
371
|
+
self._conn.execute(
|
|
372
|
+
"UPDATE reports SET synced_refs = ? WHERE id = ?",
|
|
373
|
+
(json.dumps(refs), report_id),
|
|
374
|
+
)
|
|
375
|
+
self._commit()
|
|
376
|
+
return self.get(report_id)
|
|
377
|
+
|
|
378
|
+
def move_repo(self, old_key: str, new_key: str, old_tag: Optional[str], new_tag: Optional[str]) -> int:
|
|
379
|
+
"""Re-home every report stored under `old_key`: set its repo to `new_key` and, when the
|
|
380
|
+
tag changed, swap `old_tag` for `new_tag` in its tags. Returns the number of reports."""
|
|
381
|
+
with self.transaction():
|
|
382
|
+
reports = self.list(repo=old_key)
|
|
383
|
+
for report in reports:
|
|
384
|
+
tags = list(report.tags)
|
|
385
|
+
if old_tag and new_tag and old_tag != new_tag and old_tag in tags:
|
|
386
|
+
tags[tags.index(old_tag)] = new_tag
|
|
387
|
+
self._conn.execute(
|
|
388
|
+
"UPDATE reports SET repo = ?, tags = ? WHERE id = ?",
|
|
389
|
+
(new_key, json.dumps(normalize_tags(tags)), report.id),
|
|
390
|
+
)
|
|
391
|
+
return len(reports)
|
|
392
|
+
|
|
393
|
+
def find_by_ref(self, key: str, value: str) -> Optional[Report]:
|
|
394
|
+
for report in self.list():
|
|
395
|
+
if report.synced_refs.get(key) == value:
|
|
396
|
+
return report
|
|
397
|
+
return None
|
|
398
|
+
|
|
399
|
+
# --- media -----------------------------------------------------------------
|
|
400
|
+
|
|
401
|
+
def _load_media(self, row: sqlite3.Row) -> Media:
|
|
402
|
+
media = Media.from_row(row)
|
|
403
|
+
if media.kind == "frames":
|
|
404
|
+
media.frames = [
|
|
405
|
+
MediaFrame(r["media_id"], r["frame_no"], r["path"], r["size_bytes"])
|
|
406
|
+
for r in self._conn.execute(
|
|
407
|
+
"SELECT * FROM media_frames WHERE media_id = ? ORDER BY frame_no", (media.id,)
|
|
408
|
+
)
|
|
409
|
+
]
|
|
410
|
+
return media
|
|
411
|
+
|
|
412
|
+
def list_media(self, report_id: int) -> list[Media]:
|
|
413
|
+
rows = self._conn.execute(
|
|
414
|
+
"SELECT * FROM media WHERE report_id = ? ORDER BY idx", (report_id,)
|
|
415
|
+
).fetchall()
|
|
416
|
+
return [self._load_media(r) for r in rows]
|
|
417
|
+
|
|
418
|
+
def get_media(self, media_id: int) -> Optional[Media]:
|
|
419
|
+
row = self._conn.execute("SELECT * FROM media WHERE id = ?", (media_id,)).fetchone()
|
|
420
|
+
return self._load_media(row) if row else None
|
|
421
|
+
|
|
422
|
+
def insert_media(
|
|
423
|
+
self,
|
|
424
|
+
report_id: int,
|
|
425
|
+
*,
|
|
426
|
+
kind: str = "image",
|
|
427
|
+
path: Optional[str] = None,
|
|
428
|
+
label: Optional[str] = None,
|
|
429
|
+
mime: Optional[str] = None,
|
|
430
|
+
size_bytes: Optional[int] = None,
|
|
431
|
+
source: Optional[str] = None,
|
|
432
|
+
frames: Optional[list[tuple[str, int]]] = None,
|
|
433
|
+
) -> Media:
|
|
434
|
+
"""Insert a media item at the next never-used index (computed inside the transaction)."""
|
|
435
|
+
if kind not in MEDIA_KINDS:
|
|
436
|
+
raise ValueError(f"invalid media kind {kind!r}")
|
|
437
|
+
stored = to_data_relative(path) if path else None
|
|
438
|
+
if size_bytes is None:
|
|
439
|
+
try:
|
|
440
|
+
size_bytes = Path(path).stat().st_size if path else 0
|
|
441
|
+
except OSError:
|
|
442
|
+
size_bytes = 0
|
|
443
|
+
if mime is None:
|
|
444
|
+
mime = guess_mime(path) if path else "image/png"
|
|
445
|
+
created_at = datetime.now(timezone.utc).isoformat()
|
|
446
|
+
with self.transaction():
|
|
447
|
+
idx = self._next_index(report_id)
|
|
448
|
+
cur = self._conn.execute(
|
|
449
|
+
"INSERT INTO media (report_id, idx, label, kind, path, mime, size_bytes, source, created_at) "
|
|
450
|
+
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
451
|
+
(report_id, idx, label, kind, stored, mime, size_bytes, source, created_at),
|
|
452
|
+
)
|
|
453
|
+
media_id = cur.lastrowid
|
|
454
|
+
if frames:
|
|
455
|
+
self.insert_frames(media_id, frames)
|
|
456
|
+
return self.get_media(media_id)
|
|
457
|
+
|
|
458
|
+
def _next_index(self, report_id: int) -> int:
|
|
459
|
+
"""Next index: above both the current maximum and the report's high-water mark, so an
|
|
460
|
+
index is never reused, even after the item that had it was removed."""
|
|
461
|
+
row = self._conn.execute(
|
|
462
|
+
"SELECT COALESCE(MAX(idx), 0), (SELECT media_seq FROM reports WHERE id = ?) "
|
|
463
|
+
"FROM media WHERE report_id = ?", (report_id, report_id),
|
|
464
|
+
).fetchone()
|
|
465
|
+
idx = max(row[0], row[1] or 0) + 1
|
|
466
|
+
self._conn.execute("UPDATE reports SET media_seq = ? WHERE id = ?", (idx, report_id))
|
|
467
|
+
return idx
|
|
468
|
+
|
|
469
|
+
def update_media_label(self, media_id: int, label: Optional[str]) -> None:
|
|
470
|
+
self._conn.execute("UPDATE media SET label = ? WHERE id = ?", (label, media_id))
|
|
471
|
+
self._commit()
|
|
472
|
+
|
|
473
|
+
def delete_media(self, media_id: int) -> None:
|
|
474
|
+
self._conn.execute("DELETE FROM media_frames WHERE media_id = ?", (media_id,))
|
|
475
|
+
self._conn.execute("DELETE FROM media WHERE id = ?", (media_id,))
|
|
476
|
+
self._commit()
|
|
477
|
+
|
|
478
|
+
def insert_frames(self, media_id: int, frames: list[tuple[str, int]]) -> None:
|
|
479
|
+
"""frames: [(path, size_bytes)] in order; frame_no is 1-based."""
|
|
480
|
+
for number, (path, size) in enumerate(frames, start=1):
|
|
481
|
+
self._conn.execute(
|
|
482
|
+
"INSERT INTO media_frames (media_id, frame_no, path, size_bytes) VALUES (?, ?, ?, ?)",
|
|
483
|
+
(media_id, number, to_data_relative(path), size),
|
|
484
|
+
)
|
|
485
|
+
self._commit()
|