bugcap 0.3.0__tar.gz → 0.5.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {bugcap-0.3.0/src/bugcap.egg-info → bugcap-0.5.0}/PKG-INFO +2 -2
- {bugcap-0.3.0 → bugcap-0.5.0}/README.md +1 -1
- {bugcap-0.3.0 → bugcap-0.5.0}/pyproject.toml +1 -1
- bugcap-0.5.0/src/bugcap/__init__.py +1 -0
- {bugcap-0.3.0 → bugcap-0.5.0}/src/bugcap/cli.py +13 -6
- bugcap-0.5.0/src/bugcap/drafts.py +134 -0
- {bugcap-0.3.0 → bugcap-0.5.0}/src/bugcap/ingest.py +36 -4
- bugcap-0.5.0/src/bugcap/live.py +564 -0
- bugcap-0.5.0/src/bugcap/live_session.py +300 -0
- {bugcap-0.3.0 → bugcap-0.5.0}/src/bugcap/service.py +11 -3
- {bugcap-0.3.0 → bugcap-0.5.0}/src/bugcap/store.py +8 -0
- {bugcap-0.3.0 → bugcap-0.5.0/src/bugcap.egg-info}/PKG-INFO +2 -2
- {bugcap-0.3.0 → bugcap-0.5.0}/tests/test_cli_attach.py +74 -0
- bugcap-0.5.0/tests/test_drafts.py +77 -0
- bugcap-0.5.0/tests/test_live_session.py +289 -0
- bugcap-0.3.0/src/bugcap/__init__.py +0 -1
- bugcap-0.3.0/src/bugcap/drafts.py +0 -83
- bugcap-0.3.0/src/bugcap/live.py +0 -350
- bugcap-0.3.0/src/bugcap/live_session.py +0 -125
- bugcap-0.3.0/tests/test_drafts.py +0 -43
- bugcap-0.3.0/tests/test_live_session.py +0 -125
- {bugcap-0.3.0 → bugcap-0.5.0}/LICENSE +0 -0
- {bugcap-0.3.0 → bugcap-0.5.0}/setup.cfg +0 -0
- {bugcap-0.3.0 → bugcap-0.5.0}/src/bugcap/agent_api.py +0 -0
- {bugcap-0.3.0 → bugcap-0.5.0}/src/bugcap/backends.py +0 -0
- {bugcap-0.3.0 → bugcap-0.5.0}/src/bugcap/capture.py +0 -0
- {bugcap-0.3.0 → bugcap-0.5.0}/src/bugcap/config.py +0 -0
- {bugcap-0.3.0 → bugcap-0.5.0}/src/bugcap/dashboard/__init__.py +0 -0
- {bugcap-0.3.0 → bugcap-0.5.0}/src/bugcap/dashboard/api.py +0 -0
- {bugcap-0.3.0 → bugcap-0.5.0}/src/bugcap/dashboard/media.py +0 -0
- {bugcap-0.3.0 → bugcap-0.5.0}/src/bugcap/dashboard/server.py +0 -0
- {bugcap-0.3.0 → bugcap-0.5.0}/src/bugcap/dashboard/static/app.css +0 -0
- {bugcap-0.3.0 → bugcap-0.5.0}/src/bugcap/dashboard/static/app.js +0 -0
- {bugcap-0.3.0 → bugcap-0.5.0}/src/bugcap/dashboard/static/index.html +0 -0
- {bugcap-0.3.0 → bugcap-0.5.0}/src/bugcap/errors.py +0 -0
- {bugcap-0.3.0 → bugcap-0.5.0}/src/bugcap/ghcli.py +0 -0
- {bugcap-0.3.0 → bugcap-0.5.0}/src/bugcap/logs.py +0 -0
- {bugcap-0.3.0 → bugcap-0.5.0}/src/bugcap/mcp_server.py +0 -0
- {bugcap-0.3.0 → bugcap-0.5.0}/src/bugcap/paths.py +0 -0
- {bugcap-0.3.0 → bugcap-0.5.0}/src/bugcap/recorder.py +0 -0
- {bugcap-0.3.0 → bugcap-0.5.0}/src/bugcap/refs.py +0 -0
- {bugcap-0.3.0 → bugcap-0.5.0}/src/bugcap/repo.py +0 -0
- {bugcap-0.3.0 → bugcap-0.5.0}/src/bugcap/sync.py +0 -0
- {bugcap-0.3.0 → bugcap-0.5.0}/src/bugcap/tomlio.py +0 -0
- {bugcap-0.3.0 → bugcap-0.5.0}/src/bugcap/validation.py +0 -0
- {bugcap-0.3.0 → bugcap-0.5.0}/src/bugcap.egg-info/SOURCES.txt +0 -0
- {bugcap-0.3.0 → bugcap-0.5.0}/src/bugcap.egg-info/dependency_links.txt +0 -0
- {bugcap-0.3.0 → bugcap-0.5.0}/src/bugcap.egg-info/entry_points.txt +0 -0
- {bugcap-0.3.0 → bugcap-0.5.0}/src/bugcap.egg-info/requires.txt +0 -0
- {bugcap-0.3.0 → bugcap-0.5.0}/src/bugcap.egg-info/top_level.txt +0 -0
- {bugcap-0.3.0 → bugcap-0.5.0}/tests/test_agent_api.py +0 -0
- {bugcap-0.3.0 → bugcap-0.5.0}/tests/test_backends.py +0 -0
- {bugcap-0.3.0 → bugcap-0.5.0}/tests/test_capture.py +0 -0
- {bugcap-0.3.0 → bugcap-0.5.0}/tests/test_cli_dashboard.py +0 -0
- {bugcap-0.3.0 → bugcap-0.5.0}/tests/test_cli_init_capture_list.py +0 -0
- {bugcap-0.3.0 → bugcap-0.5.0}/tests/test_cli_live_headless.py +0 -0
- {bugcap-0.3.0 → bugcap-0.5.0}/tests/test_cli_media.py +0 -0
- {bugcap-0.3.0 → bugcap-0.5.0}/tests/test_cli_no_image_delete.py +0 -0
- {bugcap-0.3.0 → bugcap-0.5.0}/tests/test_cli_record.py +0 -0
- {bugcap-0.3.0 → bugcap-0.5.0}/tests/test_cli_setup.py +0 -0
- {bugcap-0.3.0 → bugcap-0.5.0}/tests/test_cli_triage.py +0 -0
- {bugcap-0.3.0 → bugcap-0.5.0}/tests/test_dashboard_api.py +0 -0
- {bugcap-0.3.0 → bugcap-0.5.0}/tests/test_dashboard_media.py +0 -0
- {bugcap-0.3.0 → bugcap-0.5.0}/tests/test_dashboard_mutations.py +0 -0
- {bugcap-0.3.0 → bugcap-0.5.0}/tests/test_dashboard_security.py +0 -0
- {bugcap-0.3.0 → bugcap-0.5.0}/tests/test_dashboard_ui_static.py +0 -0
- {bugcap-0.3.0 → bugcap-0.5.0}/tests/test_ghcli.py +0 -0
- {bugcap-0.3.0 → bugcap-0.5.0}/tests/test_github_media_sync.py +0 -0
- {bugcap-0.3.0 → bugcap-0.5.0}/tests/test_github_pull.py +0 -0
- {bugcap-0.3.0 → bugcap-0.5.0}/tests/test_github_sync.py +0 -0
- {bugcap-0.3.0 → bugcap-0.5.0}/tests/test_image_refs.py +0 -0
- {bugcap-0.3.0 → bugcap-0.5.0}/tests/test_ingest.py +0 -0
- {bugcap-0.3.0 → bugcap-0.5.0}/tests/test_ingest_url.py +0 -0
- {bugcap-0.3.0 → bugcap-0.5.0}/tests/test_mcp.py +0 -0
- {bugcap-0.3.0 → bugcap-0.5.0}/tests/test_mcp_logging.py +0 -0
- {bugcap-0.3.0 → bugcap-0.5.0}/tests/test_paths.py +0 -0
- {bugcap-0.3.0 → bugcap-0.5.0}/tests/test_recorder_argv.py +0 -0
- {bugcap-0.3.0 → bugcap-0.5.0}/tests/test_recorder_devices.py +0 -0
- {bugcap-0.3.0 → bugcap-0.5.0}/tests/test_recorder_media_real.py +0 -0
- {bugcap-0.3.0 → bugcap-0.5.0}/tests/test_recorder_run.py +0 -0
- {bugcap-0.3.0 → bugcap-0.5.0}/tests/test_refs.py +0 -0
- {bugcap-0.3.0 → bugcap-0.5.0}/tests/test_refs_validate_rewrite.py +0 -0
- {bugcap-0.3.0 → bugcap-0.5.0}/tests/test_regression_output.py +0 -0
- {bugcap-0.3.0 → bugcap-0.5.0}/tests/test_repo_config.py +0 -0
- {bugcap-0.3.0 → bugcap-0.5.0}/tests/test_repo_values.py +0 -0
- {bugcap-0.3.0 → bugcap-0.5.0}/tests/test_service_media.py +0 -0
- {bugcap-0.3.0 → bugcap-0.5.0}/tests/test_service_refs.py +0 -0
- {bugcap-0.3.0 → bugcap-0.5.0}/tests/test_store_migration.py +0 -0
- {bugcap-0.3.0 → bugcap-0.5.0}/tests/test_sync_consent.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: bugcap
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.0
|
|
4
4
|
Summary: Local-first, agent-readable bug capture: annotated screenshots + notes, stored locally, synced to trackers on your own schedule.
|
|
5
5
|
Author: Konrad Tagnon Amen ALAHASSA
|
|
6
6
|
License: MIT
|
|
@@ -63,7 +63,7 @@ bugcap keeps the whole loop in one place:
|
|
|
63
63
|
| 🔁 **Sync when ready** | Push to a GitHub issue with the usual inline image, plus a plain copy agents can read. |
|
|
64
64
|
| 🎞️ **Record what happens** | Short screen recordings as animated GIF, video or keyframes, with size caps. |
|
|
65
65
|
| 🖥️ **Triage in a browser** | A local dashboard on `127.0.0.1` to browse, filter and set statuses. |
|
|
66
|
-
| 🪟 **Live mode** | An always-on-top window
|
|
66
|
+
| 🪟 **Live mode** | An always-on-top window (top-right) to capture or record, stage several items, then file a new bug, add to an existing one, or log a note-only bug. |
|
|
67
67
|
|
|
68
68
|
## Quick start
|
|
69
69
|
|
|
@@ -47,7 +47,7 @@ bugcap keeps the whole loop in one place:
|
|
|
47
47
|
| 🔁 **Sync when ready** | Push to a GitHub issue with the usual inline image, plus a plain copy agents can read. |
|
|
48
48
|
| 🎞️ **Record what happens** | Short screen recordings as animated GIF, video or keyframes, with size caps. |
|
|
49
49
|
| 🖥️ **Triage in a browser** | A local dashboard on `127.0.0.1` to browse, filter and set statuses. |
|
|
50
|
-
| 🪟 **Live mode** | An always-on-top window
|
|
50
|
+
| 🪟 **Live mode** | An always-on-top window (top-right) to capture or record, stage several items, then file a new bug, add to an existing one, or log a note-only bug. |
|
|
51
51
|
|
|
52
52
|
## Quick start
|
|
53
53
|
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "bugcap"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.5.0"
|
|
8
8
|
description = "Local-first, agent-readable bug capture: annotated screenshots + notes, stored locally, synced to trackers on your own schedule."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.5.0"
|
|
@@ -152,12 +152,8 @@ def cmd_list(args: argparse.Namespace) -> int:
|
|
|
152
152
|
return 0
|
|
153
153
|
|
|
154
154
|
|
|
155
|
-
def
|
|
156
|
-
|
|
157
|
-
report = resolve_report(store, args.id)
|
|
158
|
-
if report is None:
|
|
159
|
-
return 1
|
|
160
|
-
|
|
155
|
+
def print_report(report) -> None:
|
|
156
|
+
"""The multi-line details of one report, as `bugcap show` prints them."""
|
|
161
157
|
print(f"#{report.id} {report.title}")
|
|
162
158
|
print(f" created_at: {report.created_at}")
|
|
163
159
|
print(f" status: {report.status}")
|
|
@@ -181,6 +177,15 @@ def cmd_show(args: argparse.Namespace) -> int:
|
|
|
181
177
|
print(f" - {tracker}: {ref}")
|
|
182
178
|
else:
|
|
183
179
|
print(" (not synced to any tracker yet)")
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def cmd_show(args: argparse.Namespace) -> int:
|
|
183
|
+
with Store() as store:
|
|
184
|
+
report = resolve_report(store, args.id)
|
|
185
|
+
if report is None:
|
|
186
|
+
return 1
|
|
187
|
+
|
|
188
|
+
print_report(report)
|
|
184
189
|
return 0
|
|
185
190
|
|
|
186
191
|
|
|
@@ -574,6 +579,8 @@ def cmd_attach(args: argparse.Namespace) -> int:
|
|
|
574
579
|
print(f"Attached {shot_path} to report #{args.id}")
|
|
575
580
|
elif note and added:
|
|
576
581
|
print(f"Added note to report #{args.id}")
|
|
582
|
+
print()
|
|
583
|
+
print_report(current)
|
|
577
584
|
return 1 if rejected or (note and not added) else 0
|
|
578
585
|
|
|
579
586
|
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""Live-mode drafts: unsaved staged media (any number of images / recordings, or none) plus
|
|
2
|
+
whatever was typed, kept on disk until it is saved as a report or discarded. One
|
|
3
|
+
`<uuid>.json` sidecar per draft; its media files sit beside it as `<uuid>-*`. A draft made by
|
|
4
|
+
an older version is just `<uuid>.png` + `<uuid>.json` and still loads."""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import shutil
|
|
9
|
+
import sys
|
|
10
|
+
import uuid
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from datetime import datetime, timezone
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Optional
|
|
15
|
+
|
|
16
|
+
from .paths import drafts_dir
|
|
17
|
+
|
|
18
|
+
FIELDS = ("title", "notes", "tags", "status")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class Draft:
|
|
23
|
+
id: str
|
|
24
|
+
created_at: str
|
|
25
|
+
repo: Optional[str]
|
|
26
|
+
title: str = ""
|
|
27
|
+
notes: str = ""
|
|
28
|
+
tags: list = None # type: ignore[assignment]
|
|
29
|
+
status: str = "open"
|
|
30
|
+
# [{"kind", "path" (absolute or None), "mime", "size_bytes", "frames": [(path, size)]}]
|
|
31
|
+
items: list = field(default_factory=list)
|
|
32
|
+
|
|
33
|
+
def fields(self) -> dict:
|
|
34
|
+
return {"title": self.title, "notes": self.notes, "tags": list(self.tags or []), "status": self.status}
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def png_path(self) -> Optional[Path]:
|
|
38
|
+
"""The first stored file (the screenshot of a single-image draft), if any."""
|
|
39
|
+
for item in self.items:
|
|
40
|
+
if item["path"]:
|
|
41
|
+
return Path(item["path"])
|
|
42
|
+
return None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _move(source, target: Path) -> Path:
|
|
46
|
+
shutil.move(str(source), target)
|
|
47
|
+
return target
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def save_draft(repo: Optional[str], png_path, fields: dict, items: Optional[list] = None) -> Draft:
|
|
51
|
+
"""Move the media into drafts/ and write the sidecar. `items` are dicts shaped like
|
|
52
|
+
`Draft.items`; for a plain screenshot pass `png_path` instead."""
|
|
53
|
+
if items is None:
|
|
54
|
+
items = [{"kind": "image", "path": str(png_path), "mime": "image/png", "size_bytes": 0, "frames": []}] if png_path else []
|
|
55
|
+
draft_id = str(uuid.uuid4())
|
|
56
|
+
directory = drafts_dir()
|
|
57
|
+
listed = []
|
|
58
|
+
for n, item in enumerate(items, start=1):
|
|
59
|
+
path = item.get("path")
|
|
60
|
+
new_path = _move(path, directory / f"{draft_id}-{n}{Path(path).suffix}") if path else None
|
|
61
|
+
frames = []
|
|
62
|
+
for m, (frame_path, size) in enumerate(item.get("frames") or [], start=1):
|
|
63
|
+
frames.append([_move(frame_path, directory / f"{draft_id}-{n}-f{m:03d}.png").name, size])
|
|
64
|
+
listed.append({
|
|
65
|
+
"kind": item["kind"], "file": new_path.name if new_path else None,
|
|
66
|
+
"mime": item.get("mime", "image/png"), "size_bytes": item.get("size_bytes", 0), "frames": frames,
|
|
67
|
+
})
|
|
68
|
+
meta = {
|
|
69
|
+
"created_at": datetime.now(timezone.utc).isoformat(),
|
|
70
|
+
"repo": repo,
|
|
71
|
+
"title": fields.get("title", ""),
|
|
72
|
+
"notes": fields.get("notes", ""),
|
|
73
|
+
"tags": list(fields.get("tags") or []),
|
|
74
|
+
"status": fields.get("status", "open"),
|
|
75
|
+
"media": listed,
|
|
76
|
+
}
|
|
77
|
+
(directory / f"{draft_id}.json").write_text(json.dumps(meta, indent=2))
|
|
78
|
+
return load_draft(draft_id) # type: ignore[return-value]
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _items_from_meta(directory: Path, draft_id: str, meta: dict) -> list:
|
|
82
|
+
if "media" not in meta: # legacy: one screenshot
|
|
83
|
+
png = directory / f"{draft_id}.png"
|
|
84
|
+
if not png.is_file():
|
|
85
|
+
raise ValueError("incomplete draft")
|
|
86
|
+
return [{"kind": "image", "path": str(png), "mime": "image/png", "size_bytes": png.stat().st_size, "frames": []}]
|
|
87
|
+
items = []
|
|
88
|
+
for entry in meta["media"]:
|
|
89
|
+
path = directory / entry["file"] if entry.get("file") else None
|
|
90
|
+
frames = [(str(directory / name), int(size)) for name, size in entry.get("frames", [])]
|
|
91
|
+
files = ([path] if path else []) + [Path(p) for p, _ in frames]
|
|
92
|
+
if not files or not all(f.is_file() for f in files):
|
|
93
|
+
raise ValueError("incomplete draft")
|
|
94
|
+
items.append({
|
|
95
|
+
"kind": str(entry["kind"]), "path": str(path) if path else None, "mime": str(entry.get("mime", "")),
|
|
96
|
+
"size_bytes": int(entry.get("size_bytes", 0)), "frames": frames,
|
|
97
|
+
})
|
|
98
|
+
return items
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def load_draft(draft_id: str) -> Optional[Draft]:
|
|
102
|
+
directory = drafts_dir()
|
|
103
|
+
sidecar = directory / f"{draft_id}.json"
|
|
104
|
+
try:
|
|
105
|
+
meta = json.loads(sidecar.read_text())
|
|
106
|
+
if not isinstance(meta, dict):
|
|
107
|
+
raise ValueError("incomplete draft")
|
|
108
|
+
return Draft(
|
|
109
|
+
draft_id, meta.get("created_at", ""), meta.get("repo"),
|
|
110
|
+
str(meta.get("title", "")), str(meta.get("notes", "")),
|
|
111
|
+
[str(t) for t in meta.get("tags", [])], str(meta.get("status", "open")),
|
|
112
|
+
_items_from_meta(directory, draft_id, meta),
|
|
113
|
+
)
|
|
114
|
+
except (OSError, ValueError, TypeError, KeyError) as exc:
|
|
115
|
+
print(f"warning: skipping unreadable draft {draft_id}: {exc}", file=sys.stderr)
|
|
116
|
+
return None
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def list_drafts(repo: Optional[str]) -> list[Draft]:
|
|
120
|
+
"""Drafts saved for this repo (or without a repo), oldest first."""
|
|
121
|
+
found = []
|
|
122
|
+
for sidecar in sorted(drafts_dir().glob("*.json")):
|
|
123
|
+
draft = load_draft(sidecar.stem)
|
|
124
|
+
if draft is not None and draft.repo == repo:
|
|
125
|
+
found.append(draft)
|
|
126
|
+
return sorted(found, key=lambda d: d.created_at)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def delete_draft(draft_id: str) -> None:
|
|
130
|
+
directory = drafts_dir()
|
|
131
|
+
(directory / f"{draft_id}.json").unlink(missing_ok=True)
|
|
132
|
+
(directory / f"{draft_id}.png").unlink(missing_ok=True)
|
|
133
|
+
for leftover in directory.glob(f"{draft_id}-*"):
|
|
134
|
+
leftover.unlink(missing_ok=True)
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
"""Bring existing images into the store: local paths, globs and http(s) URLs.
|
|
2
2
|
|
|
3
3
|
Every input is validated by its magic bytes (never its extension or Content-Type), copied
|
|
4
|
-
into the images directory under a
|
|
5
|
-
atomically.
|
|
4
|
+
into the images directory under a name derived from its content (the original path is not
|
|
5
|
+
kept) and written atomically. The same image is therefore stored once, however many reports
|
|
6
|
+
use it. Network access is limited to plain http(s) GETs with no credentials."""
|
|
6
7
|
from __future__ import annotations
|
|
7
8
|
|
|
8
9
|
import glob
|
|
10
|
+
import hashlib
|
|
9
11
|
import os
|
|
10
12
|
import socket
|
|
11
13
|
import urllib.error
|
|
@@ -32,6 +34,7 @@ class StoredFile:
|
|
|
32
34
|
mime: str
|
|
33
35
|
size_bytes: int
|
|
34
36
|
source: str
|
|
37
|
+
reused: bool = False # the content was already in the store; no new file was written
|
|
35
38
|
|
|
36
39
|
|
|
37
40
|
@dataclass
|
|
@@ -70,10 +73,16 @@ def sniff_image(data: bytes) -> str:
|
|
|
70
73
|
raise ServiceError("invalid_image", "not an image (PNG, JPEG, GIF or WebP expected)")
|
|
71
74
|
|
|
72
75
|
|
|
76
|
+
def content_name(data: bytes, mime: str) -> str:
|
|
77
|
+
return f"{hashlib.sha256(data).hexdigest()}{_EXT[mime]}"
|
|
78
|
+
|
|
79
|
+
|
|
73
80
|
def _store(data: bytes, source: str) -> StoredFile:
|
|
74
81
|
mime = sniff_image(data)
|
|
75
|
-
dest = images_dir() /
|
|
76
|
-
|
|
82
|
+
dest = images_dir() / content_name(data, mime)
|
|
83
|
+
if dest.is_file() and dest.stat().st_size == len(data):
|
|
84
|
+
return StoredFile(to_data_relative(dest), dest, mime, len(data), source, reused=True)
|
|
85
|
+
tmp = dest.with_name(f"{dest.name}.{uuid.uuid4().hex}.part")
|
|
77
86
|
try:
|
|
78
87
|
tmp.write_bytes(data)
|
|
79
88
|
os.replace(tmp, dest)
|
|
@@ -82,6 +91,29 @@ def _store(data: bytes, source: str) -> StoredFile:
|
|
|
82
91
|
return StoredFile(to_data_relative(dest), dest, mime, len(data), source)
|
|
83
92
|
|
|
84
93
|
|
|
94
|
+
def adopt(path) -> Path:
|
|
95
|
+
"""Give a screenshot the capture tool already saved in the store its content-derived
|
|
96
|
+
name. If the same image is stored already, the new copy is dropped and the existing
|
|
97
|
+
path returned. Anything that is not a recognisable image, or sits outside the store, is
|
|
98
|
+
left where it is."""
|
|
99
|
+
path = Path(path)
|
|
100
|
+
if path.parent != images_dir():
|
|
101
|
+
return path
|
|
102
|
+
try:
|
|
103
|
+
data = path.read_bytes()
|
|
104
|
+
mime = sniff_image(data)
|
|
105
|
+
except (OSError, ServiceError):
|
|
106
|
+
return path
|
|
107
|
+
dest = images_dir() / content_name(data, mime)
|
|
108
|
+
if dest == path:
|
|
109
|
+
return path
|
|
110
|
+
if dest.is_file() and dest.stat().st_size == len(data):
|
|
111
|
+
path.unlink(missing_ok=True)
|
|
112
|
+
return dest
|
|
113
|
+
os.replace(path, dest)
|
|
114
|
+
return dest
|
|
115
|
+
|
|
116
|
+
|
|
85
117
|
def import_local(path: Path) -> StoredFile:
|
|
86
118
|
path = Path(path).expanduser()
|
|
87
119
|
if path.is_dir():
|