clearfront 2.7.1__tar.gz → 2.7.2__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.
- {clearfront-2.7.1 → clearfront-2.7.2}/PKG-INFO +22 -2
- {clearfront-2.7.1 → clearfront-2.7.2}/README.md +21 -1
- clearfront-2.7.2/clearfront/boards.py +250 -0
- {clearfront-2.7.1 → clearfront-2.7.2}/clearfront/web/index.html +282 -11
- {clearfront-2.7.1 → clearfront-2.7.2}/clearfront/web_server.py +86 -0
- {clearfront-2.7.1 → clearfront-2.7.2}/clearfront.egg-info/PKG-INFO +22 -2
- {clearfront-2.7.1 → clearfront-2.7.2}/clearfront.egg-info/SOURCES.txt +2 -0
- {clearfront-2.7.1 → clearfront-2.7.2}/pyproject.toml +1 -1
- clearfront-2.7.2/tests/test_boards.py +188 -0
- {clearfront-2.7.1 → clearfront-2.7.2}/LICENSE +0 -0
- {clearfront-2.7.1 → clearfront-2.7.2}/clearfront/__init__.py +0 -0
- {clearfront-2.7.1 → clearfront-2.7.2}/clearfront/agent.py +0 -0
- {clearfront-2.7.1 → clearfront-2.7.2}/clearfront/brightdata.py +0 -0
- {clearfront-2.7.1 → clearfront-2.7.2}/clearfront/cli.py +0 -0
- {clearfront-2.7.1 → clearfront-2.7.2}/clearfront/correlation.py +0 -0
- {clearfront-2.7.1 → clearfront-2.7.2}/clearfront/corroboration.py +0 -0
- {clearfront-2.7.1 → clearfront-2.7.2}/clearfront/effort.py +0 -0
- {clearfront-2.7.1 → clearfront-2.7.2}/clearfront/exceptions.py +0 -0
- {clearfront-2.7.1 → clearfront-2.7.2}/clearfront/extractors.py +0 -0
- {clearfront-2.7.1 → clearfront-2.7.2}/clearfront/json_output.py +0 -0
- {clearfront-2.7.1 → clearfront-2.7.2}/clearfront/mcp_server.py +0 -0
- {clearfront-2.7.1 → clearfront-2.7.2}/clearfront/multi_target.py +0 -0
- {clearfront-2.7.1 → clearfront-2.7.2}/clearfront/pdf_report.py +0 -0
- {clearfront-2.7.1 → clearfront-2.7.2}/clearfront/pivot.py +0 -0
- {clearfront-2.7.1 → clearfront-2.7.2}/clearfront/prompts.py +0 -0
- {clearfront-2.7.1 → clearfront-2.7.2}/clearfront/regexes.py +0 -0
- {clearfront-2.7.1 → clearfront-2.7.2}/clearfront/repl.py +0 -0
- {clearfront-2.7.1 → clearfront-2.7.2}/clearfront/serp.py +0 -0
- {clearfront-2.7.1 → clearfront-2.7.2}/clearfront/session_history.py +0 -0
- {clearfront-2.7.1 → clearfront-2.7.2}/clearfront/sponsors.py +0 -0
- {clearfront-2.7.1 → clearfront-2.7.2}/clearfront/tool_cache.py +0 -0
- {clearfront-2.7.1 → clearfront-2.7.2}/clearfront/tools/__init__.py +0 -0
- {clearfront-2.7.1 → clearfront-2.7.2}/clearfront/tools/data/NOTICE +0 -0
- {clearfront-2.7.1 → clearfront-2.7.2}/clearfront/tools/data/wmn-data-unique.json +0 -0
- {clearfront-2.7.1 → clearfront-2.7.2}/clearfront/tools/exceptions.py +0 -0
- {clearfront-2.7.1 → clearfront-2.7.2}/clearfront/tools/generate_dorks.py +0 -0
- {clearfront-2.7.1 → clearfront-2.7.2}/clearfront/tools/scrape_url.py +0 -0
- {clearfront-2.7.1 → clearfront-2.7.2}/clearfront/tools/search_abuseipdb.py +0 -0
- {clearfront-2.7.1 → clearfront-2.7.2}/clearfront/tools/search_breach.py +0 -0
- {clearfront-2.7.1 → clearfront-2.7.2}/clearfront/tools/search_censys.py +0 -0
- {clearfront-2.7.1 → clearfront-2.7.2}/clearfront/tools/search_crt.py +0 -0
- {clearfront-2.7.1 → clearfront-2.7.2}/clearfront/tools/search_crypto.py +0 -0
- {clearfront-2.7.1 → clearfront-2.7.2}/clearfront/tools/search_dns.py +0 -0
- {clearfront-2.7.1 → clearfront-2.7.2}/clearfront/tools/search_domain.py +0 -0
- {clearfront-2.7.1 → clearfront-2.7.2}/clearfront/tools/search_dorks_live.py +0 -0
- {clearfront-2.7.1 → clearfront-2.7.2}/clearfront/tools/search_email.py +0 -0
- {clearfront-2.7.1 → clearfront-2.7.2}/clearfront/tools/search_emailrep.py +0 -0
- {clearfront-2.7.1 → clearfront-2.7.2}/clearfront/tools/search_exif.py +0 -0
- {clearfront-2.7.1 → clearfront-2.7.2}/clearfront/tools/search_exposure.py +0 -0
- {clearfront-2.7.1 → clearfront-2.7.2}/clearfront/tools/search_footprint.py +0 -0
- {clearfront-2.7.1 → clearfront-2.7.2}/clearfront/tools/search_github.py +0 -0
- {clearfront-2.7.1 → clearfront-2.7.2}/clearfront/tools/search_gravatar.py +0 -0
- {clearfront-2.7.1 → clearfront-2.7.2}/clearfront/tools/search_greynoise.py +0 -0
- {clearfront-2.7.1 → clearfront-2.7.2}/clearfront/tools/search_harvester.py +0 -0
- {clearfront-2.7.1 → clearfront-2.7.2}/clearfront/tools/search_hudsonrock.py +0 -0
- {clearfront-2.7.1 → clearfront-2.7.2}/clearfront/tools/search_ip.py +0 -0
- {clearfront-2.7.1 → clearfront-2.7.2}/clearfront/tools/search_ip2location.py +0 -0
- {clearfront-2.7.1 → clearfront-2.7.2}/clearfront/tools/search_maigret.py +0 -0
- {clearfront-2.7.1 → clearfront-2.7.2}/clearfront/tools/search_paste.py +0 -0
- {clearfront-2.7.1 → clearfront-2.7.2}/clearfront/tools/search_phone.py +0 -0
- {clearfront-2.7.1 → clearfront-2.7.2}/clearfront/tools/search_shodan.py +0 -0
- {clearfront-2.7.1 → clearfront-2.7.2}/clearfront/tools/search_username.py +0 -0
- {clearfront-2.7.1 → clearfront-2.7.2}/clearfront/tools/search_virustotal.py +0 -0
- {clearfront-2.7.1 → clearfront-2.7.2}/clearfront/tools/search_wayback.py +0 -0
- {clearfront-2.7.1 → clearfront-2.7.2}/clearfront/tools/search_whois.py +0 -0
- {clearfront-2.7.1 → clearfront-2.7.2}/clearfront/tools/whatsmyname.py +0 -0
- {clearfront-2.7.1 → clearfront-2.7.2}/clearfront/utils.py +0 -0
- {clearfront-2.7.1 → clearfront-2.7.2}/clearfront.egg-info/dependency_links.txt +0 -0
- {clearfront-2.7.1 → clearfront-2.7.2}/clearfront.egg-info/entry_points.txt +0 -0
- {clearfront-2.7.1 → clearfront-2.7.2}/clearfront.egg-info/requires.txt +0 -0
- {clearfront-2.7.1 → clearfront-2.7.2}/clearfront.egg-info/top_level.txt +0 -0
- {clearfront-2.7.1 → clearfront-2.7.2}/setup.cfg +0 -0
- {clearfront-2.7.1 → clearfront-2.7.2}/tests/test_authorized_use.py +0 -0
- {clearfront-2.7.1 → clearfront-2.7.2}/tests/test_brightdata.py +0 -0
- {clearfront-2.7.1 → clearfront-2.7.2}/tests/test_cli_openai.py +0 -0
- {clearfront-2.7.1 → clearfront-2.7.2}/tests/test_cli_output.py +0 -0
- {clearfront-2.7.1 → clearfront-2.7.2}/tests/test_corroboration.py +0 -0
- {clearfront-2.7.1 → clearfront-2.7.2}/tests/test_crt.py +0 -0
- {clearfront-2.7.1 → clearfront-2.7.2}/tests/test_dns.py +0 -0
- {clearfront-2.7.1 → clearfront-2.7.2}/tests/test_effort.py +0 -0
- {clearfront-2.7.1 → clearfront-2.7.2}/tests/test_exif.py +0 -0
- {clearfront-2.7.1 → clearfront-2.7.2}/tests/test_footprint.py +0 -0
- {clearfront-2.7.1 → clearfront-2.7.2}/tests/test_github_code_search.py +0 -0
- {clearfront-2.7.1 → clearfront-2.7.2}/tests/test_graph_export.py +0 -0
- {clearfront-2.7.1 → clearfront-2.7.2}/tests/test_hudsonrock.py +0 -0
- {clearfront-2.7.1 → clearfront-2.7.2}/tests/test_json_export.py +0 -0
- {clearfront-2.7.1 → clearfront-2.7.2}/tests/test_mcp_authorized_use.py +0 -0
- {clearfront-2.7.1 → clearfront-2.7.2}/tests/test_openai_agent.py +0 -0
- {clearfront-2.7.1 → clearfront-2.7.2}/tests/test_pivot_freetier.py +0 -0
- {clearfront-2.7.1 → clearfront-2.7.2}/tests/test_regexes.py +0 -0
- {clearfront-2.7.1 → clearfront-2.7.2}/tests/test_repl_first_run.py +0 -0
- {clearfront-2.7.1 → clearfront-2.7.2}/tests/test_report_export.py +0 -0
- {clearfront-2.7.1 → clearfront-2.7.2}/tests/test_security.py +0 -0
- {clearfront-2.7.1 → clearfront-2.7.2}/tests/test_serp.py +0 -0
- {clearfront-2.7.1 → clearfront-2.7.2}/tests/test_tool_cache.py +0 -0
- {clearfront-2.7.1 → clearfront-2.7.2}/tests/test_tool_parity.py +0 -0
- {clearfront-2.7.1 → clearfront-2.7.2}/tests/test_tools.py +0 -0
- {clearfront-2.7.1 → clearfront-2.7.2}/tests/test_v2140.py +0 -0
- {clearfront-2.7.1 → clearfront-2.7.2}/tests/test_v240.py +0 -0
- {clearfront-2.7.1 → clearfront-2.7.2}/tests/test_wayback_greynoise.py +0 -0
- {clearfront-2.7.1 → clearfront-2.7.2}/tests/test_web_server.py +0 -0
- {clearfront-2.7.1 → clearfront-2.7.2}/tests/test_web_server_openai.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: clearfront
|
|
3
|
-
Version: 2.7.
|
|
3
|
+
Version: 2.7.2
|
|
4
4
|
Summary: Clearfront OSINT: AI-powered OSINT agent, MCP server, and CLI that maps your digital footprint across 3,400+ public data sources. Interactive REPL + 30 tools. Anthropic Claude or local Ollama. For authorized security research use only.
|
|
5
5
|
Author: Scott Martin Anderson
|
|
6
6
|
License-Expression: MIT
|
|
@@ -69,6 +69,8 @@ Requires-Dist: uvicorn[standard]>=0.51.0; extra == "all"
|
|
|
69
69
|
Requires-Dist: sse-starlette>=3.4.5; extra == "all"
|
|
70
70
|
Dynamic: license-file
|
|
71
71
|
|
|
72
|
+
<!-- mcp-name: sh.clearfront/clearfront -->
|
|
73
|
+
|
|
72
74
|
<div align="center">
|
|
73
75
|
<h1>Clearfront</h1>
|
|
74
76
|
<p><strong>Open-source AI intelligence on your digital footprint.</strong></p>
|
|
@@ -81,7 +83,7 @@ Dynamic: license-file
|
|
|
81
83
|
[](https://www.python.org/)
|
|
82
84
|
[](LICENSE)
|
|
83
85
|
[](https://modelcontextprotocol.io/)
|
|
84
|
-
[](CHANGELOG.md)
|
|
85
87
|
|
|
86
88
|
**[clearfront.sh](https://clearfront.sh)** · [Disclaimer](./DISCLAIMER.md)
|
|
87
89
|
|
|
@@ -252,6 +254,24 @@ claude mcp list
|
|
|
252
254
|
}
|
|
253
255
|
```
|
|
254
256
|
|
|
257
|
+
### Agent Skill
|
|
258
|
+
|
|
259
|
+
[`skills/clearfront-osint`](skills/clearfront-osint/SKILL.md) teaches an agent
|
|
260
|
+
how to drive Clearfront: which approach fits a given request, the direct
|
|
261
|
+
subcommands, how to read the confidence ratings, and the authorized-use rules.
|
|
262
|
+
It follows the [Agent Skills](https://agentskills.io) open standard, so it works
|
|
263
|
+
in Claude Code, Cursor, Gemini CLI, Copilot, Goose, OpenCode and the other
|
|
264
|
+
skills-compatible clients.
|
|
265
|
+
|
|
266
|
+
```bash
|
|
267
|
+
npx skills add scottmartinanderson/clearfront
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
Install Clearfront itself as well (`pip install clearfront`); the skill is the
|
|
271
|
+
instructions, not the tool. Use the skill when you want the agent to decide how
|
|
272
|
+
to run a sweep, and the MCP server above when you want it calling the 30 tools
|
|
273
|
+
directly.
|
|
274
|
+
|
|
255
275
|
## Docker
|
|
256
276
|
|
|
257
277
|
```bash
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
<!-- mcp-name: sh.clearfront/clearfront -->
|
|
2
|
+
|
|
1
3
|
<div align="center">
|
|
2
4
|
<h1>Clearfront</h1>
|
|
3
5
|
<p><strong>Open-source AI intelligence on your digital footprint.</strong></p>
|
|
@@ -10,7 +12,7 @@
|
|
|
10
12
|
[](https://www.python.org/)
|
|
11
13
|
[](LICENSE)
|
|
12
14
|
[](https://modelcontextprotocol.io/)
|
|
13
|
-
[](CHANGELOG.md)
|
|
14
16
|
|
|
15
17
|
**[clearfront.sh](https://clearfront.sh)** · [Disclaimer](./DISCLAIMER.md)
|
|
16
18
|
|
|
@@ -181,6 +183,24 @@ claude mcp list
|
|
|
181
183
|
}
|
|
182
184
|
```
|
|
183
185
|
|
|
186
|
+
### Agent Skill
|
|
187
|
+
|
|
188
|
+
[`skills/clearfront-osint`](skills/clearfront-osint/SKILL.md) teaches an agent
|
|
189
|
+
how to drive Clearfront: which approach fits a given request, the direct
|
|
190
|
+
subcommands, how to read the confidence ratings, and the authorized-use rules.
|
|
191
|
+
It follows the [Agent Skills](https://agentskills.io) open standard, so it works
|
|
192
|
+
in Claude Code, Cursor, Gemini CLI, Copilot, Goose, OpenCode and the other
|
|
193
|
+
skills-compatible clients.
|
|
194
|
+
|
|
195
|
+
```bash
|
|
196
|
+
npx skills add scottmartinanderson/clearfront
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
Install Clearfront itself as well (`pip install clearfront`); the skill is the
|
|
200
|
+
instructions, not the tool. Use the skill when you want the agent to decide how
|
|
201
|
+
to run a sweep, and the MCP server above when you want it calling the 30 tools
|
|
202
|
+
directly.
|
|
203
|
+
|
|
184
204
|
## Docker
|
|
185
205
|
|
|
186
206
|
```bash
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
# clearfront/boards.py
|
|
2
|
+
"""
|
|
3
|
+
Persistent investigation boards for Clearfront.
|
|
4
|
+
|
|
5
|
+
A *board* is a saved evidence-graph investigation: a title, an optional
|
|
6
|
+
description and colour tag, and the D3 node-link graph the web console renders.
|
|
7
|
+
Boards are stored as individual JSON files in ``~/.clearfront/boards/``, one
|
|
8
|
+
file per board keyed by a filesystem-safe slug id.
|
|
9
|
+
|
|
10
|
+
This is the local-first store, holding only what the analyst already sees in
|
|
11
|
+
their own browser; nothing is uploaded anywhere. A future cloud-hosted store can
|
|
12
|
+
implement the same board shape behind the same web routes.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import logging
|
|
19
|
+
import re
|
|
20
|
+
import uuid
|
|
21
|
+
from datetime import datetime, timezone
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
BOARDS_DIR = Path.home() / ".clearfront" / "boards"
|
|
28
|
+
MAX_BOARDS = 200
|
|
29
|
+
|
|
30
|
+
# Colour tags the UI offers; a board's colour is validated to this set so a
|
|
31
|
+
# malformed value can never reach the stored file or the client.
|
|
32
|
+
BOARD_COLORS = (
|
|
33
|
+
"blue", "purple", "pink", "red", "amber",
|
|
34
|
+
"green", "cyan", "indigo", "teal", "grey",
|
|
35
|
+
)
|
|
36
|
+
DEFAULT_COLOR = "blue"
|
|
37
|
+
|
|
38
|
+
# A board id is only ever a lowercase slug. This regex also blocks path
|
|
39
|
+
# traversal (no dots, no slashes) when an id from a URL is turned into a path.
|
|
40
|
+
_SAFE_ID_RE = re.compile(r"^[a-z0-9][a-z0-9-]{0,63}$")
|
|
41
|
+
_SLUG_STRIP_RE = re.compile(r"[^a-z0-9]+")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# ---------------------------------------------------------------------------
|
|
45
|
+
# Helpers
|
|
46
|
+
# ---------------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _now_iso() -> str:
|
|
50
|
+
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _ensure_dir() -> None:
|
|
54
|
+
BOARDS_DIR.mkdir(parents=True, exist_ok=True)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _slugify(title: str) -> str:
|
|
58
|
+
"""Turn a human title into a short filesystem-safe slug stem (no id suffix)."""
|
|
59
|
+
return _SLUG_STRIP_RE.sub("-", (title or "").lower()).strip("-")[:40].strip("-")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _new_id(title: str) -> str:
|
|
63
|
+
"""Generate a unique board id: title slug + short random suffix."""
|
|
64
|
+
stem = _slugify(title) or "board"
|
|
65
|
+
return f"{stem}-{uuid.uuid4().hex[:6]}"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _board_path(board_id: str) -> Path | None:
|
|
69
|
+
"""Return the file path for a validated id, or None if the id is unsafe."""
|
|
70
|
+
if not board_id or not _SAFE_ID_RE.match(board_id):
|
|
71
|
+
return None
|
|
72
|
+
return BOARDS_DIR / f"{board_id}.json"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _clean_graph(graph: Any) -> dict[str, list]:
|
|
76
|
+
"""Keep only the node/link fields we persist; drop force-layout state.
|
|
77
|
+
|
|
78
|
+
Link endpoints may be node objects after the client's force layout runs, so
|
|
79
|
+
they are normalised back to plain ids. ``label``/``kind`` are preserved when
|
|
80
|
+
present (forward compatibility with typed relationships).
|
|
81
|
+
"""
|
|
82
|
+
if not isinstance(graph, dict):
|
|
83
|
+
return {"nodes": [], "links": []}
|
|
84
|
+
|
|
85
|
+
nodes: list[dict] = []
|
|
86
|
+
for n in graph.get("nodes") or []:
|
|
87
|
+
if not isinstance(n, dict) or n.get("id") is None:
|
|
88
|
+
continue
|
|
89
|
+
nodes.append({
|
|
90
|
+
"id": n.get("id"),
|
|
91
|
+
"label": n.get("label", ""),
|
|
92
|
+
"type": n.get("type", ""),
|
|
93
|
+
"value": n.get("value", ""),
|
|
94
|
+
"source": n.get("source", ""),
|
|
95
|
+
"confidence": n.get("confidence", ""),
|
|
96
|
+
"severity": n.get("severity", ""),
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
links: list[dict] = []
|
|
100
|
+
for l in graph.get("links") or []:
|
|
101
|
+
if not isinstance(l, dict):
|
|
102
|
+
continue
|
|
103
|
+
src, tgt = l.get("source"), l.get("target")
|
|
104
|
+
if isinstance(src, dict):
|
|
105
|
+
src = src.get("id")
|
|
106
|
+
if isinstance(tgt, dict):
|
|
107
|
+
tgt = tgt.get("id")
|
|
108
|
+
if src is None or tgt is None:
|
|
109
|
+
continue
|
|
110
|
+
link: dict[str, Any] = {"source": src, "target": tgt}
|
|
111
|
+
if l.get("label"):
|
|
112
|
+
link["label"] = l["label"]
|
|
113
|
+
if l.get("kind"):
|
|
114
|
+
link["kind"] = l["kind"]
|
|
115
|
+
links.append(link)
|
|
116
|
+
|
|
117
|
+
return {"nodes": nodes, "links": links}
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _summary(board: dict[str, Any]) -> dict[str, Any]:
|
|
121
|
+
"""A light board descriptor for the list view (no full graph payload)."""
|
|
122
|
+
graph = board.get("graph") or {}
|
|
123
|
+
return {
|
|
124
|
+
"id": board.get("id", ""),
|
|
125
|
+
"title": board.get("title", ""),
|
|
126
|
+
"description": board.get("description", ""),
|
|
127
|
+
"color": board.get("color", DEFAULT_COLOR),
|
|
128
|
+
"created_at": board.get("created_at", ""),
|
|
129
|
+
"updated_at": board.get("updated_at", ""),
|
|
130
|
+
"node_count": len(graph.get("nodes") or []),
|
|
131
|
+
"link_count": len(graph.get("links") or []),
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
# ---------------------------------------------------------------------------
|
|
136
|
+
# Public API
|
|
137
|
+
# ---------------------------------------------------------------------------
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def list_boards() -> list[dict[str, Any]]:
|
|
141
|
+
"""Return board summaries, newest-updated first (no graph payloads)."""
|
|
142
|
+
if not BOARDS_DIR.exists():
|
|
143
|
+
return []
|
|
144
|
+
boards: list[dict[str, Any]] = []
|
|
145
|
+
for f in BOARDS_DIR.glob("*.json"):
|
|
146
|
+
try:
|
|
147
|
+
boards.append(_summary(json.loads(f.read_text(encoding="utf-8"))))
|
|
148
|
+
except Exception:
|
|
149
|
+
logger.debug("Failed to read board file: %s", f, exc_info=True)
|
|
150
|
+
boards.sort(key=lambda b: b.get("updated_at", ""), reverse=True)
|
|
151
|
+
return boards
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def load_board(board_id: str) -> dict[str, Any] | None:
|
|
155
|
+
"""Return the full board (including graph), or None if missing/invalid id."""
|
|
156
|
+
path = _board_path(board_id)
|
|
157
|
+
if path is None or not path.exists():
|
|
158
|
+
return None
|
|
159
|
+
try:
|
|
160
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
161
|
+
except Exception:
|
|
162
|
+
logger.debug("Failed to read board file: %s", path, exc_info=True)
|
|
163
|
+
return None
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def save_board(
|
|
167
|
+
*,
|
|
168
|
+
board_id: str = "",
|
|
169
|
+
title: str = "",
|
|
170
|
+
description: str = "",
|
|
171
|
+
color: str = DEFAULT_COLOR,
|
|
172
|
+
graph: Any = None,
|
|
173
|
+
notes: str = "",
|
|
174
|
+
) -> dict[str, Any] | None:
|
|
175
|
+
"""Create or update a board. Returns the saved board, or None on bad input.
|
|
176
|
+
|
|
177
|
+
A blank/unknown ``board_id`` creates a new board; a valid existing id updates
|
|
178
|
+
it in place, preserving the original ``created_at``. Requires a non-empty
|
|
179
|
+
``title``.
|
|
180
|
+
"""
|
|
181
|
+
title = (title or "").strip()
|
|
182
|
+
if not title:
|
|
183
|
+
return None
|
|
184
|
+
color = color if color in BOARD_COLORS else DEFAULT_COLOR
|
|
185
|
+
|
|
186
|
+
existing = load_board(board_id) if board_id else None
|
|
187
|
+
now = _now_iso()
|
|
188
|
+
if existing is not None:
|
|
189
|
+
bid = existing["id"]
|
|
190
|
+
created = existing.get("created_at", now)
|
|
191
|
+
else:
|
|
192
|
+
bid = _new_id(title)
|
|
193
|
+
p = _board_path(bid)
|
|
194
|
+
while p is not None and p.exists(): # astronomically unlikely, but cheap
|
|
195
|
+
bid = _new_id(title)
|
|
196
|
+
p = _board_path(bid)
|
|
197
|
+
created = now
|
|
198
|
+
|
|
199
|
+
path = _board_path(bid)
|
|
200
|
+
if path is None:
|
|
201
|
+
return None
|
|
202
|
+
|
|
203
|
+
board = {
|
|
204
|
+
"id": bid,
|
|
205
|
+
"title": title[:200],
|
|
206
|
+
"description": (description or "").strip()[:2000],
|
|
207
|
+
"color": color,
|
|
208
|
+
"created_at": created,
|
|
209
|
+
"updated_at": now,
|
|
210
|
+
"notes": (notes or "")[:20000],
|
|
211
|
+
"graph": _clean_graph(graph),
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
_ensure_dir()
|
|
215
|
+
path.write_text(json.dumps(board, indent=2), encoding="utf-8")
|
|
216
|
+
_prune()
|
|
217
|
+
return board
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def delete_board(board_id: str) -> bool:
|
|
221
|
+
"""Delete a board by id. Returns True if a file was removed."""
|
|
222
|
+
path = _board_path(board_id)
|
|
223
|
+
if path is None or not path.exists():
|
|
224
|
+
return False
|
|
225
|
+
try:
|
|
226
|
+
path.unlink()
|
|
227
|
+
return True
|
|
228
|
+
except Exception:
|
|
229
|
+
logger.debug("Failed to delete board file: %s", path, exc_info=True)
|
|
230
|
+
return False
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _prune() -> None:
|
|
234
|
+
"""Keep at most MAX_BOARDS files, deleting the oldest-updated first."""
|
|
235
|
+
files = list(BOARDS_DIR.glob("*.json"))
|
|
236
|
+
if len(files) <= MAX_BOARDS:
|
|
237
|
+
return
|
|
238
|
+
|
|
239
|
+
def _updated(f: Path) -> str:
|
|
240
|
+
try:
|
|
241
|
+
return json.loads(f.read_text(encoding="utf-8")).get("updated_at", "")
|
|
242
|
+
except Exception:
|
|
243
|
+
return ""
|
|
244
|
+
|
|
245
|
+
files.sort(key=_updated) # oldest first
|
|
246
|
+
for f in files[: len(files) - MAX_BOARDS]:
|
|
247
|
+
try:
|
|
248
|
+
f.unlink()
|
|
249
|
+
except Exception:
|
|
250
|
+
logger.debug("Failed to prune board file: %s", f, exc_info=True)
|