repr-cli 0.2.16__py3-none-any.whl → 0.2.18__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.
- repr/__init__.py +1 -1
- repr/api.py +363 -62
- repr/auth.py +47 -38
- repr/change_synthesis.py +478 -0
- repr/cli.py +4306 -364
- repr/config.py +119 -11
- repr/configure.py +889 -0
- repr/cron.py +419 -0
- repr/dashboard/__init__.py +9 -0
- repr/dashboard/build.py +126 -0
- repr/dashboard/dist/assets/index-B-aCjaCw.js +384 -0
- repr/dashboard/dist/assets/index-BYFVbEev.css +1 -0
- repr/dashboard/dist/assets/index-BrrhyJFO.css +1 -0
- repr/dashboard/dist/assets/index-C7Gzxc4f.js +384 -0
- repr/dashboard/dist/assets/index-CQdMXo6g.js +391 -0
- repr/dashboard/dist/assets/index-CcEg74ts.js +270 -0
- repr/dashboard/dist/assets/index-Cerc-iA_.js +377 -0
- repr/dashboard/dist/assets/index-CjVcBW2L.css +1 -0
- repr/dashboard/dist/assets/index-Cs8ofFGd.js +384 -0
- repr/dashboard/dist/assets/index-Dfl3mR5E.js +377 -0
- repr/dashboard/dist/assets/index-DwN0SeMc.css +1 -0
- repr/dashboard/dist/assets/index-YFch_e0S.js +384 -0
- repr/dashboard/dist/favicon.svg +4 -0
- repr/dashboard/dist/index.html +14 -0
- repr/dashboard/manager.py +234 -0
- repr/dashboard/server.py +1489 -0
- repr/db.py +980 -0
- repr/hooks.py +3 -2
- repr/loaders/__init__.py +22 -0
- repr/loaders/base.py +156 -0
- repr/loaders/claude_code.py +287 -0
- repr/loaders/clawdbot.py +313 -0
- repr/loaders/gemini_antigravity.py +381 -0
- repr/mcp_server.py +1196 -0
- repr/models.py +503 -0
- repr/openai_analysis.py +25 -0
- repr/session_extractor.py +481 -0
- repr/storage.py +328 -0
- repr/story_synthesis.py +1296 -0
- repr/templates.py +68 -4
- repr/timeline.py +710 -0
- repr/tools.py +17 -8
- {repr_cli-0.2.16.dist-info → repr_cli-0.2.18.dist-info}/METADATA +48 -10
- repr_cli-0.2.18.dist-info/RECORD +58 -0
- {repr_cli-0.2.16.dist-info → repr_cli-0.2.18.dist-info}/WHEEL +1 -1
- {repr_cli-0.2.16.dist-info → repr_cli-0.2.18.dist-info}/entry_points.txt +1 -0
- repr_cli-0.2.16.dist-info/RECORD +0 -26
- {repr_cli-0.2.16.dist-info → repr_cli-0.2.18.dist-info}/licenses/LICENSE +0 -0
- {repr_cli-0.2.16.dist-info → repr_cli-0.2.18.dist-info}/top_level.txt +0 -0
repr/cron.py
ADDED
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Cron-based story generation scheduling for repr.
|
|
3
|
+
|
|
4
|
+
Provides predictable, scheduled story generation as an alternative to
|
|
5
|
+
hook-triggered generation. Runs every 4 hours by default, skipping
|
|
6
|
+
if there aren't enough commits in the queue.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
11
|
+
import subprocess
|
|
12
|
+
import sys
|
|
13
|
+
from datetime import datetime
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from .config import REPR_HOME, load_config, save_config
|
|
18
|
+
|
|
19
|
+
# Cron job identifier - used to find/modify our entry
|
|
20
|
+
CRON_MARKER = "# repr-auto-generate"
|
|
21
|
+
CRON_MARKER_PAUSED = "# repr-auto-generate-PAUSED"
|
|
22
|
+
|
|
23
|
+
# Default interval: every 4 hours
|
|
24
|
+
DEFAULT_INTERVAL_HOURS = 4
|
|
25
|
+
|
|
26
|
+
# Minimum commits needed to trigger generation
|
|
27
|
+
DEFAULT_MIN_COMMITS = 3
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _get_repr_path() -> str:
|
|
31
|
+
"""Get the path to repr executable."""
|
|
32
|
+
# Check common installation paths first
|
|
33
|
+
common_paths = [
|
|
34
|
+
"/usr/local/bin/repr",
|
|
35
|
+
"/opt/homebrew/bin/repr",
|
|
36
|
+
Path.home() / ".local/bin/repr",
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
for p in common_paths:
|
|
40
|
+
if Path(p).exists():
|
|
41
|
+
return str(p)
|
|
42
|
+
|
|
43
|
+
# Try to find repr in PATH (avoiding venv paths)
|
|
44
|
+
try:
|
|
45
|
+
result = subprocess.run(
|
|
46
|
+
["which", "-a", "repr"],
|
|
47
|
+
capture_output=True,
|
|
48
|
+
text=True,
|
|
49
|
+
)
|
|
50
|
+
if result.returncode == 0:
|
|
51
|
+
for path in result.stdout.strip().split('\n'):
|
|
52
|
+
# Skip venv/virtualenv paths
|
|
53
|
+
if 'venv' not in path and '.venv' not in path:
|
|
54
|
+
return path
|
|
55
|
+
# If all are venv, use the first one
|
|
56
|
+
return result.stdout.strip().split('\n')[0]
|
|
57
|
+
except Exception:
|
|
58
|
+
pass
|
|
59
|
+
|
|
60
|
+
# Fallback to python -m repr (using system python, not venv)
|
|
61
|
+
return "python3 -m repr"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _get_helper_script_path() -> Path:
|
|
65
|
+
"""Get path to the cron helper script."""
|
|
66
|
+
return REPR_HOME / "bin" / "cron-generate.sh"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _create_helper_script(min_commits: int = DEFAULT_MIN_COMMITS) -> Path:
|
|
70
|
+
"""
|
|
71
|
+
Create the helper script for cron job.
|
|
72
|
+
|
|
73
|
+
This is cleaner than embedding complex shell in crontab.
|
|
74
|
+
"""
|
|
75
|
+
repr_path = _get_repr_path()
|
|
76
|
+
log_file = REPR_HOME / "logs" / "cron.log"
|
|
77
|
+
|
|
78
|
+
script_path = _get_helper_script_path()
|
|
79
|
+
script_path.parent.mkdir(parents=True, exist_ok=True)
|
|
80
|
+
|
|
81
|
+
script = f'''#!/bin/bash
|
|
82
|
+
# repr cron helper - auto-generated, do not edit
|
|
83
|
+
# Checks queue and generates stories if enough commits
|
|
84
|
+
|
|
85
|
+
LOG="{log_file}"
|
|
86
|
+
MIN_COMMITS={min_commits}
|
|
87
|
+
|
|
88
|
+
# Get total queue count across all repos
|
|
89
|
+
QUEUE_COUNT=$({repr_path} hooks status --json 2>/dev/null | \\
|
|
90
|
+
python3 -c "import sys,json; d=json.load(sys.stdin); print(sum(r.get('queue_count',0) for r in d))" 2>/dev/null || echo 0)
|
|
91
|
+
|
|
92
|
+
if [ "$QUEUE_COUNT" -ge "$MIN_COMMITS" ]; then
|
|
93
|
+
echo "[$(date -Iseconds)] Generating stories (queue: $QUEUE_COUNT commits)" >> "$LOG"
|
|
94
|
+
{repr_path} generate --local >> "$LOG" 2>&1
|
|
95
|
+
else
|
|
96
|
+
echo "[$(date -Iseconds)] Skipping (queue: $QUEUE_COUNT < $MIN_COMMITS)" >> "$LOG"
|
|
97
|
+
fi
|
|
98
|
+
'''
|
|
99
|
+
|
|
100
|
+
script_path.write_text(script)
|
|
101
|
+
# Make executable
|
|
102
|
+
script_path.chmod(script_path.stat().st_mode | 0o755)
|
|
103
|
+
|
|
104
|
+
return script_path
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _get_cron_command(min_commits: int = DEFAULT_MIN_COMMITS) -> str:
|
|
108
|
+
"""
|
|
109
|
+
Build the cron command.
|
|
110
|
+
|
|
111
|
+
Creates a helper script and returns the path to run it.
|
|
112
|
+
"""
|
|
113
|
+
script_path = _create_helper_script(min_commits)
|
|
114
|
+
return str(script_path)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _get_cron_line(interval_hours: int = DEFAULT_INTERVAL_HOURS, min_commits: int = DEFAULT_MIN_COMMITS) -> str:
|
|
118
|
+
"""Build the full crontab line."""
|
|
119
|
+
cmd = _get_cron_command(min_commits)
|
|
120
|
+
# Run at minute 0 every N hours
|
|
121
|
+
# Format: minute hour day month weekday command
|
|
122
|
+
return f"0 */{interval_hours} * * * {cmd} {CRON_MARKER}"
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _read_crontab() -> list[str]:
|
|
126
|
+
"""Read current user's crontab."""
|
|
127
|
+
try:
|
|
128
|
+
result = subprocess.run(
|
|
129
|
+
["crontab", "-l"],
|
|
130
|
+
capture_output=True,
|
|
131
|
+
text=True,
|
|
132
|
+
)
|
|
133
|
+
if result.returncode == 0:
|
|
134
|
+
return result.stdout.strip().split('\n') if result.stdout.strip() else []
|
|
135
|
+
return []
|
|
136
|
+
except Exception:
|
|
137
|
+
return []
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _write_crontab(lines: list[str]) -> bool:
|
|
141
|
+
"""Write lines to user's crontab."""
|
|
142
|
+
try:
|
|
143
|
+
content = '\n'.join(lines) + '\n' if lines else ''
|
|
144
|
+
result = subprocess.run(
|
|
145
|
+
["crontab", "-"],
|
|
146
|
+
input=content,
|
|
147
|
+
capture_output=True,
|
|
148
|
+
text=True,
|
|
149
|
+
)
|
|
150
|
+
return result.returncode == 0
|
|
151
|
+
except Exception:
|
|
152
|
+
return False
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _find_repr_cron_line(lines: list[str]) -> tuple[int, bool]:
|
|
156
|
+
"""
|
|
157
|
+
Find repr cron line in crontab.
|
|
158
|
+
|
|
159
|
+
Returns:
|
|
160
|
+
(index, is_paused) - index is -1 if not found
|
|
161
|
+
"""
|
|
162
|
+
for i, line in enumerate(lines):
|
|
163
|
+
# Check PAUSED first since MARKER is a substring of MARKER_PAUSED
|
|
164
|
+
if CRON_MARKER_PAUSED in line:
|
|
165
|
+
return i, True
|
|
166
|
+
if CRON_MARKER in line:
|
|
167
|
+
return i, False
|
|
168
|
+
return -1, False
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def get_cron_status() -> dict[str, Any]:
|
|
172
|
+
"""
|
|
173
|
+
Get current cron job status.
|
|
174
|
+
|
|
175
|
+
Returns:
|
|
176
|
+
Dict with 'installed', 'paused', 'interval_hours', 'next_run' keys
|
|
177
|
+
"""
|
|
178
|
+
lines = _read_crontab()
|
|
179
|
+
idx, is_paused = _find_repr_cron_line(lines)
|
|
180
|
+
|
|
181
|
+
if idx == -1:
|
|
182
|
+
return {
|
|
183
|
+
"installed": False,
|
|
184
|
+
"paused": False,
|
|
185
|
+
"interval_hours": None,
|
|
186
|
+
"cron_line": None,
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
line = lines[idx]
|
|
190
|
+
|
|
191
|
+
# Parse interval from cron expression (e.g., "0 */4 * * *")
|
|
192
|
+
interval_hours = DEFAULT_INTERVAL_HOURS
|
|
193
|
+
match = re.search(r'\*/(\d+)', line)
|
|
194
|
+
if match:
|
|
195
|
+
interval_hours = int(match.group(1))
|
|
196
|
+
|
|
197
|
+
return {
|
|
198
|
+
"installed": True,
|
|
199
|
+
"paused": is_paused,
|
|
200
|
+
"interval_hours": interval_hours,
|
|
201
|
+
"cron_line": line if not is_paused else line.lstrip('# '),
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def install_cron(
|
|
206
|
+
interval_hours: int = DEFAULT_INTERVAL_HOURS,
|
|
207
|
+
min_commits: int = DEFAULT_MIN_COMMITS,
|
|
208
|
+
) -> dict[str, Any]:
|
|
209
|
+
"""
|
|
210
|
+
Install cron job for automatic story generation.
|
|
211
|
+
|
|
212
|
+
Args:
|
|
213
|
+
interval_hours: Hours between runs (default 4)
|
|
214
|
+
min_commits: Minimum commits in queue to trigger generation
|
|
215
|
+
|
|
216
|
+
Returns:
|
|
217
|
+
Dict with 'success', 'message', 'already_installed' keys
|
|
218
|
+
"""
|
|
219
|
+
# Ensure log directory exists
|
|
220
|
+
log_dir = REPR_HOME / "logs"
|
|
221
|
+
log_dir.mkdir(parents=True, exist_ok=True)
|
|
222
|
+
|
|
223
|
+
lines = _read_crontab()
|
|
224
|
+
idx, is_paused = _find_repr_cron_line(lines)
|
|
225
|
+
|
|
226
|
+
cron_line = _get_cron_line(interval_hours, min_commits)
|
|
227
|
+
|
|
228
|
+
if idx >= 0:
|
|
229
|
+
if is_paused:
|
|
230
|
+
# Replace paused line with active one
|
|
231
|
+
lines[idx] = cron_line
|
|
232
|
+
if _write_crontab(lines):
|
|
233
|
+
_update_config_cron_status(True, interval_hours, min_commits)
|
|
234
|
+
return {
|
|
235
|
+
"success": True,
|
|
236
|
+
"message": "Cron job resumed and updated",
|
|
237
|
+
"already_installed": False,
|
|
238
|
+
}
|
|
239
|
+
return {
|
|
240
|
+
"success": False,
|
|
241
|
+
"message": "Failed to write crontab",
|
|
242
|
+
"already_installed": False,
|
|
243
|
+
}
|
|
244
|
+
else:
|
|
245
|
+
# Update existing active line
|
|
246
|
+
lines[idx] = cron_line
|
|
247
|
+
if _write_crontab(lines):
|
|
248
|
+
_update_config_cron_status(True, interval_hours, min_commits)
|
|
249
|
+
return {
|
|
250
|
+
"success": True,
|
|
251
|
+
"message": "Cron job updated",
|
|
252
|
+
"already_installed": True,
|
|
253
|
+
}
|
|
254
|
+
return {
|
|
255
|
+
"success": False,
|
|
256
|
+
"message": "Failed to write crontab",
|
|
257
|
+
"already_installed": True,
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
# Add new line
|
|
261
|
+
lines.append(cron_line)
|
|
262
|
+
if _write_crontab(lines):
|
|
263
|
+
_update_config_cron_status(True, interval_hours, min_commits)
|
|
264
|
+
return {
|
|
265
|
+
"success": True,
|
|
266
|
+
"message": f"Cron job installed (every {interval_hours}h, min {min_commits} commits)",
|
|
267
|
+
"already_installed": False,
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
return {
|
|
271
|
+
"success": False,
|
|
272
|
+
"message": "Failed to write crontab",
|
|
273
|
+
"already_installed": False,
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def remove_cron() -> dict[str, Any]:
|
|
278
|
+
"""
|
|
279
|
+
Remove cron job for story generation.
|
|
280
|
+
|
|
281
|
+
Returns:
|
|
282
|
+
Dict with 'success', 'message' keys
|
|
283
|
+
"""
|
|
284
|
+
lines = _read_crontab()
|
|
285
|
+
idx, _ = _find_repr_cron_line(lines)
|
|
286
|
+
|
|
287
|
+
if idx == -1:
|
|
288
|
+
return {
|
|
289
|
+
"success": True,
|
|
290
|
+
"message": "No cron job to remove",
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
lines.pop(idx)
|
|
294
|
+
if _write_crontab(lines):
|
|
295
|
+
_update_config_cron_status(False, None, None)
|
|
296
|
+
return {
|
|
297
|
+
"success": True,
|
|
298
|
+
"message": "Cron job removed",
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
return {
|
|
302
|
+
"success": False,
|
|
303
|
+
"message": "Failed to write crontab",
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def pause_cron() -> dict[str, Any]:
|
|
308
|
+
"""
|
|
309
|
+
Pause cron job by commenting it out.
|
|
310
|
+
|
|
311
|
+
Returns:
|
|
312
|
+
Dict with 'success', 'message' keys
|
|
313
|
+
"""
|
|
314
|
+
lines = _read_crontab()
|
|
315
|
+
idx, is_paused = _find_repr_cron_line(lines)
|
|
316
|
+
|
|
317
|
+
if idx == -1:
|
|
318
|
+
return {
|
|
319
|
+
"success": False,
|
|
320
|
+
"message": "No cron job installed",
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
if is_paused:
|
|
324
|
+
return {
|
|
325
|
+
"success": True,
|
|
326
|
+
"message": "Cron job already paused",
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
# Comment out the line, change marker to PAUSED
|
|
330
|
+
line = lines[idx]
|
|
331
|
+
paused_line = "# " + line.replace(CRON_MARKER, CRON_MARKER_PAUSED)
|
|
332
|
+
lines[idx] = paused_line
|
|
333
|
+
|
|
334
|
+
if _write_crontab(lines):
|
|
335
|
+
config = load_config()
|
|
336
|
+
if "cron" not in config:
|
|
337
|
+
config["cron"] = {}
|
|
338
|
+
config["cron"]["paused"] = True
|
|
339
|
+
save_config(config)
|
|
340
|
+
return {
|
|
341
|
+
"success": True,
|
|
342
|
+
"message": "Cron job paused",
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
return {
|
|
346
|
+
"success": False,
|
|
347
|
+
"message": "Failed to write crontab",
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def resume_cron() -> dict[str, Any]:
|
|
352
|
+
"""
|
|
353
|
+
Resume paused cron job.
|
|
354
|
+
|
|
355
|
+
Returns:
|
|
356
|
+
Dict with 'success', 'message' keys
|
|
357
|
+
"""
|
|
358
|
+
lines = _read_crontab()
|
|
359
|
+
idx, is_paused = _find_repr_cron_line(lines)
|
|
360
|
+
|
|
361
|
+
if idx == -1:
|
|
362
|
+
return {
|
|
363
|
+
"success": False,
|
|
364
|
+
"message": "No cron job installed",
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
if not is_paused:
|
|
368
|
+
return {
|
|
369
|
+
"success": True,
|
|
370
|
+
"message": "Cron job already active",
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
# Uncomment the line, change marker back
|
|
374
|
+
line = lines[idx].lstrip('# ')
|
|
375
|
+
active_line = line.replace(CRON_MARKER_PAUSED, CRON_MARKER)
|
|
376
|
+
lines[idx] = active_line
|
|
377
|
+
|
|
378
|
+
if _write_crontab(lines):
|
|
379
|
+
config = load_config()
|
|
380
|
+
if "cron" not in config:
|
|
381
|
+
config["cron"] = {}
|
|
382
|
+
config["cron"]["paused"] = False
|
|
383
|
+
save_config(config)
|
|
384
|
+
return {
|
|
385
|
+
"success": True,
|
|
386
|
+
"message": "Cron job resumed",
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
return {
|
|
390
|
+
"success": False,
|
|
391
|
+
"message": "Failed to write crontab",
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def _update_config_cron_status(
|
|
396
|
+
installed: bool,
|
|
397
|
+
interval_hours: int | None,
|
|
398
|
+
min_commits: int | None,
|
|
399
|
+
) -> None:
|
|
400
|
+
"""Update cron status in config file."""
|
|
401
|
+
config = load_config()
|
|
402
|
+
|
|
403
|
+
if installed:
|
|
404
|
+
config["cron"] = {
|
|
405
|
+
"installed": True,
|
|
406
|
+
"paused": False,
|
|
407
|
+
"interval_hours": interval_hours,
|
|
408
|
+
"min_commits": min_commits,
|
|
409
|
+
"installed_at": datetime.now().isoformat(),
|
|
410
|
+
}
|
|
411
|
+
else:
|
|
412
|
+
config["cron"] = {
|
|
413
|
+
"installed": False,
|
|
414
|
+
"paused": False,
|
|
415
|
+
"interval_hours": None,
|
|
416
|
+
"min_commits": None,
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
save_config(config)
|
repr/dashboard/build.py
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Build script for the dashboard
|
|
4
|
+
Inlines all CSS and JavaScript into a single HTML file for distribution
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def read_file(path: Path) -> str:
|
|
12
|
+
"""Read file contents"""
|
|
13
|
+
with open(path, 'r', encoding='utf-8') as f:
|
|
14
|
+
return f.read()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def inline_css(html: str, src_dir: Path) -> str:
|
|
18
|
+
"""Replace CSS link tags with inline styles"""
|
|
19
|
+
styles = []
|
|
20
|
+
|
|
21
|
+
# Read all CSS files
|
|
22
|
+
css_files = [
|
|
23
|
+
src_dir / 'styles' / 'main.css',
|
|
24
|
+
src_dir / 'styles' / 'components.css'
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
for css_file in css_files:
|
|
28
|
+
if css_file.exists():
|
|
29
|
+
styles.append(read_file(css_file))
|
|
30
|
+
|
|
31
|
+
# Combine all styles
|
|
32
|
+
combined_styles = '\n'.join(styles)
|
|
33
|
+
|
|
34
|
+
# Replace link tags with inline style
|
|
35
|
+
import re
|
|
36
|
+
pattern = r'<link rel="stylesheet" href="styles/[^"]+\.css">'
|
|
37
|
+
|
|
38
|
+
# Find first occurrence and replace with all styles
|
|
39
|
+
match = re.search(pattern, html)
|
|
40
|
+
if match:
|
|
41
|
+
replacement = f'<style>\n{combined_styles}\n </style>'
|
|
42
|
+
html = html[:match.start()] + replacement + re.sub(pattern, '', html[match.end():])
|
|
43
|
+
|
|
44
|
+
return html
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def inline_js(html: str, src_dir: Path) -> str:
|
|
48
|
+
"""Replace JavaScript script tags with inline scripts"""
|
|
49
|
+
scripts = []
|
|
50
|
+
|
|
51
|
+
# Read all JS files in order
|
|
52
|
+
js_files = [
|
|
53
|
+
src_dir / 'scripts' / 'utils.js',
|
|
54
|
+
src_dir / 'scripts' / 'api.js',
|
|
55
|
+
src_dir / 'scripts' / 'state.js',
|
|
56
|
+
src_dir / 'scripts' / 'theme.js',
|
|
57
|
+
src_dir / 'scripts' / 'keyboard.js',
|
|
58
|
+
src_dir / 'scripts' / 'stories.js',
|
|
59
|
+
src_dir / 'scripts' / 'settings.js',
|
|
60
|
+
src_dir / 'scripts' / 'repos.js'
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
for js_file in js_files:
|
|
64
|
+
if js_file.exists():
|
|
65
|
+
scripts.append(read_file(js_file))
|
|
66
|
+
|
|
67
|
+
# Combine all scripts
|
|
68
|
+
combined_scripts = '\n\n'.join(scripts)
|
|
69
|
+
|
|
70
|
+
# Replace script tags with inline script
|
|
71
|
+
import re
|
|
72
|
+
pattern = r'<script src="scripts/[^"]+\.js"></script>'
|
|
73
|
+
|
|
74
|
+
# Find first occurrence and replace with all scripts
|
|
75
|
+
match = re.search(pattern, html)
|
|
76
|
+
if match:
|
|
77
|
+
replacement = f'<script>\n{combined_scripts}\n </script>'
|
|
78
|
+
html = html[:match.start()] + replacement + re.sub(pattern, '', html[match.end():])
|
|
79
|
+
|
|
80
|
+
return html
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def build():
|
|
84
|
+
"""Build the single-file dashboard"""
|
|
85
|
+
# Get paths
|
|
86
|
+
dashboard_dir = Path(__file__).parent
|
|
87
|
+
src_dir = dashboard_dir / 'src'
|
|
88
|
+
src_html = src_dir / 'index.html'
|
|
89
|
+
output_html = dashboard_dir / 'index.html'
|
|
90
|
+
|
|
91
|
+
if not src_html.exists():
|
|
92
|
+
print(f"Error: Source HTML not found at {src_html}")
|
|
93
|
+
return 1
|
|
94
|
+
|
|
95
|
+
print(f"Building dashboard from {src_dir}...")
|
|
96
|
+
|
|
97
|
+
# Read source HTML
|
|
98
|
+
html = read_file(src_html)
|
|
99
|
+
|
|
100
|
+
# Inline CSS
|
|
101
|
+
print(" Inlining CSS...")
|
|
102
|
+
html = inline_css(html, src_dir)
|
|
103
|
+
|
|
104
|
+
# Inline JavaScript
|
|
105
|
+
print(" Inlining JavaScript...")
|
|
106
|
+
html = inline_js(html, src_dir)
|
|
107
|
+
|
|
108
|
+
# Write output
|
|
109
|
+
print(f" Writing to {output_html}...")
|
|
110
|
+
with open(output_html, 'w', encoding='utf-8') as f:
|
|
111
|
+
f.write(html)
|
|
112
|
+
|
|
113
|
+
# Get file sizes
|
|
114
|
+
src_size = sum(f.stat().st_size for f in src_dir.rglob('*') if f.is_file())
|
|
115
|
+
output_size = output_html.stat().st_size
|
|
116
|
+
|
|
117
|
+
print(f"\n✓ Build complete!")
|
|
118
|
+
print(f" Source: {len(list(src_dir.rglob('*.js')))} JS + {len(list(src_dir.rglob('*.css')))} CSS files ({src_size:,} bytes)")
|
|
119
|
+
print(f" Output: {output_html.name} ({output_size:,} bytes)")
|
|
120
|
+
print(f" Lines: {len(html.splitlines())}")
|
|
121
|
+
|
|
122
|
+
return 0
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
if __name__ == '__main__':
|
|
126
|
+
exit(build())
|