opencode-log 0.3.0__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.
Files changed (35) hide show
  1. opencode_log/__init__.py +3 -0
  2. opencode_log/cache.py +240 -0
  3. opencode_log/cli.py +565 -0
  4. opencode_log/markdown.py +197 -0
  5. opencode_log/models.py +131 -0
  6. opencode_log/normalizer.py +146 -0
  7. opencode_log/render.py +360 -0
  8. opencode_log/storage.py +271 -0
  9. opencode_log/templates/combined.html +345 -0
  10. opencode_log/templates/components/base_styles.css +126 -0
  11. opencode_log/templates/components/edit_diff_styles.css +76 -0
  12. opencode_log/templates/components/filter_styles.css +168 -0
  13. opencode_log/templates/components/global_styles.css +237 -0
  14. opencode_log/templates/components/message_styles.css +1057 -0
  15. opencode_log/templates/components/page_nav_styles.css +79 -0
  16. opencode_log/templates/components/project_card_styles.css +138 -0
  17. opencode_log/templates/components/pygments_styles.css +218 -0
  18. opencode_log/templates/components/search.html +774 -0
  19. opencode_log/templates/components/search_inline.html +29 -0
  20. opencode_log/templates/components/search_inline_script.html +3 -0
  21. opencode_log/templates/components/search_results_panel.html +10 -0
  22. opencode_log/templates/components/search_styles.css +371 -0
  23. opencode_log/templates/components/session_nav.html +39 -0
  24. opencode_log/templates/components/session_nav_styles.css +106 -0
  25. opencode_log/templates/components/timeline.html +493 -0
  26. opencode_log/templates/components/timeline_styles.css +151 -0
  27. opencode_log/templates/components/timezone_converter.js +115 -0
  28. opencode_log/templates/components/todo_styles.css +186 -0
  29. opencode_log/templates/index.html +308 -0
  30. opencode_log/templates/transcript.html +372 -0
  31. opencode_log-0.3.0.dist-info/METADATA +519 -0
  32. opencode_log-0.3.0.dist-info/RECORD +35 -0
  33. opencode_log-0.3.0.dist-info/WHEEL +4 -0
  34. opencode_log-0.3.0.dist-info/entry_points.txt +2 -0
  35. opencode_log-0.3.0.dist-info/licenses/LICENSE +21 -0
opencode_log/render.py ADDED
@@ -0,0 +1,360 @@
1
+ from __future__ import annotations
2
+
3
+ import html
4
+ import json
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from jinja2 import Environment, FileSystemLoader, select_autoescape
10
+
11
+ from .models import Message, Project, Session, format_local_time
12
+ from .storage import safe_slug
13
+
14
+ RENDER_VERSION = "0.4"
15
+
16
+
17
+ @dataclass
18
+ class RenderedPart:
19
+ kind: str
20
+ html: str
21
+ css_class: str
22
+
23
+
24
+ @dataclass
25
+ class RenderedMessage:
26
+ id: str
27
+ role: str
28
+ role_label: str
29
+ css_class: str
30
+ created_text: str
31
+ created_ms: int
32
+ mode: str | None
33
+ agent: str | None
34
+ model: str | None
35
+ provider: str | None
36
+ finish: str | None
37
+ error: str | None
38
+ token_text: str
39
+ cost_text: str
40
+ has_tool: bool
41
+ has_reasoning: bool
42
+ search_blob: str
43
+ parts: list[RenderedPart]
44
+
45
+
46
+ def _env() -> Environment:
47
+ template_dir = Path(__file__).parent / "templates"
48
+ return Environment(
49
+ loader=FileSystemLoader(template_dir),
50
+ autoescape=select_autoescape(["html"]),
51
+ trim_blocks=True,
52
+ lstrip_blocks=True,
53
+ )
54
+
55
+
56
+ def _json_code_block(data: Any) -> str:
57
+ text = json.dumps(data, ensure_ascii=False, indent=2)
58
+ return f"<pre>{html.escape(text)}</pre>"
59
+
60
+
61
+ def _render_part(part: dict[str, Any]) -> RenderedPart:
62
+ part_type = str(part.get("type", "unknown"))
63
+
64
+ if part_type == "text":
65
+ text = part.get("text", "")
66
+ return RenderedPart(
67
+ kind="text",
68
+ css_class="part-text",
69
+ html=f"<pre>{html.escape(str(text))}</pre>",
70
+ )
71
+
72
+ if part_type == "reasoning":
73
+ text = part.get("text", "")
74
+ content = "<details class='collapsible-details'><summary>Reasoning</summary><pre>{}</pre></details>".format(
75
+ html.escape(str(text))
76
+ )
77
+ return RenderedPart(kind="reasoning", css_class="part-reasoning", html=content)
78
+
79
+ if part_type == "tool":
80
+ tool_name = str(part.get("tool", "tool"))
81
+ state = part.get("state") or {}
82
+ status = state.get("status", "unknown")
83
+ blocks = [
84
+ f"<details class='collapsible-details part-tool-block'><summary>Tool: {html.escape(tool_name)} ({html.escape(str(status))})</summary>"
85
+ ]
86
+ for key in ("title", "metadata", "input", "output", "error"):
87
+ value = state.get(key)
88
+ if value in (None, "", {}, []):
89
+ continue
90
+ blocks.append(
91
+ f"<div><strong>{html.escape(key)}</strong>{_json_code_block(value)}</div>"
92
+ )
93
+ blocks.append("</details>")
94
+ return RenderedPart(kind="tool", css_class="part-tool", html="".join(blocks))
95
+
96
+ if part_type in {
97
+ "step-start",
98
+ "step-finish",
99
+ "compaction",
100
+ "agent",
101
+ "patch",
102
+ "file",
103
+ "subtask",
104
+ "snapshot",
105
+ "retry",
106
+ }:
107
+ return RenderedPart(
108
+ kind=part_type,
109
+ css_class=f"part-{part_type}",
110
+ html=(
111
+ f"<details class='collapsible-details'><summary>{html.escape(part_type)}</summary>"
112
+ f"{_json_code_block(part)}</details>"
113
+ ),
114
+ )
115
+
116
+ return RenderedPart(
117
+ kind=part_type,
118
+ css_class="part-unknown",
119
+ html=f"<details class='collapsible-details'><summary>{html.escape(part_type)}</summary>{_json_code_block(part)}</details>",
120
+ )
121
+
122
+
123
+ def _token_text(message: Message) -> str:
124
+ parts = [
125
+ f"in {message.tokens_input:,}",
126
+ f"out {message.tokens_output:,}",
127
+ ]
128
+ if message.tokens_reasoning:
129
+ parts.append(f"reason {message.tokens_reasoning:,}")
130
+ if message.tokens_cache_read:
131
+ parts.append(f"cache r {message.tokens_cache_read:,}")
132
+ if message.tokens_cache_write:
133
+ parts.append(f"cache w {message.tokens_cache_write:,}")
134
+ return " | ".join(parts)
135
+
136
+
137
+ def _build_search_blob(message: Message) -> str:
138
+ chunks: list[str] = [
139
+ message.role,
140
+ message.mode or "",
141
+ message.agent or "",
142
+ message.model or "",
143
+ message.provider or "",
144
+ ]
145
+ for part in message.parts:
146
+ part_type = str(part.get("type", ""))
147
+ chunks.append(part_type)
148
+ if part_type in {"text", "reasoning"}:
149
+ chunks.append(str(part.get("text", "")))
150
+ if part_type == "tool":
151
+ chunks.append(str(part.get("tool", "")))
152
+ state = part.get("state") or {}
153
+ if isinstance(state, dict):
154
+ for key in ("title", "status"):
155
+ chunks.append(str(state.get(key, "")))
156
+ return " ".join(chunks).lower()
157
+
158
+
159
+ def _render_message(message: Message) -> RenderedMessage:
160
+ role = message.role
161
+ role_label = role.capitalize()
162
+ css_class = role if role in {"user", "assistant"} else "unknown"
163
+
164
+ rendered_parts = [_render_part(p) for p in message.parts]
165
+ kinds = {p.kind for p in rendered_parts}
166
+ created_ms = int(message.created_ms or 0)
167
+
168
+ return RenderedMessage(
169
+ id=message.id,
170
+ role=role,
171
+ role_label=role_label,
172
+ css_class=css_class,
173
+ created_text=format_local_time(message.created_ms),
174
+ created_ms=created_ms,
175
+ mode=message.mode,
176
+ agent=message.agent,
177
+ model=message.model,
178
+ provider=message.provider,
179
+ finish=message.finish,
180
+ error=message.error,
181
+ token_text=_token_text(message),
182
+ cost_text=f"{message.cost:.6f}",
183
+ has_tool="tool" in kinds,
184
+ has_reasoning="reasoning" in kinds,
185
+ search_blob=_build_search_blob(message),
186
+ parts=rendered_parts,
187
+ )
188
+
189
+
190
+ def session_render_signature(session: Session) -> str:
191
+ return "|".join(
192
+ [
193
+ RENDER_VERSION,
194
+ session.info.id,
195
+ str(session.info.updated_ms or 0),
196
+ str(session.message_count),
197
+ str(len(session.todos)),
198
+ str(len(session.diffs)),
199
+ str(session.total_input_tokens),
200
+ str(session.total_output_tokens),
201
+ f"{session.total_cost:.6f}",
202
+ ]
203
+ )
204
+
205
+
206
+ def combined_render_signature(project: Project, sessions: list[Session]) -> str:
207
+ session_sig = ",".join(session_render_signature(s) for s in sessions)
208
+ return f"{RENDER_VERSION}|combined|{project.id}|{session_sig}"
209
+
210
+
211
+ def index_render_signature(
212
+ projects: list[Project], sessions_by_project: dict[str, list[Session]]
213
+ ) -> str:
214
+ blocks: list[str] = [RENDER_VERSION, "index"]
215
+ for project in sorted(projects, key=lambda p: p.id):
216
+ sessions = sessions_by_project.get(project.id, [])
217
+ if not sessions:
218
+ continue
219
+ blocks.append(project.id)
220
+ blocks.extend(session_render_signature(s) for s in sessions)
221
+ return "|".join(blocks)
222
+
223
+
224
+ def render_session_page(
225
+ session: Session,
226
+ out_file: Path,
227
+ project_name: str,
228
+ back_link: str,
229
+ session_links: list[dict[str, str]],
230
+ ) -> None:
231
+ env = _env()
232
+ template = env.get_template("transcript.html")
233
+ out_file.parent.mkdir(parents=True, exist_ok=True)
234
+
235
+ html_content = template.render(
236
+ title=session.info.title,
237
+ project_name=project_name,
238
+ session=session,
239
+ session_created_text=format_local_time(session.info.created_ms),
240
+ session_updated_text=format_local_time(session.info.updated_ms),
241
+ rendered_messages=[_render_message(m) for m in session.messages],
242
+ session_links=session_links,
243
+ back_link=back_link,
244
+ )
245
+ out_file.write_text(html_content, encoding="utf-8")
246
+
247
+
248
+ def render_combined_page(
249
+ project: Project,
250
+ sessions: list[Session],
251
+ out_file: Path,
252
+ back_link: str,
253
+ ) -> None:
254
+ env = _env()
255
+ template = env.get_template("combined.html")
256
+ out_file.parent.mkdir(parents=True, exist_ok=True)
257
+
258
+ rendered_sessions: list[dict[str, Any]] = []
259
+ session_links: list[dict[str, str]] = []
260
+ for session in sessions:
261
+ anchor = f"ses-{session.info.id}"
262
+ session_links.append(
263
+ {
264
+ "id": session.info.id,
265
+ "title": session.info.title,
266
+ "anchor": anchor,
267
+ "file": f"session-{session.info.id}.html",
268
+ }
269
+ )
270
+ rendered_sessions.append(
271
+ {
272
+ "info": session.info,
273
+ "anchor": anchor,
274
+ "created_text": format_local_time(session.info.created_ms),
275
+ "updated_text": format_local_time(session.info.updated_ms),
276
+ "rendered_messages": [_render_message(m) for m in session.messages],
277
+ "message_count": session.message_count,
278
+ "tool_call_count": session.tool_call_count,
279
+ "total_input_tokens": session.total_input_tokens,
280
+ "total_output_tokens": session.total_output_tokens,
281
+ "total_reasoning_tokens": session.total_reasoning_tokens,
282
+ "total_cost": session.total_cost,
283
+ }
284
+ )
285
+
286
+ html_content = template.render(
287
+ title=f"{project.display_name} - Combined Transcripts",
288
+ project=project,
289
+ sessions=rendered_sessions,
290
+ session_links=session_links,
291
+ back_link=back_link,
292
+ )
293
+ out_file.write_text(html_content, encoding="utf-8")
294
+
295
+
296
+ def render_index_page(
297
+ output_root: Path,
298
+ projects: list[Project],
299
+ sessions_by_project: dict[str, list[Session]],
300
+ ) -> Path:
301
+ env = _env()
302
+ template = env.get_template("index.html")
303
+
304
+ project_cards: list[dict[str, Any]] = []
305
+ total_sessions = 0
306
+ total_messages = 0
307
+ total_cost = 0.0
308
+ total_in_tokens = 0
309
+ total_out_tokens = 0
310
+
311
+ for project in projects:
312
+ sessions = sessions_by_project.get(project.id, [])
313
+ if not sessions:
314
+ continue
315
+ total_sessions += len(sessions)
316
+
317
+ project_slug = f"{safe_slug(project.display_name)}-{project.id[:8]}"
318
+ base = f"projects/{project_slug}"
319
+ summary = {
320
+ "id": project.id,
321
+ "name": project.display_name,
322
+ "worktree": project.worktree,
323
+ "session_count": len(sessions),
324
+ "combined_link": f"{base}/combined_transcripts.html",
325
+ "sessions": [],
326
+ }
327
+
328
+ for s in sessions:
329
+ total_messages += s.message_count
330
+ total_cost += s.total_cost
331
+ total_in_tokens += s.total_input_tokens
332
+ total_out_tokens += s.total_output_tokens
333
+ summary["sessions"].append(
334
+ {
335
+ "id": s.info.id,
336
+ "title": s.info.title,
337
+ "updated": format_local_time(s.info.updated_ms),
338
+ "updated_ms": int(s.info.updated_ms or 0),
339
+ "message_count": s.message_count,
340
+ "tool_count": s.tool_call_count,
341
+ "cost": f"${s.total_cost:.6f}",
342
+ "token_summary": f"in {s.total_input_tokens:,} / out {s.total_output_tokens:,}",
343
+ "link": f"{base}/session-{s.info.id}.html",
344
+ }
345
+ )
346
+ project_cards.append(summary)
347
+
348
+ output_root.mkdir(parents=True, exist_ok=True)
349
+ index_path = output_root / "index.html"
350
+ html_content = template.render(
351
+ title="OpenCode Log Index",
352
+ projects=project_cards,
353
+ total_projects=len(project_cards),
354
+ total_sessions=total_sessions,
355
+ total_messages=total_messages,
356
+ total_cost=f"${total_cost:.6f}",
357
+ total_token_summary=f"in {total_in_tokens:,} / out {total_out_tokens:,}",
358
+ )
359
+ index_path.write_text(html_content, encoding="utf-8")
360
+ return index_path
@@ -0,0 +1,271 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import re
5
+ from pathlib import Path
6
+ from typing import Any, TYPE_CHECKING
7
+
8
+ import dateparser
9
+
10
+ from .models import Message, Project, Session, SessionDiffItem, SessionInfo, TodoItem
11
+ from .normalizer import inspect_storage_schema, normalize_message
12
+
13
+ if TYPE_CHECKING:
14
+ from .cache import CacheManager
15
+
16
+
17
+ def _read_json_file(path: Path) -> Any | None:
18
+ if not path.exists():
19
+ return None
20
+ try:
21
+ with open(path, "r", encoding="utf-8") as f:
22
+ return json.load(f)
23
+ except Exception:
24
+ return None
25
+
26
+
27
+ def parse_date_to_ms(text: str | None, end_of_day: bool = False) -> int | None:
28
+ if not text:
29
+ return None
30
+ dt = dateparser.parse(
31
+ text,
32
+ settings={"TIMEZONE": "UTC", "RETURN_AS_TIMEZONE_AWARE": True},
33
+ )
34
+ if dt is None:
35
+ return None
36
+ if end_of_day:
37
+ dt = dt.replace(hour=23, minute=59, second=59, microsecond=999000)
38
+ else:
39
+ dt = dt.replace(hour=0, minute=0, second=0, microsecond=0)
40
+ return int(dt.timestamp() * 1000)
41
+
42
+
43
+ def load_projects(storage_dir: Path) -> list[Project]:
44
+ project_dir = storage_dir / "project"
45
+ projects: list[Project] = []
46
+ if not project_dir.exists():
47
+ return projects
48
+
49
+ for file in sorted(project_dir.glob("*.json")):
50
+ data = _read_json_file(file)
51
+ if not isinstance(data, dict):
52
+ continue
53
+ time_data = data.get("time") or {}
54
+ projects.append(
55
+ Project(
56
+ id=str(data.get("id", "")),
57
+ worktree=str(data.get("worktree", "")),
58
+ vcs=data.get("vcs"),
59
+ created_ms=time_data.get("created"),
60
+ updated_ms=time_data.get("updated"),
61
+ )
62
+ )
63
+ return projects
64
+
65
+
66
+ def _load_project_sessions(storage_dir: Path, project_id: str) -> list[SessionInfo]:
67
+ session_root = storage_dir / "session" / project_id
68
+ infos: list[SessionInfo] = []
69
+ if not session_root.exists():
70
+ return infos
71
+
72
+ for file in sorted(session_root.glob("ses_*.json")):
73
+ data = _read_json_file(file)
74
+ if not isinstance(data, dict):
75
+ continue
76
+ t = data.get("time") or {}
77
+ s = data.get("summary") or {}
78
+ infos.append(
79
+ SessionInfo(
80
+ id=str(data.get("id", "")),
81
+ project_id=str(data.get("projectID", project_id)),
82
+ directory=str(data.get("directory", "")),
83
+ title=str(data.get("title", "Untitled Session")),
84
+ slug=data.get("slug"),
85
+ version=data.get("version"),
86
+ created_ms=t.get("created"),
87
+ updated_ms=t.get("updated"),
88
+ additions=int(s.get("additions", 0) or 0),
89
+ deletions=int(s.get("deletions", 0) or 0),
90
+ files=int(s.get("files", 0) or 0),
91
+ )
92
+ )
93
+ return infos
94
+
95
+
96
+ def _part_sort_key(part: dict[str, Any]) -> tuple[int, int, str]:
97
+ time_data = part.get("time") or {}
98
+ start = int(time_data.get("start", 0) or 0)
99
+ end = int(time_data.get("end", 0) or 0)
100
+ return (start, end, str(part.get("id", "")))
101
+
102
+
103
+ def _load_parts_for_message(storage_dir: Path, message_id: str) -> list[dict[str, Any]]:
104
+ part_dir = storage_dir / "part" / message_id
105
+ if not part_dir.exists():
106
+ return []
107
+
108
+ items: list[dict[str, Any]] = []
109
+ for file in sorted(part_dir.glob("prt_*.json")):
110
+ part = _read_json_file(file)
111
+ if isinstance(part, dict):
112
+ items.append(part)
113
+ items.sort(key=_part_sort_key)
114
+ return items
115
+
116
+
117
+ def _load_messages(storage_dir: Path, session_id: str) -> list[Message]:
118
+ message_dir = storage_dir / "message" / session_id
119
+ if not message_dir.exists():
120
+ return []
121
+
122
+ messages: list[Message] = []
123
+ for file in sorted(message_dir.glob("msg_*.json")):
124
+ data = _read_json_file(file)
125
+ if not isinstance(data, dict):
126
+ continue
127
+ m = normalize_message(
128
+ raw=data,
129
+ session_id=session_id,
130
+ parts=_load_parts_for_message(storage_dir, str(data.get("id", ""))),
131
+ )
132
+ messages.append(m)
133
+
134
+ messages.sort(key=lambda x: ((x.created_ms or 0), x.id))
135
+ return messages
136
+
137
+
138
+ def _load_todos(storage_dir: Path, session_id: str) -> list[TodoItem]:
139
+ todo_path = storage_dir / "todo" / f"{session_id}.json"
140
+ data = _read_json_file(todo_path)
141
+ if not isinstance(data, list):
142
+ return []
143
+
144
+ items: list[TodoItem] = []
145
+ for row in data:
146
+ if not isinstance(row, dict):
147
+ continue
148
+ items.append(
149
+ TodoItem(
150
+ id=str(row.get("id", "")),
151
+ content=str(row.get("content", "")),
152
+ status=str(row.get("status", "pending")),
153
+ priority=str(row.get("priority", "medium")),
154
+ )
155
+ )
156
+ return items
157
+
158
+
159
+ def _load_session_diffs(storage_dir: Path, session_id: str) -> list[SessionDiffItem]:
160
+ diff_path = storage_dir / "session_diff" / f"{session_id}.json"
161
+ data = _read_json_file(diff_path)
162
+ if not isinstance(data, list):
163
+ return []
164
+
165
+ items: list[SessionDiffItem] = []
166
+ for row in data:
167
+ if not isinstance(row, dict):
168
+ continue
169
+ items.append(
170
+ SessionDiffItem(
171
+ file=str(row.get("file", "")),
172
+ status=str(row.get("status", "modified")),
173
+ additions=int(row.get("additions", 0) or 0),
174
+ deletions=int(row.get("deletions", 0) or 0),
175
+ )
176
+ )
177
+ return items
178
+
179
+
180
+ def _session_in_range(
181
+ session: SessionInfo,
182
+ from_ms: int | None,
183
+ to_ms: int | None,
184
+ ) -> bool:
185
+ if from_ms is None and to_ms is None:
186
+ return True
187
+ updated = session.updated_ms or session.created_ms or 0
188
+ if from_ms is not None and updated < from_ms:
189
+ return False
190
+ if to_ms is not None and updated > to_ms:
191
+ return False
192
+ return True
193
+
194
+
195
+ def _message_in_range(message: Message, from_ms: int | None, to_ms: int | None) -> bool:
196
+ if from_ms is None and to_ms is None:
197
+ return True
198
+ ts = message.created_ms or 0
199
+ if from_ms is not None and ts < from_ms:
200
+ return False
201
+ if to_ms is not None and ts > to_ms:
202
+ return False
203
+ return True
204
+
205
+
206
+ def load_project_sessions(
207
+ storage_dir: Path,
208
+ project_id: str,
209
+ from_ms: int | None = None,
210
+ to_ms: int | None = None,
211
+ max_sessions: int | None = None,
212
+ cache_manager: "CacheManager | None" = None,
213
+ include_todos: bool = True,
214
+ include_diffs: bool = True,
215
+ ) -> list[Session]:
216
+ result: list[Session] = []
217
+ infos = _load_project_sessions(storage_dir, project_id)
218
+ infos.sort(key=lambda x: ((x.updated_ms or x.created_ms or 0), x.id), reverse=True)
219
+
220
+ for info in infos:
221
+ if not _session_in_range(info, from_ms, to_ms):
222
+ continue
223
+ cached = (
224
+ cache_manager.get_session(info.id, info.updated_ms)
225
+ if cache_manager
226
+ else None
227
+ )
228
+ if cached is not None:
229
+ session_loaded = cached
230
+ else:
231
+ session_loaded = Session(
232
+ info=info,
233
+ messages=_load_messages(storage_dir, info.id),
234
+ todos=_load_todos(storage_dir, info.id),
235
+ diffs=_load_session_diffs(storage_dir, info.id),
236
+ )
237
+ if cache_manager:
238
+ cache_manager.set_session(session_loaded)
239
+
240
+ messages = session_loaded.messages
241
+ if from_ms is not None or to_ms is not None:
242
+ messages = [m for m in messages if _message_in_range(m, from_ms, to_ms)]
243
+ if not messages:
244
+ continue
245
+ result.append(
246
+ Session(
247
+ info=info,
248
+ messages=messages,
249
+ todos=session_loaded.todos if include_todos else [],
250
+ diffs=session_loaded.diffs if include_diffs else [],
251
+ )
252
+ )
253
+ if max_sessions is not None and len(result) >= max_sessions:
254
+ break
255
+
256
+ result.sort(
257
+ key=lambda s: (s.info.updated_ms or s.info.created_ms or 0, s.info.id),
258
+ reverse=True,
259
+ )
260
+ return result
261
+
262
+
263
+ def safe_slug(text: str) -> str:
264
+ value = text.strip().lower()
265
+ value = re.sub(r"[^a-z0-9._-]+", "-", value)
266
+ value = re.sub(r"-+", "-", value).strip("-")
267
+ return value or "project"
268
+
269
+
270
+ def get_storage_schema_warnings(storage_dir: Path) -> list[str]:
271
+ return inspect_storage_schema(storage_dir)