jekyll-theme-zer0 0.22.0 → 0.22.19
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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +236 -0
- data/README.md +66 -19
- data/_data/navigation/admin.yml +53 -0
- data/_data/theme_backgrounds.yml +121 -0
- data/_includes/components/admin-tabs.html +59 -0
- data/_includes/components/analytics-dashboard.html +232 -0
- data/_includes/components/background-customizer.html +159 -0
- data/_includes/components/background-settings.html +137 -0
- data/_includes/components/collection-manager.html +151 -0
- data/_includes/components/component-showcase.html +452 -0
- data/_includes/components/config-editor.html +207 -0
- data/_includes/components/config-viewer.html +479 -0
- data/_includes/components/env-dashboard.html +154 -0
- data/_includes/components/feature-card.html +94 -0
- data/_includes/components/info-section.html +172 -149
- data/_includes/components/js-cdn.html +4 -1
- data/_includes/components/nav-editor.html +99 -0
- data/_includes/components/setup-banner.html +28 -0
- data/_includes/components/setup-check.html +53 -0
- data/_includes/components/svg-background.html +42 -0
- data/_includes/components/theme-customizer.html +46 -0
- data/_includes/content/seo.html +68 -135
- data/_includes/core/footer.html +1 -1
- data/_includes/core/head.html +3 -2
- data/_includes/core/header.html +14 -7
- data/_includes/landing/landing-install-cards.html +18 -7
- data/_includes/navigation/admin-nav.html +95 -0
- data/_includes/navigation/navbar.html +43 -5
- data/_includes/navigation/sidebar-left.html +1 -1
- data/_includes/setup/wizard.html +330 -0
- data/_layouts/admin.html +166 -0
- data/_layouts/landing.html +23 -9
- data/_layouts/root.html +12 -6
- data/_layouts/setup.html +73 -0
- data/_plugins/preview_image_generator.rb +26 -12
- data/_sass/core/_navbar.scss +2 -2
- data/_sass/custom.scss +28 -6
- data/_sass/theme/_background-mixins.scss +95 -0
- data/_sass/theme/_backgrounds.scss +156 -0
- data/_sass/theme/_color-modes.scss +2 -1
- data/assets/backgrounds/gradients/air.svg +15 -0
- data/assets/backgrounds/gradients/aqua.svg +15 -0
- data/assets/backgrounds/gradients/contrast.svg +15 -0
- data/assets/backgrounds/gradients/dark.svg +15 -0
- data/assets/backgrounds/gradients/dirt.svg +15 -0
- data/assets/backgrounds/gradients/mint.svg +15 -0
- data/assets/backgrounds/gradients/neon.svg +15 -0
- data/assets/backgrounds/gradients/plum.svg +15 -0
- data/assets/backgrounds/gradients/sunrise.svg +15 -0
- data/assets/backgrounds/noise/air.svg +8 -0
- data/assets/backgrounds/noise/aqua.svg +8 -0
- data/assets/backgrounds/noise/contrast.svg +8 -0
- data/assets/backgrounds/noise/dark.svg +8 -0
- data/assets/backgrounds/noise/dirt.svg +8 -0
- data/assets/backgrounds/noise/mint.svg +8 -0
- data/assets/backgrounds/noise/neon.svg +8 -0
- data/assets/backgrounds/noise/plum.svg +8 -0
- data/assets/backgrounds/noise/sunrise.svg +8 -0
- data/assets/backgrounds/patterns/air.svg +7 -0
- data/assets/backgrounds/patterns/aqua.svg +7 -0
- data/assets/backgrounds/patterns/contrast.svg +4 -0
- data/assets/backgrounds/patterns/dark.svg +5 -0
- data/assets/backgrounds/patterns/dirt.svg +5 -0
- data/assets/backgrounds/patterns/mint.svg +6 -0
- data/assets/backgrounds/patterns/neon.svg +6 -0
- data/assets/backgrounds/patterns/plum.svg +6 -0
- data/assets/backgrounds/patterns/sunrise.svg +5 -0
- data/assets/js/background-customizer.js +73 -0
- data/assets/js/code-copy.js +18 -47
- data/assets/js/config-utility.js +307 -0
- data/assets/js/nav-editor.js +39 -0
- data/assets/js/palette-generator.js +415 -0
- data/assets/js/search-modal.js +31 -11
- data/assets/js/setup-wizard.js +306 -0
- data/assets/js/skin-editor.js +645 -0
- data/assets/js/theme-customizer.js +102 -0
- data/assets/js/ui-enhancements.js +15 -24
- data/assets/vendor/bootstrap/css/bootstrap.min.css +1 -0
- data/assets/vendor/bootstrap/js/bootstrap.bundle.min.js +1 -0
- data/scripts/README.md +45 -0
- data/scripts/features/generate-preview-images +297 -7
- data/scripts/features/install-preview-generator +51 -33
- data/scripts/fork-cleanup.sh +92 -19
- data/scripts/github-setup.sh +284 -0
- data/scripts/init_setup.sh +0 -1
- data/scripts/lib/frontmatter.sh +543 -0
- data/scripts/lib/migrate.sh +265 -0
- data/scripts/lib/preview_generator.py +607 -32
- data/scripts/lint-pages +505 -0
- data/scripts/migrate.sh +201 -0
- data/scripts/platform/setup-linux.sh +244 -0
- data/scripts/platform/setup-macos.sh +187 -0
- data/scripts/platform/setup-wsl.sh +196 -0
- metadata +71 -6
|
@@ -23,9 +23,14 @@ import json
|
|
|
23
23
|
import os
|
|
24
24
|
import re
|
|
25
25
|
import sys
|
|
26
|
-
|
|
26
|
+
import signal
|
|
27
|
+
import time
|
|
28
|
+
import threading
|
|
29
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
30
|
+
from dataclasses import dataclass, field
|
|
31
|
+
from datetime import datetime, timedelta
|
|
27
32
|
from pathlib import Path
|
|
28
|
-
from typing import Optional, List, Dict, Any
|
|
33
|
+
from typing import Optional, List, Dict, Any, TextIO, Tuple
|
|
29
34
|
import yaml
|
|
30
35
|
|
|
31
36
|
# Optional imports with fallback
|
|
@@ -42,6 +47,81 @@ except ImportError:
|
|
|
42
47
|
HAS_OPENAI = False
|
|
43
48
|
|
|
44
49
|
|
|
50
|
+
def _load_dotenv():
|
|
51
|
+
"""Load environment variables from .env file if present."""
|
|
52
|
+
# Search for .env in cwd and parent directories
|
|
53
|
+
search_dir = Path.cwd()
|
|
54
|
+
for _ in range(5): # limit search depth
|
|
55
|
+
env_file = search_dir / '.env'
|
|
56
|
+
if env_file.is_file():
|
|
57
|
+
with open(env_file) as f:
|
|
58
|
+
for line in f:
|
|
59
|
+
line = line.strip()
|
|
60
|
+
if not line or line.startswith('#'):
|
|
61
|
+
continue
|
|
62
|
+
if '=' in line:
|
|
63
|
+
key, _, value = line.partition('=')
|
|
64
|
+
key = key.strip()
|
|
65
|
+
value = value.strip().strip('"').strip("'")
|
|
66
|
+
if key and key not in os.environ:
|
|
67
|
+
os.environ[key] = value
|
|
68
|
+
return
|
|
69
|
+
parent = search_dir.parent
|
|
70
|
+
if parent == search_dir:
|
|
71
|
+
break
|
|
72
|
+
search_dir = parent
|
|
73
|
+
|
|
74
|
+
_load_dotenv()
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# Global state for interrupt handling
|
|
78
|
+
_interrupted = False
|
|
79
|
+
_log_file: Optional[TextIO] = None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _signal_handler(signum, frame):
|
|
83
|
+
"""Handle interrupt signals gracefully."""
|
|
84
|
+
global _interrupted
|
|
85
|
+
_interrupted = True
|
|
86
|
+
print(f"\n{Colors.YELLOW}⚠️ Interrupt received. Finishing current tasks...{Colors.NC}")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class RateLimiter:
|
|
90
|
+
"""Token bucket rate limiter for API calls."""
|
|
91
|
+
|
|
92
|
+
def __init__(self, requests_per_minute: int = 5):
|
|
93
|
+
self.requests_per_minute = requests_per_minute
|
|
94
|
+
self.min_interval = 60.0 / requests_per_minute
|
|
95
|
+
self.lock = threading.Lock()
|
|
96
|
+
self.last_request_time = 0.0
|
|
97
|
+
self.request_count = 0
|
|
98
|
+
self.window_start = time.time()
|
|
99
|
+
|
|
100
|
+
def acquire(self) -> float:
|
|
101
|
+
"""Acquire permission to make a request. Returns time waited."""
|
|
102
|
+
with self.lock:
|
|
103
|
+
now = time.time()
|
|
104
|
+
if now - self.window_start >= 60.0:
|
|
105
|
+
self.window_start = now
|
|
106
|
+
self.request_count = 0
|
|
107
|
+
if self.request_count >= self.requests_per_minute:
|
|
108
|
+
wait_time = 60.0 - (now - self.window_start)
|
|
109
|
+
if wait_time > 0:
|
|
110
|
+
time.sleep(wait_time)
|
|
111
|
+
self.window_start = time.time()
|
|
112
|
+
self.request_count = 0
|
|
113
|
+
return wait_time
|
|
114
|
+
elapsed = now - self.last_request_time
|
|
115
|
+
if elapsed < self.min_interval:
|
|
116
|
+
wait_time = self.min_interval - elapsed
|
|
117
|
+
time.sleep(wait_time)
|
|
118
|
+
else:
|
|
119
|
+
wait_time = 0
|
|
120
|
+
self.last_request_time = time.time()
|
|
121
|
+
self.request_count += 1
|
|
122
|
+
return wait_time
|
|
123
|
+
|
|
124
|
+
|
|
45
125
|
@dataclass
|
|
46
126
|
class ContentFile:
|
|
47
127
|
"""Represents a Jekyll content file with its metadata."""
|
|
@@ -63,6 +143,192 @@ class GenerationResult:
|
|
|
63
143
|
preview_url: Optional[str]
|
|
64
144
|
error: Optional[str]
|
|
65
145
|
prompt_used: Optional[str]
|
|
146
|
+
duration: float = 0.0
|
|
147
|
+
file_path: Optional[Path] = None
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class ThreadSafeStats:
|
|
151
|
+
"""Thread-safe progress statistics."""
|
|
152
|
+
|
|
153
|
+
def __init__(self):
|
|
154
|
+
self.lock = threading.Lock()
|
|
155
|
+
self._total_files: int = 0
|
|
156
|
+
self._current_index: int = 0
|
|
157
|
+
self._processed: int = 0
|
|
158
|
+
self._generated: int = 0
|
|
159
|
+
self._skipped: int = 0
|
|
160
|
+
self._errors: int = 0
|
|
161
|
+
self._start_time: float = time.time()
|
|
162
|
+
self._generation_times: List[float] = []
|
|
163
|
+
self._active_workers: int = 0
|
|
164
|
+
self._pending_files: List[str] = []
|
|
165
|
+
|
|
166
|
+
@property
|
|
167
|
+
def total_files(self) -> int:
|
|
168
|
+
with self.lock:
|
|
169
|
+
return self._total_files
|
|
170
|
+
|
|
171
|
+
@total_files.setter
|
|
172
|
+
def total_files(self, value: int):
|
|
173
|
+
with self.lock:
|
|
174
|
+
self._total_files = value
|
|
175
|
+
|
|
176
|
+
@property
|
|
177
|
+
def current_index(self) -> int:
|
|
178
|
+
with self.lock:
|
|
179
|
+
return self._current_index
|
|
180
|
+
|
|
181
|
+
@current_index.setter
|
|
182
|
+
def current_index(self, value: int):
|
|
183
|
+
with self.lock:
|
|
184
|
+
self._current_index = value
|
|
185
|
+
|
|
186
|
+
@property
|
|
187
|
+
def processed(self) -> int:
|
|
188
|
+
with self.lock:
|
|
189
|
+
return self._processed
|
|
190
|
+
|
|
191
|
+
@property
|
|
192
|
+
def generated(self) -> int:
|
|
193
|
+
with self.lock:
|
|
194
|
+
return self._generated
|
|
195
|
+
|
|
196
|
+
@property
|
|
197
|
+
def skipped(self) -> int:
|
|
198
|
+
with self.lock:
|
|
199
|
+
return self._skipped
|
|
200
|
+
|
|
201
|
+
@property
|
|
202
|
+
def errors(self) -> int:
|
|
203
|
+
with self.lock:
|
|
204
|
+
return self._errors
|
|
205
|
+
|
|
206
|
+
@property
|
|
207
|
+
def active_workers(self) -> int:
|
|
208
|
+
with self.lock:
|
|
209
|
+
return self._active_workers
|
|
210
|
+
|
|
211
|
+
def increment_processed(self):
|
|
212
|
+
with self.lock:
|
|
213
|
+
self._processed += 1
|
|
214
|
+
self._current_index += 1
|
|
215
|
+
|
|
216
|
+
def increment_generated(self):
|
|
217
|
+
with self.lock:
|
|
218
|
+
self._generated += 1
|
|
219
|
+
|
|
220
|
+
def increment_skipped(self):
|
|
221
|
+
with self.lock:
|
|
222
|
+
self._skipped += 1
|
|
223
|
+
|
|
224
|
+
def increment_errors(self):
|
|
225
|
+
with self.lock:
|
|
226
|
+
self._errors += 1
|
|
227
|
+
|
|
228
|
+
def add_generation_time(self, duration: float):
|
|
229
|
+
with self.lock:
|
|
230
|
+
self._generation_times.append(duration)
|
|
231
|
+
|
|
232
|
+
def set_active_workers(self, count: int):
|
|
233
|
+
with self.lock:
|
|
234
|
+
self._active_workers = count
|
|
235
|
+
|
|
236
|
+
def add_pending_file(self, filename: str):
|
|
237
|
+
with self.lock:
|
|
238
|
+
self._pending_files.append(filename)
|
|
239
|
+
|
|
240
|
+
def remove_pending_file(self, filename: str):
|
|
241
|
+
with self.lock:
|
|
242
|
+
if filename in self._pending_files:
|
|
243
|
+
self._pending_files.remove(filename)
|
|
244
|
+
|
|
245
|
+
def get_pending_files(self) -> List[str]:
|
|
246
|
+
with self.lock:
|
|
247
|
+
return self._pending_files.copy()
|
|
248
|
+
|
|
249
|
+
@property
|
|
250
|
+
def elapsed(self) -> float:
|
|
251
|
+
return time.time() - self._start_time
|
|
252
|
+
|
|
253
|
+
@property
|
|
254
|
+
def elapsed_str(self) -> str:
|
|
255
|
+
return str(timedelta(seconds=int(self.elapsed)))
|
|
256
|
+
|
|
257
|
+
@property
|
|
258
|
+
def avg_generation_time(self) -> float:
|
|
259
|
+
with self.lock:
|
|
260
|
+
if not self._generation_times:
|
|
261
|
+
return 25.0
|
|
262
|
+
return sum(self._generation_times) / len(self._generation_times)
|
|
263
|
+
|
|
264
|
+
@property
|
|
265
|
+
def generation_times(self) -> List[float]:
|
|
266
|
+
with self.lock:
|
|
267
|
+
return self._generation_times.copy()
|
|
268
|
+
|
|
269
|
+
@property
|
|
270
|
+
def estimated_remaining(self) -> float:
|
|
271
|
+
with self.lock:
|
|
272
|
+
remaining = self._total_files - self._current_index
|
|
273
|
+
return remaining * self.avg_generation_time
|
|
274
|
+
|
|
275
|
+
@property
|
|
276
|
+
def eta_str(self) -> str:
|
|
277
|
+
with self.lock:
|
|
278
|
+
if self._total_files == 0:
|
|
279
|
+
return "unknown"
|
|
280
|
+
return str(timedelta(seconds=int(self.estimated_remaining)))
|
|
281
|
+
|
|
282
|
+
@property
|
|
283
|
+
def percentage(self) -> float:
|
|
284
|
+
with self.lock:
|
|
285
|
+
if self._total_files == 0:
|
|
286
|
+
return 0.0
|
|
287
|
+
return (self._current_index / self._total_files) * 100
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
@dataclass
|
|
291
|
+
class ProgressStats:
|
|
292
|
+
"""Track progress statistics (legacy, non-thread-safe)."""
|
|
293
|
+
total_files: int = 0
|
|
294
|
+
current_index: int = 0
|
|
295
|
+
processed: int = 0
|
|
296
|
+
generated: int = 0
|
|
297
|
+
skipped: int = 0
|
|
298
|
+
errors: int = 0
|
|
299
|
+
start_time: float = field(default_factory=time.time)
|
|
300
|
+
generation_times: List[float] = field(default_factory=list)
|
|
301
|
+
|
|
302
|
+
@property
|
|
303
|
+
def elapsed(self) -> float:
|
|
304
|
+
return time.time() - self.start_time
|
|
305
|
+
|
|
306
|
+
@property
|
|
307
|
+
def elapsed_str(self) -> str:
|
|
308
|
+
return str(timedelta(seconds=int(self.elapsed)))
|
|
309
|
+
|
|
310
|
+
@property
|
|
311
|
+
def avg_generation_time(self) -> float:
|
|
312
|
+
if not self.generation_times:
|
|
313
|
+
return 25.0
|
|
314
|
+
return sum(self.generation_times) / len(self.generation_times)
|
|
315
|
+
|
|
316
|
+
@property
|
|
317
|
+
def estimated_remaining(self) -> float:
|
|
318
|
+
remaining = self.total_files - self.current_index
|
|
319
|
+
return remaining * self.avg_generation_time
|
|
320
|
+
|
|
321
|
+
@property
|
|
322
|
+
def eta_str(self) -> str:
|
|
323
|
+
if self.total_files == 0:
|
|
324
|
+
return "unknown"
|
|
325
|
+
return str(timedelta(seconds=int(self.estimated_remaining)))
|
|
326
|
+
|
|
327
|
+
@property
|
|
328
|
+
def percentage(self) -> float:
|
|
329
|
+
if self.total_files == 0:
|
|
330
|
+
return 0.0
|
|
331
|
+
return (self.current_index / self.total_files) * 100
|
|
66
332
|
|
|
67
333
|
|
|
68
334
|
class Colors:
|
|
@@ -73,11 +339,56 @@ class Colors:
|
|
|
73
339
|
BLUE = '\033[0;34m'
|
|
74
340
|
CYAN = '\033[0;36m'
|
|
75
341
|
PURPLE = '\033[0;35m'
|
|
342
|
+
BOLD = '\033[1m'
|
|
343
|
+
DIM = '\033[2m'
|
|
76
344
|
NC = '\033[0m' # No Color
|
|
77
345
|
|
|
78
346
|
|
|
79
|
-
|
|
347
|
+
class Spinner:
|
|
348
|
+
"""Simple spinner for showing activity during long operations."""
|
|
349
|
+
|
|
350
|
+
FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
|
|
351
|
+
|
|
352
|
+
def __init__(self, message: str = ""):
|
|
353
|
+
self.message = message
|
|
354
|
+
self.running = False
|
|
355
|
+
self.thread: Optional[threading.Thread] = None
|
|
356
|
+
self.frame_idx = 0
|
|
357
|
+
self.start_time = 0.0
|
|
358
|
+
|
|
359
|
+
def _spin(self):
|
|
360
|
+
while self.running:
|
|
361
|
+
elapsed = int(time.time() - self.start_time)
|
|
362
|
+
frame = self.FRAMES[self.frame_idx % len(self.FRAMES)]
|
|
363
|
+
sys.stdout.write(f"\r{Colors.CYAN}{frame}{Colors.NC} {self.message} ({elapsed}s)...")
|
|
364
|
+
sys.stdout.flush()
|
|
365
|
+
self.frame_idx += 1
|
|
366
|
+
time.sleep(0.1)
|
|
367
|
+
|
|
368
|
+
def start(self, message: str = None):
|
|
369
|
+
if message:
|
|
370
|
+
self.message = message
|
|
371
|
+
self.running = True
|
|
372
|
+
self.start_time = time.time()
|
|
373
|
+
self.thread = threading.Thread(target=self._spin, daemon=True)
|
|
374
|
+
self.thread.start()
|
|
375
|
+
|
|
376
|
+
def stop(self, success: bool = True):
|
|
377
|
+
self.running = False
|
|
378
|
+
if self.thread:
|
|
379
|
+
self.thread.join(timeout=0.2)
|
|
380
|
+
elapsed = time.time() - self.start_time
|
|
381
|
+
sys.stdout.write('\r' + ' ' * 80 + '\r')
|
|
382
|
+
sys.stdout.flush()
|
|
383
|
+
return elapsed
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def log(msg: str, level: str = "info", to_file: bool = True):
|
|
80
387
|
"""Print formatted log message."""
|
|
388
|
+
global _log_file
|
|
389
|
+
|
|
390
|
+
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
391
|
+
|
|
81
392
|
colors = {
|
|
82
393
|
"info": Colors.BLUE,
|
|
83
394
|
"success": Colors.GREEN,
|
|
@@ -85,10 +396,32 @@ def log(msg: str, level: str = "info"):
|
|
|
85
396
|
"error": Colors.RED,
|
|
86
397
|
"debug": Colors.PURPLE,
|
|
87
398
|
"step": Colors.CYAN,
|
|
399
|
+
"progress": Colors.BOLD,
|
|
88
400
|
}
|
|
89
401
|
color = colors.get(level, Colors.NC)
|
|
90
402
|
prefix = f"[{level.upper()}]"
|
|
91
403
|
print(f"{color}{prefix}{Colors.NC} {msg}")
|
|
404
|
+
|
|
405
|
+
if to_file and _log_file:
|
|
406
|
+
_log_file.write(f"{timestamp} {prefix} {msg}\n")
|
|
407
|
+
_log_file.flush()
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def log_progress(current: int, total: int, title: str, stats):
|
|
411
|
+
"""Print progress bar and statistics."""
|
|
412
|
+
bar_width = 30
|
|
413
|
+
filled = int(bar_width * current / total) if total > 0 else 0
|
|
414
|
+
bar = '█' * filled + '░' * (bar_width - filled)
|
|
415
|
+
|
|
416
|
+
max_title_len = 40
|
|
417
|
+
display_title = title[:max_title_len-3] + "..." if len(title) > max_title_len else title
|
|
418
|
+
|
|
419
|
+
print(f"\n{Colors.CYAN}{'─' * 70}{Colors.NC}")
|
|
420
|
+
print(f"{Colors.BOLD}📊 Progress: [{bar}] {current}/{total} ({stats.percentage:.1f}%){Colors.NC}")
|
|
421
|
+
print(f" {Colors.DIM}Elapsed: {stats.elapsed_str} | ETA: {stats.eta_str} | Avg: {stats.avg_generation_time:.1f}s/image{Colors.NC}")
|
|
422
|
+
print(f" ✅ Generated: {stats.generated} | ⏭️ Skipped: {stats.skipped} | ❌ Errors: {stats.errors}")
|
|
423
|
+
print(f"{Colors.CYAN}{'─' * 70}{Colors.NC}")
|
|
424
|
+
print(f"📁 Processing: {display_title}")
|
|
92
425
|
|
|
93
426
|
|
|
94
427
|
class PreviewGenerator:
|
|
@@ -106,6 +439,9 @@ class PreviewGenerator:
|
|
|
106
439
|
dry_run: bool = False,
|
|
107
440
|
verbose: bool = False,
|
|
108
441
|
force: bool = False,
|
|
442
|
+
batch_limit: int = 0,
|
|
443
|
+
workers: int = 1,
|
|
444
|
+
rate_limit: int = 5,
|
|
109
445
|
):
|
|
110
446
|
self.project_root = project_root
|
|
111
447
|
self.provider = provider
|
|
@@ -117,12 +453,19 @@ class PreviewGenerator:
|
|
|
117
453
|
self.dry_run = dry_run
|
|
118
454
|
self.verbose = verbose
|
|
119
455
|
self.force = force
|
|
456
|
+
self.batch_limit = batch_limit
|
|
457
|
+
self.workers = workers
|
|
458
|
+
self.rate_limit = rate_limit
|
|
459
|
+
|
|
460
|
+
# Progress tracking - use thread-safe stats for parallel processing
|
|
461
|
+
if workers > 1:
|
|
462
|
+
self.stats = ThreadSafeStats()
|
|
463
|
+
else:
|
|
464
|
+
self.stats = ProgressStats()
|
|
465
|
+
self.spinner = Spinner()
|
|
120
466
|
|
|
121
|
-
#
|
|
122
|
-
self.
|
|
123
|
-
self.generated = 0
|
|
124
|
-
self.skipped = 0
|
|
125
|
-
self.errors = 0
|
|
467
|
+
# Rate limiter for API calls
|
|
468
|
+
self.rate_limiter = RateLimiter(requests_per_minute=rate_limit)
|
|
126
469
|
|
|
127
470
|
# Ensure output directory exists
|
|
128
471
|
if not dry_run:
|
|
@@ -592,13 +935,26 @@ class PreviewGenerator:
|
|
|
592
935
|
log(f"Failed to update front matter: {e}", "error")
|
|
593
936
|
return False
|
|
594
937
|
|
|
938
|
+
def _increment_stat(self, stat_name: str):
|
|
939
|
+
"""Increment a stat on either ThreadSafeStats or ProgressStats."""
|
|
940
|
+
if isinstance(self.stats, ThreadSafeStats):
|
|
941
|
+
method = getattr(self.stats, f"increment_{stat_name}", None)
|
|
942
|
+
if method:
|
|
943
|
+
method()
|
|
944
|
+
else:
|
|
945
|
+
setattr(self.stats, stat_name, getattr(self.stats, stat_name) + 1)
|
|
946
|
+
|
|
595
947
|
def process_file(self, file_path: Path, list_only: bool = False) -> bool:
|
|
596
948
|
"""Process a single content file."""
|
|
597
|
-
|
|
949
|
+
global _interrupted
|
|
950
|
+
if _interrupted:
|
|
951
|
+
return False
|
|
952
|
+
|
|
953
|
+
self._increment_stat("processed")
|
|
598
954
|
|
|
599
955
|
content = self.parse_front_matter(file_path)
|
|
600
956
|
if not content:
|
|
601
|
-
self.skipped
|
|
957
|
+
self._increment_stat("skipped")
|
|
602
958
|
return False
|
|
603
959
|
|
|
604
960
|
self.debug(f"Processing: {content.title}")
|
|
@@ -607,7 +963,7 @@ class PreviewGenerator:
|
|
|
607
963
|
if content.preview and self.check_preview_exists(content.preview):
|
|
608
964
|
if not self.force:
|
|
609
965
|
self.debug(f"Preview exists: {content.preview}")
|
|
610
|
-
self.skipped
|
|
966
|
+
self._increment_stat("skipped")
|
|
611
967
|
return True
|
|
612
968
|
else:
|
|
613
969
|
log(f"Force mode: regenerating preview for {content.title}", "info")
|
|
@@ -639,56 +995,233 @@ class PreviewGenerator:
|
|
|
639
995
|
print(f" Preview URL: {preview_url}")
|
|
640
996
|
print(f" Prompt: {prompt[:200]}...")
|
|
641
997
|
print()
|
|
642
|
-
self.generated
|
|
998
|
+
self._increment_stat("generated")
|
|
643
999
|
return True
|
|
644
1000
|
|
|
1001
|
+
# Rate limiting
|
|
1002
|
+
self.rate_limiter.acquire()
|
|
1003
|
+
|
|
1004
|
+
start_time = time.time()
|
|
1005
|
+
|
|
645
1006
|
# Generate image
|
|
1007
|
+
self.spinner.start(f"Generating: {content.title[:50]}...")
|
|
646
1008
|
result = self.generate_image(prompt, output_file)
|
|
1009
|
+
self.spinner.stop()
|
|
1010
|
+
|
|
1011
|
+
duration = time.time() - start_time
|
|
1012
|
+
result.duration = duration
|
|
1013
|
+
result.file_path = file_path
|
|
647
1014
|
|
|
648
1015
|
if result.success:
|
|
649
1016
|
# Update front matter
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
1017
|
+
self.spinner.start("Updating front matter...")
|
|
1018
|
+
updated = self.update_front_matter(file_path, preview_url)
|
|
1019
|
+
self.spinner.stop()
|
|
1020
|
+
|
|
1021
|
+
if updated:
|
|
1022
|
+
log(f"Updated front matter with: {preview_url} ({duration:.1f}s)", "success")
|
|
1023
|
+
self._increment_stat("generated")
|
|
1024
|
+
if isinstance(self.stats, ThreadSafeStats):
|
|
1025
|
+
self.stats.add_generation_time(duration)
|
|
653
1026
|
return True
|
|
654
1027
|
else:
|
|
655
|
-
self.errors
|
|
1028
|
+
self._increment_stat("errors")
|
|
656
1029
|
return False
|
|
657
1030
|
else:
|
|
658
1031
|
log(f"Failed to generate image: {result.error}", "warning")
|
|
659
|
-
self.errors
|
|
1032
|
+
self._increment_stat("errors")
|
|
660
1033
|
return False
|
|
661
1034
|
|
|
1035
|
+
def process_file_parallel(self, file_path: Path) -> Tuple[Path, GenerationResult]:
|
|
1036
|
+
"""Thread-safe version of process_file for parallel processing."""
|
|
1037
|
+
global _interrupted
|
|
1038
|
+
if _interrupted:
|
|
1039
|
+
return file_path, GenerationResult(success=False, error="Interrupted")
|
|
1040
|
+
|
|
1041
|
+
content = self.parse_front_matter(file_path)
|
|
1042
|
+
if not content:
|
|
1043
|
+
self.stats.increment_skipped()
|
|
1044
|
+
return file_path, GenerationResult(success=False, error="No front matter")
|
|
1045
|
+
|
|
1046
|
+
# Check if preview exists
|
|
1047
|
+
if content.preview and self.check_preview_exists(content.preview):
|
|
1048
|
+
if not self.force:
|
|
1049
|
+
self.stats.increment_skipped()
|
|
1050
|
+
return file_path, GenerationResult(success=True, file_path=file_path)
|
|
1051
|
+
|
|
1052
|
+
# Generate filename and paths
|
|
1053
|
+
safe_filename = self.generate_filename(content.title)
|
|
1054
|
+
output_file = self.output_dir / f"{safe_filename}.png"
|
|
1055
|
+
preview_url = f"/{self.output_dir.relative_to(self.project_root)}/{safe_filename}.png"
|
|
1056
|
+
|
|
1057
|
+
# Generate prompt
|
|
1058
|
+
prompt = self.generate_prompt(content)
|
|
1059
|
+
|
|
1060
|
+
# Rate limiting
|
|
1061
|
+
self.rate_limiter.acquire()
|
|
1062
|
+
|
|
1063
|
+
start_time = time.time()
|
|
1064
|
+
result = self.generate_image(prompt, output_file)
|
|
1065
|
+
duration = time.time() - start_time
|
|
1066
|
+
result.duration = duration
|
|
1067
|
+
result.file_path = file_path
|
|
1068
|
+
|
|
1069
|
+
if result.success:
|
|
1070
|
+
if self.update_front_matter(file_path, preview_url):
|
|
1071
|
+
self.stats.increment_generated()
|
|
1072
|
+
self.stats.add_generation_time(duration)
|
|
1073
|
+
return file_path, result
|
|
1074
|
+
else:
|
|
1075
|
+
self.stats.increment_errors()
|
|
1076
|
+
result.success = False
|
|
1077
|
+
result.error = "Failed to update front matter"
|
|
1078
|
+
return file_path, result
|
|
1079
|
+
else:
|
|
1080
|
+
self.stats.increment_errors()
|
|
1081
|
+
return file_path, result
|
|
1082
|
+
|
|
662
1083
|
def process_collection(self, collection_path: Path, list_only: bool = False):
|
|
663
1084
|
"""Process all markdown files in a collection."""
|
|
664
1085
|
if not collection_path.exists():
|
|
665
1086
|
log(f"Collection not found: {collection_path}", "warning")
|
|
666
1087
|
return
|
|
667
1088
|
|
|
668
|
-
|
|
1089
|
+
files = sorted(collection_path.rglob("*.md"))
|
|
1090
|
+
|
|
1091
|
+
# Apply batch limit
|
|
1092
|
+
if self.batch_limit > 0:
|
|
1093
|
+
files = files[:self.batch_limit]
|
|
1094
|
+
log(f"Batch limit: processing {len(files)} files", "info")
|
|
1095
|
+
|
|
1096
|
+
if not files:
|
|
1097
|
+
log(f"No markdown files found in {collection_path}", "info")
|
|
1098
|
+
return
|
|
1099
|
+
|
|
1100
|
+
if self.workers > 1 and not list_only and not self.dry_run:
|
|
1101
|
+
self._process_collection_parallel(files)
|
|
1102
|
+
else:
|
|
1103
|
+
self._process_collection_sequential(files, list_only)
|
|
1104
|
+
|
|
1105
|
+
def _process_collection_sequential(self, files: List[Path], list_only: bool = False):
|
|
1106
|
+
"""Process files sequentially with progress tracking."""
|
|
1107
|
+
global _interrupted
|
|
1108
|
+
total = len(files)
|
|
1109
|
+
|
|
1110
|
+
for i, md_file in enumerate(files):
|
|
1111
|
+
if _interrupted:
|
|
1112
|
+
log("Interrupted! Stopping...", "warning")
|
|
1113
|
+
break
|
|
1114
|
+
|
|
1115
|
+
if isinstance(self.stats, ProgressStats):
|
|
1116
|
+
log_progress(i + 1, total, "Processing", self.stats)
|
|
1117
|
+
|
|
669
1118
|
self.process_file(md_file, list_only)
|
|
1119
|
+
self.stats.processed = i + 1
|
|
1120
|
+
|
|
1121
|
+
def _process_collection_parallel(self, files: List[Path]):
|
|
1122
|
+
"""Process files in parallel using ThreadPoolExecutor."""
|
|
1123
|
+
global _interrupted
|
|
1124
|
+
total = len(files)
|
|
1125
|
+
|
|
1126
|
+
log(f"Processing {total} files with {self.workers} workers", "info")
|
|
1127
|
+
|
|
1128
|
+
if isinstance(self.stats, ThreadSafeStats):
|
|
1129
|
+
self.stats.set_active_workers(self.workers)
|
|
1130
|
+
for f in files:
|
|
1131
|
+
self.stats.add_pending_file(str(f))
|
|
1132
|
+
|
|
1133
|
+
completed = 0
|
|
1134
|
+
|
|
1135
|
+
with ThreadPoolExecutor(max_workers=self.workers) as executor:
|
|
1136
|
+
futures = {
|
|
1137
|
+
executor.submit(self.process_file_parallel, f): f
|
|
1138
|
+
for f in files
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
for future in as_completed(futures):
|
|
1142
|
+
if _interrupted:
|
|
1143
|
+
log("Interrupted! Cancelling remaining tasks...", "warning")
|
|
1144
|
+
for f in futures:
|
|
1145
|
+
f.cancel()
|
|
1146
|
+
break
|
|
1147
|
+
|
|
1148
|
+
file_path, result = future.result()
|
|
1149
|
+
completed += 1
|
|
1150
|
+
|
|
1151
|
+
if isinstance(self.stats, ThreadSafeStats):
|
|
1152
|
+
self.stats.increment_processed()
|
|
1153
|
+
self.stats.remove_pending_file(str(file_path))
|
|
1154
|
+
|
|
1155
|
+
self._show_parallel_progress(completed, total, file_path, result)
|
|
1156
|
+
|
|
1157
|
+
def _show_parallel_progress(self, completed: int, total: int, file_path: Path, result: GenerationResult):
|
|
1158
|
+
"""Show progress for parallel processing."""
|
|
1159
|
+
pct = (completed / total) * 100
|
|
1160
|
+
bar_len = 30
|
|
1161
|
+
filled = int(bar_len * completed / total)
|
|
1162
|
+
bar = "█" * filled + "░" * (bar_len - filled)
|
|
1163
|
+
|
|
1164
|
+
status = f"{Colors.GREEN}✓{Colors.NC}" if result.success else f"{Colors.RED}✗{Colors.NC}"
|
|
1165
|
+
name = file_path.name[:30]
|
|
1166
|
+
duration = f" ({result.duration:.1f}s)" if result.duration > 0 else ""
|
|
1167
|
+
|
|
1168
|
+
workers_info = ""
|
|
1169
|
+
if isinstance(self.stats, ThreadSafeStats):
|
|
1170
|
+
workers_info = f" [{self.stats.active_workers}w]"
|
|
1171
|
+
|
|
1172
|
+
print(f"\r {bar} {pct:5.1f}% ({completed}/{total}){workers_info} {status} {name}{duration} ", end="", flush=True)
|
|
1173
|
+
|
|
1174
|
+
if completed == total:
|
|
1175
|
+
print()
|
|
670
1176
|
|
|
671
1177
|
def print_summary(self):
|
|
672
1178
|
"""Print processing summary."""
|
|
1179
|
+
stats = self.stats
|
|
1180
|
+
|
|
673
1181
|
print()
|
|
674
|
-
print(f"{Colors.CYAN}{'=' *
|
|
675
|
-
print(f"{Colors.CYAN}📊 Summary{Colors.NC}")
|
|
676
|
-
print(f"{Colors.CYAN}{'=' *
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
1182
|
+
print(f"{Colors.CYAN}{'=' * 50}{Colors.NC}")
|
|
1183
|
+
print(f"{Colors.CYAN}📊 Generation Summary{Colors.NC}")
|
|
1184
|
+
print(f"{Colors.CYAN}{'=' * 50}{Colors.NC}")
|
|
1185
|
+
|
|
1186
|
+
if isinstance(stats, ThreadSafeStats):
|
|
1187
|
+
print(f" 📁 Files processed: {stats.processed}")
|
|
1188
|
+
print(f" 🎨 Images generated: {stats.generated}")
|
|
1189
|
+
print(f" ⏭️ Files skipped: {stats.skipped}")
|
|
1190
|
+
print(f" ❌ Errors: {stats.errors}")
|
|
1191
|
+
|
|
1192
|
+
if stats.elapsed > 0:
|
|
1193
|
+
print(f"\n ⏱️ Total time: {stats.elapsed_str}")
|
|
1194
|
+
if stats.avg_generation_time > 0:
|
|
1195
|
+
print(f" 📈 Avg time/image: {stats.avg_generation_time:.1f}s")
|
|
1196
|
+
if self.workers > 1:
|
|
1197
|
+
print(f" 👷 Workers used: {self.workers}")
|
|
1198
|
+
else:
|
|
1199
|
+
print(f" 📁 Files processed: {stats.processed}")
|
|
1200
|
+
print(f" 🎨 Images generated: {stats.generated}")
|
|
1201
|
+
print(f" ⏭️ Files skipped: {stats.skipped}")
|
|
1202
|
+
print(f" ❌ Errors: {stats.errors}")
|
|
1203
|
+
|
|
681
1204
|
print()
|
|
682
1205
|
|
|
1206
|
+
if _interrupted:
|
|
1207
|
+
log("Processing was interrupted by user.", "warning")
|
|
1208
|
+
|
|
683
1209
|
if self.dry_run:
|
|
684
1210
|
log("This was a dry run. No actual changes were made.", "info")
|
|
685
1211
|
|
|
686
|
-
|
|
1212
|
+
errors = stats.errors if isinstance(stats, ThreadSafeStats) else stats.errors
|
|
1213
|
+
if errors > 0:
|
|
687
1214
|
log("Some files had errors. Check the output above.", "warning")
|
|
688
1215
|
|
|
689
1216
|
|
|
690
1217
|
def main():
|
|
691
1218
|
"""Main entry point."""
|
|
1219
|
+
global _log_file
|
|
1220
|
+
|
|
1221
|
+
# Set up signal handlers for graceful shutdown
|
|
1222
|
+
signal.signal(signal.SIGINT, _signal_handler)
|
|
1223
|
+
signal.signal(signal.SIGTERM, _signal_handler)
|
|
1224
|
+
|
|
692
1225
|
parser = argparse.ArgumentParser(
|
|
693
1226
|
description="AI-powered preview image generator for Jekyll content"
|
|
694
1227
|
)
|
|
@@ -747,9 +1280,39 @@ def main():
|
|
|
747
1280
|
action='store_true',
|
|
748
1281
|
help="Disable automatic assets prefix prepending"
|
|
749
1282
|
)
|
|
1283
|
+
parser.add_argument(
|
|
1284
|
+
'--batch',
|
|
1285
|
+
type=int,
|
|
1286
|
+
default=0,
|
|
1287
|
+
help="Limit number of files to process (0 = no limit)"
|
|
1288
|
+
)
|
|
1289
|
+
parser.add_argument(
|
|
1290
|
+
'--log-file',
|
|
1291
|
+
help="Write log output to file"
|
|
1292
|
+
)
|
|
1293
|
+
parser.add_argument(
|
|
1294
|
+
'-w', '--workers',
|
|
1295
|
+
type=int,
|
|
1296
|
+
default=1,
|
|
1297
|
+
help="Number of parallel workers (default: 1 = sequential)"
|
|
1298
|
+
)
|
|
1299
|
+
parser.add_argument(
|
|
1300
|
+
'--rate-limit',
|
|
1301
|
+
type=int,
|
|
1302
|
+
default=5,
|
|
1303
|
+
help="Max API requests per minute (default: 5)"
|
|
1304
|
+
)
|
|
750
1305
|
|
|
751
1306
|
args = parser.parse_args()
|
|
752
1307
|
|
|
1308
|
+
# Set up log file
|
|
1309
|
+
if args.log_file:
|
|
1310
|
+
try:
|
|
1311
|
+
_log_file = open(args.log_file, 'w')
|
|
1312
|
+
log(f"Logging to: {args.log_file}", "info")
|
|
1313
|
+
except IOError as e:
|
|
1314
|
+
log(f"Cannot open log file: {e}", "warning")
|
|
1315
|
+
|
|
753
1316
|
# Determine project root
|
|
754
1317
|
script_dir = Path(__file__).parent
|
|
755
1318
|
project_root = script_dir.parent.parent
|
|
@@ -765,16 +1328,23 @@ def main():
|
|
|
765
1328
|
dry_run=args.dry_run,
|
|
766
1329
|
verbose=args.verbose,
|
|
767
1330
|
force=args.force,
|
|
1331
|
+
batch_limit=args.batch,
|
|
1332
|
+
workers=args.workers,
|
|
1333
|
+
rate_limit=args.rate_limit,
|
|
768
1334
|
)
|
|
769
1335
|
|
|
770
|
-
print(f"{Colors.BLUE}{'=' *
|
|
1336
|
+
print(f"{Colors.BLUE}{'=' * 50}{Colors.NC}")
|
|
771
1337
|
print(f"{Colors.BLUE}🎨 Preview Image Generator{Colors.NC}")
|
|
772
|
-
print(f"{Colors.BLUE}{'=' *
|
|
1338
|
+
print(f"{Colors.BLUE}{'=' * 50}{Colors.NC}")
|
|
773
1339
|
print()
|
|
774
1340
|
|
|
775
|
-
log(f"Provider:
|
|
776
|
-
log(f"Output Dir:
|
|
777
|
-
log(f"
|
|
1341
|
+
log(f"Provider: {args.provider}", "info")
|
|
1342
|
+
log(f"Output Dir: {args.output_dir}", "info")
|
|
1343
|
+
log(f"Workers: {args.workers}", "info")
|
|
1344
|
+
log(f"Rate Limit: {args.rate_limit} req/min", "info")
|
|
1345
|
+
log(f"Dry Run: {args.dry_run}", "info")
|
|
1346
|
+
if args.batch > 0:
|
|
1347
|
+
log(f"Batch Limit: {args.batch}", "info")
|
|
778
1348
|
print()
|
|
779
1349
|
|
|
780
1350
|
# Process files
|
|
@@ -810,7 +1380,12 @@ def main():
|
|
|
810
1380
|
|
|
811
1381
|
generator.print_summary()
|
|
812
1382
|
|
|
813
|
-
|
|
1383
|
+
# Close log file
|
|
1384
|
+
if _log_file:
|
|
1385
|
+
_log_file.close()
|
|
1386
|
+
|
|
1387
|
+
errors = generator.stats.errors if isinstance(generator.stats, ThreadSafeStats) else generator.stats.errors
|
|
1388
|
+
return 0 if errors == 0 else 1
|
|
814
1389
|
|
|
815
1390
|
|
|
816
1391
|
if __name__ == "__main__":
|