ultimate-jekyll-manager 1.6.3 → 1.6.5
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.
- package/CHANGELOG.md +32 -0
- package/dist/assets/css/pages/admin/calendar/index.scss +23 -9
- package/dist/assets/js/pages/admin/calendar/calendar-core.js +80 -14
- package/dist/assets/js/pages/admin/calendar/calendar-events.js +75 -10
- package/dist/assets/js/pages/admin/calendar/calendar-renderer.js +89 -16
- package/dist/defaults/.github/workflows/build.yml +1 -1
- package/dist/defaults/dist/_layouts/blueprint/admin/calendar/index.html +36 -9
- package/dist/gulp/tasks/defaults.js +1 -1
- package/dist/gulp/tasks/imagemin.js +12 -12
- package/dist/gulp/tasks/translation.js +95 -158
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -14,6 +14,38 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
|
|
14
14
|
- `Fixed` for any bug fixes.
|
|
15
15
|
- `Security` in case of vulnerabilities.
|
|
16
16
|
|
|
17
|
+
---
|
|
18
|
+
## [1.6.5] - 2026-06-04
|
|
19
|
+
|
|
20
|
+
### Added
|
|
21
|
+
|
|
22
|
+
- **Dynamic event fitting in month view.** Calendar cells now show as many events as physically fit instead of a hardcoded max of 3; "+N more" only appears when events genuinely overflow.
|
|
23
|
+
- **Local time display on calendar events.** Event pills show local time (data layer remains UTC). Editor modal shows a local date+time badge below the UTC inputs.
|
|
24
|
+
- **Hover states on event pills.** Brightness + box-shadow effect on hover for month, week, and day views.
|
|
25
|
+
- **Now line on month view.** Red progress line shows current time of day in today's cell, with dot rendered above cell borders.
|
|
26
|
+
- **Left tab accent on all event pills.** Consistent dark left border matches week/day view style.
|
|
27
|
+
- **Monthly-weekday (Nth weekday) recurrence pattern.** Calendar supports "2nd Wednesday of every month" style recurring campaigns with calendar-relative date stepping.
|
|
28
|
+
|
|
29
|
+
### Changed
|
|
30
|
+
|
|
31
|
+
- **Per-string translation caching replaces all-or-nothing page caching.** Each page's cache is now a `hash→translation` map (`es/pages/index.html.json`). Only strings whose content hash changed are sent to the API — unchanged strings are served from cache. Dramatically reduces API calls and cost on incremental builds.
|
|
32
|
+
- **Prompt hash mismatch now wipes page cache files** (not just meta entries) for a clean slate.
|
|
33
|
+
- **Translation stats now track cached vs new strings** instead of whole-page hit/miss.
|
|
34
|
+
- **Outside-month calendar cells** now fade only content (date number + events), keeping borders at full opacity for consistent grid lines.
|
|
35
|
+
- **Imagemin constants renamed** (`MAX_SOURCE_DIMENSION` → `IMAGE_MAX_DIMENSION`) and default max dimension lowered from 4096 to 2048.
|
|
36
|
+
|
|
37
|
+
### Removed
|
|
38
|
+
|
|
39
|
+
- **`RECHECK_DAYS`** — per-string content hashes make age-based invalidation unnecessary.
|
|
40
|
+
- **All-day row** removed from week view (no all-day event concept in marketing calendar).
|
|
41
|
+
|
|
42
|
+
---
|
|
43
|
+
## [1.6.4] - 2026-06-03
|
|
44
|
+
|
|
45
|
+
### Fixed
|
|
46
|
+
|
|
47
|
+
- **Workflow template `{ github.secrets }` clobbered `{ github.workflows.build.schedule }`.** Renamed to flat `{ githubSecrets }` key to avoid namespace collision in the template spread.
|
|
48
|
+
|
|
17
49
|
---
|
|
18
50
|
## [1.6.3] - 2026-06-03
|
|
19
51
|
|
|
@@ -79,7 +79,7 @@ $campaign-push-color: #4CAF50;
|
|
|
79
79
|
.calendar-month {
|
|
80
80
|
display: grid;
|
|
81
81
|
grid-template-columns: repeat(7, 1fr);
|
|
82
|
-
grid-
|
|
82
|
+
grid-auto-rows: 1fr;
|
|
83
83
|
flex-grow: 1;
|
|
84
84
|
min-height: 0;
|
|
85
85
|
}
|
|
@@ -88,12 +88,13 @@ $campaign-push-color: #4CAF50;
|
|
|
88
88
|
border-right: 1px solid var(--bs-border-color);
|
|
89
89
|
border-bottom: 1px solid var(--bs-border-color);
|
|
90
90
|
padding: 0.25rem;
|
|
91
|
-
overflow
|
|
92
|
-
|
|
93
|
-
min-height: 80px;
|
|
91
|
+
overflow: hidden;
|
|
92
|
+
min-height: 0;
|
|
94
93
|
position: relative;
|
|
95
94
|
cursor: pointer;
|
|
96
95
|
transition: background-color 0.15s;
|
|
96
|
+
display: flex;
|
|
97
|
+
flex-direction: column;
|
|
97
98
|
|
|
98
99
|
&:nth-child(7n) {
|
|
99
100
|
border-right: none;
|
|
@@ -120,7 +121,10 @@ $campaign-push-color: #4CAF50;
|
|
|
120
121
|
}
|
|
121
122
|
|
|
122
123
|
.calendar-cell--outside {
|
|
123
|
-
|
|
124
|
+
.calendar-cell-date,
|
|
125
|
+
.calendar-cell-events {
|
|
126
|
+
opacity: 0.35;
|
|
127
|
+
}
|
|
124
128
|
}
|
|
125
129
|
|
|
126
130
|
.calendar-cell--drag-over {
|
|
@@ -142,6 +146,9 @@ $campaign-push-color: #4CAF50;
|
|
|
142
146
|
display: flex;
|
|
143
147
|
flex-direction: column;
|
|
144
148
|
gap: 1px;
|
|
149
|
+
flex: 1;
|
|
150
|
+
min-height: 0;
|
|
151
|
+
overflow: hidden;
|
|
145
152
|
}
|
|
146
153
|
|
|
147
154
|
.calendar-cell-more {
|
|
@@ -253,7 +260,7 @@ $campaign-push-color: #4CAF50;
|
|
|
253
260
|
right: 0;
|
|
254
261
|
height: 2px;
|
|
255
262
|
background-color: $calendar-now-color;
|
|
256
|
-
z-index:
|
|
263
|
+
z-index: 3;
|
|
257
264
|
pointer-events: none;
|
|
258
265
|
|
|
259
266
|
&::before {
|
|
@@ -268,6 +275,7 @@ $campaign-push-color: #4CAF50;
|
|
|
268
275
|
}
|
|
269
276
|
}
|
|
270
277
|
|
|
278
|
+
|
|
271
279
|
.calendar-week-event {
|
|
272
280
|
position: absolute;
|
|
273
281
|
left: 2px;
|
|
@@ -280,9 +288,11 @@ $campaign-push-color: #4CAF50;
|
|
|
280
288
|
z-index: 1;
|
|
281
289
|
color: #fff;
|
|
282
290
|
border-left: 3px solid rgba(0, 0, 0, 0.2);
|
|
291
|
+
transition: filter 0.15s, box-shadow 0.15s;
|
|
283
292
|
|
|
284
293
|
&:hover {
|
|
285
|
-
|
|
294
|
+
filter: brightness(1.2);
|
|
295
|
+
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.3);
|
|
286
296
|
}
|
|
287
297
|
}
|
|
288
298
|
|
|
@@ -402,15 +412,17 @@ $campaign-push-color: #4CAF50;
|
|
|
402
412
|
height: 1.25rem;
|
|
403
413
|
box-sizing: border-box;
|
|
404
414
|
border: 1px solid transparent;
|
|
415
|
+
border-left: 3px solid rgba(0, 0, 0, 0.2);
|
|
405
416
|
white-space: nowrap;
|
|
406
417
|
overflow: hidden;
|
|
407
418
|
text-overflow: ellipsis;
|
|
408
419
|
cursor: pointer;
|
|
409
420
|
color: #fff;
|
|
410
|
-
transition:
|
|
421
|
+
transition: filter 0.15s, box-shadow 0.15s;
|
|
411
422
|
|
|
412
423
|
&:hover {
|
|
413
|
-
|
|
424
|
+
filter: brightness(1.2);
|
|
425
|
+
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.3);
|
|
414
426
|
}
|
|
415
427
|
}
|
|
416
428
|
|
|
@@ -466,7 +478,9 @@ $campaign-push-color: #4CAF50;
|
|
|
466
478
|
// Virtual recurring occurrences: dashed border, slightly transparent
|
|
467
479
|
.calendar-event--virtual {
|
|
468
480
|
border-style: dashed;
|
|
481
|
+
border-left-style: solid;
|
|
469
482
|
border-color: rgba(255, 255, 255, 0.5);
|
|
483
|
+
border-left-color: rgba(0, 0, 0, 0.2);
|
|
470
484
|
opacity: 0.7;
|
|
471
485
|
}
|
|
472
486
|
|
|
@@ -358,15 +358,23 @@ export default class CalendarCore {
|
|
|
358
358
|
|
|
359
359
|
/**
|
|
360
360
|
* Generate virtual occurrences using the template's sendAt as seed.
|
|
361
|
-
*
|
|
361
|
+
* Fixed-interval patterns (daily, weekly) use pure unix math.
|
|
362
|
+
* Calendar-relative patterns (monthly, monthly-weekday, quarterly, yearly)
|
|
363
|
+
* step through actual dates so they land correctly every month.
|
|
362
364
|
*/
|
|
363
365
|
_generateOccurrences(template, startUNIX, endUNIX) {
|
|
364
|
-
const
|
|
366
|
+
const { pattern } = template.recurrence;
|
|
365
367
|
const occurrences = [];
|
|
366
368
|
const seedUNIX = template.sendAt;
|
|
367
369
|
|
|
368
|
-
|
|
370
|
+
if (pattern === 'monthly-weekday') {
|
|
371
|
+
return this._generateNthWeekdayOccurrences(template, startUNIX, endUNIX);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// Fixed-interval patterns
|
|
375
|
+
const interval = this._getIntervalSeconds(pattern);
|
|
369
376
|
let cursorUNIX = seedUNIX;
|
|
377
|
+
|
|
370
378
|
while (cursorUNIX > startUNIX + interval) {
|
|
371
379
|
cursorUNIX -= interval;
|
|
372
380
|
}
|
|
@@ -376,23 +384,81 @@ export default class CalendarCore {
|
|
|
376
384
|
|
|
377
385
|
let maxIterations = 400;
|
|
378
386
|
while (cursorUNIX <= endUNIX && maxIterations-- > 0) {
|
|
379
|
-
occurrences.push(
|
|
380
|
-
id: `${template.id}__virtual__${cursorUNIX}`,
|
|
381
|
-
sendAt: cursorUNIX,
|
|
382
|
-
status: 'pending',
|
|
383
|
-
type: template.type,
|
|
384
|
-
settings: template.settings,
|
|
385
|
-
recurrence: template.recurrence,
|
|
386
|
-
_virtual: true,
|
|
387
|
-
_recurringSourceId: template.id,
|
|
388
|
-
});
|
|
389
|
-
|
|
387
|
+
occurrences.push(this._buildVirtualEvent(template, cursorUNIX));
|
|
390
388
|
cursorUNIX += interval;
|
|
391
389
|
}
|
|
392
390
|
|
|
393
391
|
return occurrences;
|
|
394
392
|
}
|
|
395
393
|
|
|
394
|
+
/**
|
|
395
|
+
* Generate Nth-weekday-of-month occurrences (e.g., 2nd Wednesday).
|
|
396
|
+
* Walks month by month, computing the actual calendar date each time.
|
|
397
|
+
*/
|
|
398
|
+
_generateNthWeekdayOccurrences(template, startUNIX, endUNIX) {
|
|
399
|
+
const { nth = 1, day: dayOfWeek = 0, hour = 0, minute = 0 } = template.recurrence;
|
|
400
|
+
const occurrences = [];
|
|
401
|
+
|
|
402
|
+
// Start scanning 2 months before the visible range
|
|
403
|
+
const startDate = new Date(startUNIX * 1000);
|
|
404
|
+
let year = startDate.getUTCFullYear();
|
|
405
|
+
let month = startDate.getUTCMonth() - 2;
|
|
406
|
+
|
|
407
|
+
let maxIterations = 100;
|
|
408
|
+
while (maxIterations-- > 0) {
|
|
409
|
+
const ts = this._nthWeekdayOfMonth(year, month, nth, dayOfWeek, hour, minute);
|
|
410
|
+
|
|
411
|
+
if (ts > endUNIX) {
|
|
412
|
+
break;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
if (ts >= startUNIX) {
|
|
416
|
+
occurrences.push(this._buildVirtualEvent(template, ts));
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
month++;
|
|
420
|
+
if (month > 11) {
|
|
421
|
+
month = 0;
|
|
422
|
+
year++;
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
return occurrences;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Find the Nth occurrence of a weekday in a given month (UTC).
|
|
431
|
+
* Returns unix timestamp.
|
|
432
|
+
*/
|
|
433
|
+
_nthWeekdayOfMonth(year, month, nth, dayOfWeek, hour, minute) {
|
|
434
|
+
const first = new Date(Date.UTC(year, month, 1));
|
|
435
|
+
let dateNum = 1;
|
|
436
|
+
|
|
437
|
+
// Advance to the first matching weekday
|
|
438
|
+
while (first.getUTCDay() !== dayOfWeek) {
|
|
439
|
+
dateNum++;
|
|
440
|
+
first.setUTCDate(dateNum);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// Advance to the Nth occurrence
|
|
444
|
+
dateNum += (nth - 1) * 7;
|
|
445
|
+
|
|
446
|
+
return Date.UTC(year, month, dateNum, hour, minute, 0) / 1000;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
_buildVirtualEvent(template, sendAtUNIX) {
|
|
450
|
+
return {
|
|
451
|
+
id: `${template.id}__virtual__${sendAtUNIX}`,
|
|
452
|
+
sendAt: sendAtUNIX,
|
|
453
|
+
status: 'pending',
|
|
454
|
+
type: template.type,
|
|
455
|
+
settings: template.settings,
|
|
456
|
+
recurrence: template.recurrence,
|
|
457
|
+
_virtual: true,
|
|
458
|
+
_recurringSourceId: template.id,
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
|
|
396
462
|
_getIntervalSeconds(pattern) {
|
|
397
463
|
const DAY = 86400;
|
|
398
464
|
switch (pattern) {
|
|
@@ -47,7 +47,11 @@ export default class CalendarEvents {
|
|
|
47
47
|
this._setType('email');
|
|
48
48
|
this._resetRecurrence();
|
|
49
49
|
document.getElementById('campaign-modal-title-text').textContent = 'Create Campaign';
|
|
50
|
+
document.getElementById('campaign-local-time-row').style.display = 'none';
|
|
50
51
|
});
|
|
52
|
+
|
|
53
|
+
document.getElementById('campaign-date').addEventListener('input', () => this._updateLocalTimeHint());
|
|
54
|
+
document.getElementById('campaign-time').addEventListener('input', () => this._updateLocalTimeHint());
|
|
51
55
|
}
|
|
52
56
|
|
|
53
57
|
_initResultsModal() {
|
|
@@ -168,6 +172,8 @@ export default class CalendarEvents {
|
|
|
168
172
|
const $patternSelect = document.getElementById('campaign-recurrence-pattern');
|
|
169
173
|
const $dayHint = document.getElementById('recurrence-day-hint');
|
|
170
174
|
const $monthRow = document.getElementById('recurrence-month-row');
|
|
175
|
+
const $nthRow = document.getElementById('recurrence-nth-row');
|
|
176
|
+
const $dayInput = document.getElementById('campaign-recurrence-day');
|
|
171
177
|
|
|
172
178
|
$checkbox.addEventListener('change', () => {
|
|
173
179
|
$fields.classList.toggle('d-none', !$checkbox.checked);
|
|
@@ -175,14 +181,21 @@ export default class CalendarEvents {
|
|
|
175
181
|
|
|
176
182
|
$patternSelect.addEventListener('change', () => {
|
|
177
183
|
const pattern = $patternSelect.value;
|
|
178
|
-
|
|
179
|
-
|
|
184
|
+
|
|
185
|
+
const isWeekday = pattern === 'weekly' || pattern === 'monthly-weekday';
|
|
186
|
+
|
|
187
|
+
if (isWeekday) {
|
|
180
188
|
$dayHint.textContent = '(of week, 0=Sun)';
|
|
189
|
+
$dayInput.min = 0;
|
|
190
|
+
$dayInput.max = 6;
|
|
181
191
|
} else {
|
|
182
192
|
$dayHint.textContent = '(of month)';
|
|
193
|
+
$dayInput.min = 1;
|
|
194
|
+
$dayInput.max = 31;
|
|
183
195
|
}
|
|
184
|
-
|
|
196
|
+
|
|
185
197
|
$monthRow.classList.toggle('d-none', pattern !== 'yearly');
|
|
198
|
+
$nthRow.classList.toggle('d-none', pattern !== 'monthly-weekday');
|
|
186
199
|
});
|
|
187
200
|
}
|
|
188
201
|
|
|
@@ -226,6 +239,7 @@ export default class CalendarEvents {
|
|
|
226
239
|
// Pre-fill date and time
|
|
227
240
|
document.getElementById('campaign-date').value = date || '';
|
|
228
241
|
document.getElementById('campaign-time').value = time || '09:00';
|
|
242
|
+
this._updateLocalTimeHint();
|
|
229
243
|
|
|
230
244
|
this._getEditorModal().show();
|
|
231
245
|
}
|
|
@@ -359,23 +373,32 @@ export default class CalendarEvents {
|
|
|
359
373
|
const $recurrenceFields = document.getElementById('recurrence-fields');
|
|
360
374
|
|
|
361
375
|
if (recurrence) {
|
|
376
|
+
const pattern = recurrence.pattern || 'monthly';
|
|
377
|
+
const isWeekday = pattern === 'weekly' || pattern === 'monthly-weekday';
|
|
378
|
+
|
|
362
379
|
$recurringCheckbox.checked = true;
|
|
363
380
|
$recurrenceFields.classList.remove('d-none');
|
|
364
|
-
document.getElementById('campaign-recurrence-pattern').value =
|
|
381
|
+
document.getElementById('campaign-recurrence-pattern').value = pattern;
|
|
365
382
|
document.getElementById('campaign-recurrence-hour').value = recurrence.hour || 0;
|
|
383
|
+
document.getElementById('campaign-recurrence-minute').value = recurrence.minute || 0;
|
|
366
384
|
document.getElementById('campaign-recurrence-day').value = recurrence.day || 1;
|
|
385
|
+
document.getElementById('campaign-recurrence-nth').value = recurrence.nth || 2;
|
|
367
386
|
document.getElementById('campaign-recurrence-month').value = recurrence.month || 1;
|
|
368
387
|
|
|
369
|
-
|
|
370
|
-
document.getElementById('recurrence-
|
|
388
|
+
document.getElementById('recurrence-month-row').classList.toggle('d-none', pattern !== 'yearly');
|
|
389
|
+
document.getElementById('recurrence-nth-row').classList.toggle('d-none', pattern !== 'monthly-weekday');
|
|
371
390
|
|
|
372
|
-
// Update day hint
|
|
373
391
|
const $dayHint = document.getElementById('recurrence-day-hint');
|
|
374
|
-
$
|
|
392
|
+
const $dayInput = document.getElementById('campaign-recurrence-day');
|
|
393
|
+
$dayHint.textContent = isWeekday ? '(of week, 0=Sun)' : '(of month)';
|
|
394
|
+
$dayInput.min = isWeekday ? 0 : 1;
|
|
395
|
+
$dayInput.max = isWeekday ? 6 : 31;
|
|
375
396
|
} else {
|
|
376
397
|
$recurringCheckbox.checked = false;
|
|
377
398
|
$recurrenceFields.classList.add('d-none');
|
|
378
399
|
}
|
|
400
|
+
|
|
401
|
+
this._updateLocalTimeHint();
|
|
379
402
|
}
|
|
380
403
|
|
|
381
404
|
_setType(type) {
|
|
@@ -386,6 +409,35 @@ export default class CalendarEvents {
|
|
|
386
409
|
}
|
|
387
410
|
}
|
|
388
411
|
|
|
412
|
+
_updateLocalTimeHint() {
|
|
413
|
+
const $row = document.getElementById('campaign-local-time-row');
|
|
414
|
+
const $hint = document.getElementById('campaign-time-local');
|
|
415
|
+
const date = document.getElementById('campaign-date').value;
|
|
416
|
+
const time = document.getElementById('campaign-time').value;
|
|
417
|
+
|
|
418
|
+
if (!date || !time) {
|
|
419
|
+
$row.style.display = 'none';
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
const [y, mo, d] = date.split('-').map(Number);
|
|
424
|
+
const [h, m] = time.split(':').map(Number);
|
|
425
|
+
const utc = new Date(Date.UTC(y, mo - 1, d, h, m));
|
|
426
|
+
|
|
427
|
+
const localStr = utc.toLocaleString('en', {
|
|
428
|
+
weekday: 'short',
|
|
429
|
+
month: 'short',
|
|
430
|
+
day: 'numeric',
|
|
431
|
+
year: 'numeric',
|
|
432
|
+
hour: 'numeric',
|
|
433
|
+
minute: '2-digit',
|
|
434
|
+
timeZoneName: 'short',
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
$hint.textContent = `Local: ${localStr}`;
|
|
438
|
+
$row.style.display = '';
|
|
439
|
+
}
|
|
440
|
+
|
|
389
441
|
// ============================================
|
|
390
442
|
// Payload Building
|
|
391
443
|
// ============================================
|
|
@@ -498,12 +550,17 @@ export default class CalendarEvents {
|
|
|
498
550
|
// Recurrence
|
|
499
551
|
if (c.recurring) {
|
|
500
552
|
const rec = c.recurrence || {};
|
|
553
|
+
const pattern = rec.pattern || 'monthly';
|
|
501
554
|
payload.recurrence = {
|
|
502
|
-
pattern
|
|
555
|
+
pattern,
|
|
503
556
|
hour: parseInt(rec.hour, 10) || 0,
|
|
557
|
+
minute: parseInt(rec.minute, 10) || 0,
|
|
504
558
|
day: parseInt(rec.day, 10) || 1,
|
|
505
559
|
};
|
|
506
|
-
if (
|
|
560
|
+
if (pattern === 'monthly-weekday') {
|
|
561
|
+
payload.recurrence.nth = parseInt(rec.nth, 10) || 1;
|
|
562
|
+
}
|
|
563
|
+
if (pattern === 'yearly') {
|
|
507
564
|
payload.recurrence.month = parseInt(rec.month, 10) || 1;
|
|
508
565
|
}
|
|
509
566
|
}
|
|
@@ -616,8 +673,13 @@ export default class CalendarEvents {
|
|
|
616
673
|
|
|
617
674
|
// Update recurrence metadata for the backend cron
|
|
618
675
|
recurrence.hour = d.getUTCHours();
|
|
676
|
+
recurrence.minute = d.getUTCMinutes();
|
|
619
677
|
if (recurrence.pattern === 'weekly') {
|
|
620
678
|
recurrence.day = d.getUTCDay();
|
|
679
|
+
} else if (recurrence.pattern === 'monthly-weekday') {
|
|
680
|
+
recurrence.day = d.getUTCDay();
|
|
681
|
+
// Calculate which occurrence of this weekday falls on this date (1st, 2nd, 3rd, 4th)
|
|
682
|
+
recurrence.nth = Math.ceil(d.getUTCDate() / 7);
|
|
621
683
|
} else if (recurrence.pattern === 'monthly' || recurrence.pattern === 'quarterly') {
|
|
622
684
|
recurrence.day = d.getUTCDate();
|
|
623
685
|
} else if (recurrence.pattern === 'yearly') {
|
|
@@ -735,9 +797,12 @@ export default class CalendarEvents {
|
|
|
735
797
|
document.getElementById('recurrence-fields').classList.add('d-none');
|
|
736
798
|
document.getElementById('campaign-recurrence-pattern').value = 'monthly';
|
|
737
799
|
document.getElementById('campaign-recurrence-hour').value = '14';
|
|
800
|
+
document.getElementById('campaign-recurrence-minute').value = '0';
|
|
738
801
|
document.getElementById('campaign-recurrence-day').value = '1';
|
|
802
|
+
document.getElementById('campaign-recurrence-nth').value = '2';
|
|
739
803
|
document.getElementById('campaign-recurrence-month').value = '1';
|
|
740
804
|
document.getElementById('recurrence-month-row').classList.add('d-none');
|
|
805
|
+
document.getElementById('recurrence-nth-row').classList.add('d-none');
|
|
741
806
|
}
|
|
742
807
|
|
|
743
808
|
}
|
|
@@ -110,10 +110,9 @@ export default class CalendarRenderer {
|
|
|
110
110
|
html += `<div class="calendar-cell-date">${cell.date.getUTCDate()}</div>`;
|
|
111
111
|
html += '<div class="calendar-cell-events">';
|
|
112
112
|
|
|
113
|
-
|
|
114
|
-
campaigns.
|
|
115
|
-
|
|
116
|
-
html += `<div class="calendar-cell-more" data-date="${dateStr}">+${campaigns.length - maxVisible} more</div>`;
|
|
113
|
+
campaigns.forEach((c) => { html += this._renderEventPill(c); });
|
|
114
|
+
if (campaigns.length > 0) {
|
|
115
|
+
html += `<div class="calendar-cell-more" data-date="${dateStr}" style="display:none"></div>`;
|
|
117
116
|
}
|
|
118
117
|
|
|
119
118
|
html += '</div></div>';
|
|
@@ -121,11 +120,63 @@ export default class CalendarRenderer {
|
|
|
121
120
|
html += '</div>';
|
|
122
121
|
|
|
123
122
|
this.$grid.innerHTML = html;
|
|
123
|
+
this._fitMonthEvents();
|
|
124
124
|
this._bindCellClicks();
|
|
125
125
|
this._bindCampaignClicks();
|
|
126
126
|
this._bindDragAndDrop();
|
|
127
127
|
}
|
|
128
128
|
|
|
129
|
+
_fitMonthEvents() {
|
|
130
|
+
this.$grid.querySelectorAll('.calendar-cell').forEach(($cell) => {
|
|
131
|
+
const $events = $cell.querySelector('.calendar-cell-events');
|
|
132
|
+
if (!$events) { return; }
|
|
133
|
+
|
|
134
|
+
const $pills = $events.querySelectorAll('.calendar-event');
|
|
135
|
+
const $more = $events.querySelector('.calendar-cell-more');
|
|
136
|
+
if ($pills.length === 0) { return; }
|
|
137
|
+
|
|
138
|
+
// Available height = cell inner height minus everything above the events container
|
|
139
|
+
const cellRect = $cell.getBoundingClientRect();
|
|
140
|
+
const eventsRect = $events.getBoundingClientRect();
|
|
141
|
+
const available = cellRect.bottom - eventsRect.top;
|
|
142
|
+
|
|
143
|
+
const pillHeight = $pills[0].getBoundingClientRect().height;
|
|
144
|
+
const gap = 1;
|
|
145
|
+
|
|
146
|
+
// Measure "+more" line height
|
|
147
|
+
if ($more) {
|
|
148
|
+
$more.textContent = '+1 more';
|
|
149
|
+
$more.style.display = '';
|
|
150
|
+
}
|
|
151
|
+
const moreLineHeight = $more ? $more.getBoundingClientRect().height + gap : 0;
|
|
152
|
+
if ($more) { $more.style.display = 'none'; }
|
|
153
|
+
|
|
154
|
+
let shown = 0;
|
|
155
|
+
|
|
156
|
+
for (let i = 0; i < $pills.length; i++) {
|
|
157
|
+
const heightSoFar = (shown + 1) * pillHeight + shown * gap;
|
|
158
|
+
const remaining = $pills.length - i - 1;
|
|
159
|
+
const wouldNeedMore = remaining > 0;
|
|
160
|
+
const totalNeeded = heightSoFar + (wouldNeedMore ? moreLineHeight : 0);
|
|
161
|
+
|
|
162
|
+
if (totalNeeded <= available) {
|
|
163
|
+
$pills[i].style.display = '';
|
|
164
|
+
shown++;
|
|
165
|
+
} else {
|
|
166
|
+
$pills[i].style.display = 'none';
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if ($more) {
|
|
171
|
+
const hidden = $pills.length - shown;
|
|
172
|
+
if (hidden > 0) {
|
|
173
|
+
$more.textContent = `+${hidden} more`;
|
|
174
|
+
$more.style.display = '';
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
129
180
|
// ============================================
|
|
130
181
|
// Week View
|
|
131
182
|
// ============================================
|
|
@@ -141,12 +192,6 @@ export default class CalendarRenderer {
|
|
|
141
192
|
});
|
|
142
193
|
html += '</div>';
|
|
143
194
|
|
|
144
|
-
html += '<div class="calendar-week-allday"><div class="calendar-week-time-label">all-day</div>';
|
|
145
|
-
weekDates.forEach((date) => {
|
|
146
|
-
html += `<div class="calendar-cell" data-date="${formatDateUTC(date)}" data-allday="true" style="min-height:auto;border-bottom:none;"></div>`;
|
|
147
|
-
});
|
|
148
|
-
html += '</div>';
|
|
149
|
-
|
|
150
195
|
html += '<div class="calendar-week-body"><div class="calendar-week-time-col">';
|
|
151
196
|
hours.forEach((hour) => { html += `<div class="calendar-week-time-label">${hour.label}</div>`; });
|
|
152
197
|
html += '</div>';
|
|
@@ -277,7 +322,7 @@ export default class CalendarRenderer {
|
|
|
277
322
|
html += `<tr class="calendar-list-date-header"><td colspan="4">${dayName}, ${monthName} ${d.getUTCDate()}, ${d.getUTCFullYear()}${todayBadge}</td></tr>`;
|
|
278
323
|
}
|
|
279
324
|
|
|
280
|
-
const timeStr = this.
|
|
325
|
+
const timeStr = this._formatLocalTime(campaign.sendAt);
|
|
281
326
|
const name = (campaign.settings && campaign.settings.name) || 'Untitled';
|
|
282
327
|
const statusStyle = core.campaignStatusStyle(campaign);
|
|
283
328
|
const isRecurring = core.isRecurring(campaign);
|
|
@@ -320,7 +365,7 @@ export default class CalendarRenderer {
|
|
|
320
365
|
|
|
321
366
|
_renderEventPill(campaign) {
|
|
322
367
|
const core = this.core;
|
|
323
|
-
const timeStr = this.
|
|
368
|
+
const timeStr = this._formatLocalTime(campaign.sendAt);
|
|
324
369
|
const color = core.campaignColor(campaign);
|
|
325
370
|
const statusStyle = core.campaignStatusStyle(campaign);
|
|
326
371
|
const name = (campaign.settings && campaign.settings.name) || 'Untitled';
|
|
@@ -356,11 +401,11 @@ export default class CalendarRenderer {
|
|
|
356
401
|
|
|
357
402
|
_renderTimeEvent(campaign, layout) {
|
|
358
403
|
const core = this.core;
|
|
359
|
-
const
|
|
360
|
-
const [hours, minutes] =
|
|
404
|
+
const utcTime = formatTimeUTC(campaign.sendAt);
|
|
405
|
+
const [hours, minutes] = utcTime.split(':').map(Number);
|
|
361
406
|
const topPx = (hours * 60 + minutes);
|
|
362
407
|
const heightPx = Math.max(core.campaignDuration(), 15);
|
|
363
|
-
const timeStr = this.
|
|
408
|
+
const timeStr = this._formatLocalTime(campaign.sendAt);
|
|
364
409
|
const color = core.campaignColor(campaign);
|
|
365
410
|
const statusStyle = core.campaignStatusStyle(campaign);
|
|
366
411
|
const name = (campaign.settings && campaign.settings.name) || 'Untitled';
|
|
@@ -452,7 +497,7 @@ export default class CalendarRenderer {
|
|
|
452
497
|
// ============================================
|
|
453
498
|
_startNowLine() {
|
|
454
499
|
clearInterval(this._nowLineInterval);
|
|
455
|
-
if (this.core.viewMode !== 'day' && this.core.viewMode !== 'week') {
|
|
500
|
+
if (this.core.viewMode !== 'day' && this.core.viewMode !== 'week' && this.core.viewMode !== 'month') {
|
|
456
501
|
return;
|
|
457
502
|
}
|
|
458
503
|
this._updateNowLine();
|
|
@@ -466,6 +511,7 @@ export default class CalendarRenderer {
|
|
|
466
511
|
const todayStr = formatDateUTC(now);
|
|
467
512
|
const minutesSinceMidnight = now.getUTCHours() * 60 + now.getUTCMinutes();
|
|
468
513
|
|
|
514
|
+
// Week/day views: absolute position in px (1px per minute)
|
|
469
515
|
const $cols = this.$grid.querySelectorAll(
|
|
470
516
|
`.calendar-week-day-col[data-date="${todayStr}"], .calendar-day-col[data-date="${todayStr}"]`
|
|
471
517
|
);
|
|
@@ -476,6 +522,24 @@ export default class CalendarRenderer {
|
|
|
476
522
|
$line.style.top = `${minutesSinceMidnight}px`;
|
|
477
523
|
$col.appendChild($line);
|
|
478
524
|
});
|
|
525
|
+
|
|
526
|
+
// Month view: position on the grid container so the dot isn't clipped by cell overflow
|
|
527
|
+
if (this.core.viewMode === 'month') {
|
|
528
|
+
const $cell = this.$grid.querySelector(`.calendar-cell[data-date="${todayStr}"]`);
|
|
529
|
+
if ($cell) {
|
|
530
|
+
const gridRect = this.$grid.getBoundingClientRect();
|
|
531
|
+
const cellRect = $cell.getBoundingClientRect();
|
|
532
|
+
const pct = minutesSinceMidnight / 1440;
|
|
533
|
+
const topPx = (cellRect.top - gridRect.top) + (cellRect.height * pct);
|
|
534
|
+
|
|
535
|
+
const $line = document.createElement('div');
|
|
536
|
+
$line.className = 'calendar-now-line';
|
|
537
|
+
$line.style.top = `${topPx}px`;
|
|
538
|
+
$line.style.left = `${cellRect.left - gridRect.left}px`;
|
|
539
|
+
$line.style.width = `${cellRect.width}px`;
|
|
540
|
+
this.$grid.appendChild($line);
|
|
541
|
+
}
|
|
542
|
+
}
|
|
479
543
|
}
|
|
480
544
|
|
|
481
545
|
// ============================================
|
|
@@ -679,5 +743,14 @@ export default class CalendarRenderer {
|
|
|
679
743
|
const display = h === 0 ? 12 : h > 12 ? h - 12 : h;
|
|
680
744
|
return `${display}${m > 0 ? ':' + String(m).padStart(2, '0') : ''}${period}`;
|
|
681
745
|
}
|
|
746
|
+
|
|
747
|
+
_formatLocalTime(sendAt) {
|
|
748
|
+
const d = new Date(sendAt * 1000);
|
|
749
|
+
const h = d.getHours();
|
|
750
|
+
const m = d.getMinutes();
|
|
751
|
+
const period = h >= 12 ? 'p' : 'a';
|
|
752
|
+
const display = h === 0 ? 12 : h > 12 ? h - 12 : h;
|
|
753
|
+
return `${display}${m > 0 ? ':' + String(m).padStart(2, '0') : ''}${period}`;
|
|
754
|
+
}
|
|
682
755
|
}
|
|
683
756
|
|
|
@@ -305,7 +305,7 @@ prerender_icons:
|
|
|
305
305
|
<small class="text-muted fw-normal ms-1">(UTC)</small>
|
|
306
306
|
</h6>
|
|
307
307
|
|
|
308
|
-
<div class="row mb-
|
|
308
|
+
<div class="row mb-2">
|
|
309
309
|
<div class="col-md-5">
|
|
310
310
|
<label for="campaign-date" class="form-label">
|
|
311
311
|
Date (UTC) <span class="text-danger">*</span>
|
|
@@ -336,6 +336,9 @@ prerender_icons:
|
|
|
336
336
|
</button>
|
|
337
337
|
</div>
|
|
338
338
|
</div>
|
|
339
|
+
<div class="mb-3" id="campaign-local-time-row" style="display:none">
|
|
340
|
+
<span class="badge bg-body-secondary text-body-secondary" id="campaign-time-local"></span>
|
|
341
|
+
</div>
|
|
339
342
|
|
|
340
343
|
<!-- Recurrence -->
|
|
341
344
|
<h6 class="mb-3 mt-4">
|
|
@@ -361,20 +364,32 @@ prerender_icons:
|
|
|
361
364
|
<select class="form-select" id="campaign-recurrence-pattern" name="campaign.recurrence.pattern">
|
|
362
365
|
<option value="daily">Daily</option>
|
|
363
366
|
<option value="weekly">Weekly</option>
|
|
364
|
-
<option value="monthly" selected>Monthly</option>
|
|
367
|
+
<option value="monthly" selected>Monthly (day of month)</option>
|
|
368
|
+
<option value="monthly-weekday">Monthly (Nth weekday)</option>
|
|
365
369
|
<option value="quarterly">Quarterly</option>
|
|
366
370
|
<option value="yearly">Yearly</option>
|
|
367
371
|
</select>
|
|
368
372
|
</div>
|
|
369
373
|
<div class="col-md-4">
|
|
370
374
|
<label for="campaign-recurrence-hour" class="form-label">Hour (UTC)</label>
|
|
371
|
-
<
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
375
|
+
<div class="input-group">
|
|
376
|
+
<input type="number"
|
|
377
|
+
class="form-control"
|
|
378
|
+
id="campaign-recurrence-hour"
|
|
379
|
+
name="campaign.recurrence.hour"
|
|
380
|
+
value="14"
|
|
381
|
+
min="0"
|
|
382
|
+
max="23">
|
|
383
|
+
<span class="input-group-text">:</span>
|
|
384
|
+
<input type="number"
|
|
385
|
+
class="form-control"
|
|
386
|
+
id="campaign-recurrence-minute"
|
|
387
|
+
name="campaign.recurrence.minute"
|
|
388
|
+
value="0"
|
|
389
|
+
min="0"
|
|
390
|
+
max="59"
|
|
391
|
+
placeholder="Min">
|
|
392
|
+
</div>
|
|
378
393
|
</div>
|
|
379
394
|
<div class="col-md-4">
|
|
380
395
|
<label for="campaign-recurrence-day" class="form-label">
|
|
@@ -391,6 +406,18 @@ prerender_icons:
|
|
|
391
406
|
</div>
|
|
392
407
|
</div>
|
|
393
408
|
|
|
409
|
+
<div class="row mb-3 d-none" id="recurrence-nth-row">
|
|
410
|
+
<div class="col-md-4">
|
|
411
|
+
<label for="campaign-recurrence-nth" class="form-label">Occurrence</label>
|
|
412
|
+
<select class="form-select" id="campaign-recurrence-nth" name="campaign.recurrence.nth">
|
|
413
|
+
<option value="1">1st</option>
|
|
414
|
+
<option value="2" selected>2nd</option>
|
|
415
|
+
<option value="3">3rd</option>
|
|
416
|
+
<option value="4">4th</option>
|
|
417
|
+
</select>
|
|
418
|
+
</div>
|
|
419
|
+
</div>
|
|
420
|
+
|
|
394
421
|
<div class="row mb-3 d-none" id="recurrence-month-row">
|
|
395
422
|
<div class="col-md-4">
|
|
396
423
|
<label for="campaign-recurrence-month" class="form-label">Month</label>
|
|
@@ -17,8 +17,8 @@ const ujmConfig = Manager.getUJMConfig();
|
|
|
17
17
|
// Settings
|
|
18
18
|
const CACHE_DIR = '.temp/cache/imagemin';
|
|
19
19
|
const CACHE_BRANCH = 'cache-uj-imagemin';
|
|
20
|
-
const
|
|
21
|
-
const
|
|
20
|
+
const IMAGE_MAX_DIMENSION = 2048;
|
|
21
|
+
const IMAGE_JPEG_QUALITY = 80;
|
|
22
22
|
|
|
23
23
|
// Variables
|
|
24
24
|
let githubCache;
|
|
@@ -129,7 +129,7 @@ async function imagemin(complete) {
|
|
|
129
129
|
}
|
|
130
130
|
|
|
131
131
|
// Optionally rewrite oversized source images on disk (opt-in via UJ_IMAGEMIN_REWRITE_SOURCES=true).
|
|
132
|
-
// Caps longest dimension at
|
|
132
|
+
// Caps longest dimension at IMAGE_MAX_DIMENSION so gulp-responsive-modern + sharp don't stall
|
|
133
133
|
// on huge inputs. Runs BEFORE determineFilesToProcess so cached-but-oversized images get
|
|
134
134
|
// rewritten too; the new on-disk content hashes differently than the stored meta hash, so
|
|
135
135
|
// determineFilesToProcess naturally picks the rewritten image up for re-optimization.
|
|
@@ -305,7 +305,7 @@ module.exports = series(
|
|
|
305
305
|
// Helper Functions
|
|
306
306
|
// ============================================================================
|
|
307
307
|
|
|
308
|
-
// Rewrite oversized source images in place, capping longest dimension at
|
|
308
|
+
// Rewrite oversized source images in place, capping longest dimension at IMAGE_MAX_DIMENSION.
|
|
309
309
|
// Only affects files whose decoded longest side exceeds the cap. Cache invalidation is implicit:
|
|
310
310
|
// the new content hashes differently than the previously-cached entry, so determineFilesToProcess
|
|
311
311
|
// will pick affected files up for re-optimization on its own.
|
|
@@ -315,14 +315,14 @@ async function rewriteOversizedSources(files) {
|
|
|
315
315
|
return;
|
|
316
316
|
}
|
|
317
317
|
|
|
318
|
-
logger.log(`🔍 Checking ${responsiveFiles.length} source images for oversize (>${
|
|
318
|
+
logger.log(`🔍 Checking ${responsiveFiles.length} source images for oversize (>${IMAGE_MAX_DIMENSION}px longest side)...`);
|
|
319
319
|
|
|
320
320
|
let rewritten = 0;
|
|
321
321
|
for (const file of responsiveFiles) {
|
|
322
322
|
const metadata = await sharp(file).metadata();
|
|
323
323
|
const longest = Math.max(metadata.width || 0, metadata.height || 0);
|
|
324
324
|
|
|
325
|
-
if (longest <=
|
|
325
|
+
if (longest <= IMAGE_MAX_DIMENSION) {
|
|
326
326
|
continue;
|
|
327
327
|
}
|
|
328
328
|
|
|
@@ -331,15 +331,15 @@ async function rewriteOversizedSources(files) {
|
|
|
331
331
|
|
|
332
332
|
// Resize, encode to a buffer (sharp can't write back to its own input file directly), then overwrite.
|
|
333
333
|
const pipeline = sharp(file).resize({
|
|
334
|
-
width:
|
|
335
|
-
height:
|
|
334
|
+
width: IMAGE_MAX_DIMENSION,
|
|
335
|
+
height: IMAGE_MAX_DIMENSION,
|
|
336
336
|
fit: 'inside',
|
|
337
337
|
withoutEnlargement: true,
|
|
338
338
|
});
|
|
339
339
|
|
|
340
340
|
const buffer = ext === 'png'
|
|
341
|
-
? await pipeline.png({ quality:
|
|
342
|
-
: await pipeline.jpeg({ quality:
|
|
341
|
+
? await pipeline.png({ quality: IMAGE_JPEG_QUALITY }).toBuffer()
|
|
342
|
+
: await pipeline.jpeg({ quality: IMAGE_JPEG_QUALITY, progressive: true, mozjpeg: true }).toBuffer();
|
|
343
343
|
|
|
344
344
|
jetpack.write(file, buffer);
|
|
345
345
|
const sizeAfter = buffer.length;
|
|
@@ -350,11 +350,11 @@ async function rewriteOversizedSources(files) {
|
|
|
350
350
|
// with the new hash when determineFilesToProcess() runs.
|
|
351
351
|
|
|
352
352
|
rewritten++;
|
|
353
|
-
logger.log(`✂️ Rewrote ${path.relative(rootPathProject, file)}: ${metadata.width}x${metadata.height} → max ${
|
|
353
|
+
logger.log(`✂️ Rewrote ${path.relative(rootPathProject, file)}: ${metadata.width}x${metadata.height} → max ${IMAGE_MAX_DIMENSION}px, ${formatBytes(sizeBefore)} → ${formatBytes(sizeAfter)}`);
|
|
354
354
|
}
|
|
355
355
|
|
|
356
356
|
if (rewritten === 0) {
|
|
357
|
-
logger.log(`✅ No oversized sources found (all within ${
|
|
357
|
+
logger.log(`✅ No oversized sources found (all within ${IMAGE_MAX_DIMENSION}px)`);
|
|
358
358
|
} else {
|
|
359
359
|
logger.log(`✂️ Rewrote ${rewritten} oversized source image(s)`);
|
|
360
360
|
}
|
|
@@ -42,7 +42,6 @@ const AI = {
|
|
|
42
42
|
}
|
|
43
43
|
const CACHE_DIR = '.temp/cache/translation';
|
|
44
44
|
const CACHE_BRANCH = 'cache-uj-translation';
|
|
45
|
-
const RECHECK_DAYS = 0;
|
|
46
45
|
// const LOUD = false;
|
|
47
46
|
const LOUD = process.env.UJ_LOUD_LOGS === 'true';
|
|
48
47
|
const CONTROL = 'UJ-TRANSLATION-CONTROL';
|
|
@@ -171,8 +170,6 @@ module.exports = series(
|
|
|
171
170
|
async function processTranslation() {
|
|
172
171
|
const enabled = config?.translation?.enabled !== false;
|
|
173
172
|
const languages = config?.translation?.languages || [];
|
|
174
|
-
const updatedFiles = new Set();
|
|
175
|
-
|
|
176
173
|
// Track timing
|
|
177
174
|
const startTime = Date.now();
|
|
178
175
|
|
|
@@ -199,14 +196,12 @@ async function processTranslation() {
|
|
|
199
196
|
logger.log(`Translating ${allFiles.length} files for ${languages.length} supported languages: ${languages.join(', ')}`);
|
|
200
197
|
// logger.log(allFiles);
|
|
201
198
|
|
|
202
|
-
// Prepare
|
|
203
|
-
const metas = {
|
|
204
|
-
global: {
|
|
205
|
-
skipped: new Set(),
|
|
206
|
-
}
|
|
207
|
-
};
|
|
199
|
+
// Prepare prompt hash for cache invalidation
|
|
208
200
|
const promptHash = crypto.createHash('sha256').update(SYSTEM_PROMPT).digest('hex');
|
|
201
|
+
const skippedFiles = new Set();
|
|
209
202
|
|
|
203
|
+
// Load per-language meta (prompt hash only)
|
|
204
|
+
const metas = {};
|
|
210
205
|
for (const lang of languages) {
|
|
211
206
|
const metaPath = path.join(CACHE_DIR, lang, 'meta.json');
|
|
212
207
|
let meta = {};
|
|
@@ -218,30 +213,30 @@ async function processTranslation() {
|
|
|
218
213
|
}
|
|
219
214
|
}
|
|
220
215
|
|
|
221
|
-
// Check if the promptHash matches; if not,
|
|
216
|
+
// Check if the promptHash matches; if not, wipe all page caches for this language
|
|
222
217
|
if (meta.prompt?.hash !== promptHash) {
|
|
223
|
-
|
|
224
|
-
|
|
218
|
+
const pagesDir = path.join(CACHE_DIR, lang, 'pages');
|
|
219
|
+
const existing = jetpack.exists(pagesDir) ? jetpack.find(pagesDir, { matching: '**/*', files: true }).length : 0;
|
|
220
|
+
logger.warn(`⚠️ Prompt cache MISS [${lang}]: hash mismatch — clearing ${existing} cached page files.`);
|
|
221
|
+
jetpack.remove(pagesDir);
|
|
225
222
|
} else {
|
|
226
|
-
const
|
|
227
|
-
|
|
223
|
+
const pagesDir = path.join(CACHE_DIR, lang, 'pages');
|
|
224
|
+
const existing = jetpack.exists(pagesDir) ? jetpack.find(pagesDir, { matching: '**/*', files: true }).length : 0;
|
|
225
|
+
logger.log(`✅ Prompt cache HIT [${lang}]: ${existing} cached page files available.`);
|
|
228
226
|
}
|
|
229
227
|
|
|
230
|
-
// Store the current promptHash in the meta file
|
|
231
228
|
meta.prompt = { hash: promptHash };
|
|
232
|
-
metas[lang] = { meta, path: metaPath
|
|
229
|
+
metas[lang] = { meta, path: metaPath };
|
|
233
230
|
}
|
|
234
231
|
|
|
235
232
|
// Track token usage and statistics
|
|
236
233
|
const tokens = { input: 0, output: 0 };
|
|
237
234
|
const tasks = [];
|
|
238
235
|
const stats = {
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
cachedFiles: [],
|
|
244
|
-
processedFiles: []
|
|
236
|
+
totalPages: 0,
|
|
237
|
+
cachedStrings: 0,
|
|
238
|
+
newStrings: 0,
|
|
239
|
+
failedPages: [],
|
|
245
240
|
};
|
|
246
241
|
|
|
247
242
|
// Calculate total tasks for progress tracking
|
|
@@ -264,29 +259,24 @@ async function processTranslation() {
|
|
|
264
259
|
// Collect text nodes
|
|
265
260
|
const textNodes = collectTextNodes($, { tag: false });
|
|
266
261
|
|
|
267
|
-
// Build strings array
|
|
262
|
+
// Build strings array and per-string hashes
|
|
268
263
|
const strings = textNodes.map(n => n.text);
|
|
269
|
-
|
|
270
|
-
// Compute hash from the strings
|
|
271
|
-
const hash = crypto.createHash('sha256').update(JSON.stringify(strings)).digest('hex');
|
|
264
|
+
const stringHashes = strings.map(s => crypto.createHash('sha256').update(s).digest('hex').slice(0, 12));
|
|
272
265
|
|
|
273
266
|
// Skip all except the specified HTML file
|
|
274
267
|
if (ujOnly && relativePath !== ujOnly) {
|
|
275
|
-
|
|
276
|
-
metas.global.skipped.add(`${relativePath} (UJ_TRANSLATION_ONLY set)`);
|
|
268
|
+
skippedFiles.add(`${relativePath} (UJ_TRANSLATION_ONLY set)`);
|
|
277
269
|
continue;
|
|
278
270
|
}
|
|
279
271
|
|
|
280
272
|
// Log the page being processed
|
|
281
|
-
logger.log(`🔍 Processing: ${relativePath} (${strings.length} strings
|
|
273
|
+
logger.log(`🔍 Processing: ${relativePath} (${strings.length} strings)`);
|
|
282
274
|
if (LOUD) logger.log(`🔍 Strings: \n${JSON.stringify(strings, null, 2)}`)
|
|
283
275
|
|
|
284
|
-
// Translate this file for all languages
|
|
276
|
+
// Translate this file for all languages
|
|
285
277
|
for (const lang of languages) {
|
|
286
278
|
const task = async () => {
|
|
287
|
-
const
|
|
288
|
-
const cachePath = path.join(CACHE_DIR, lang, 'pages', relativePath);
|
|
289
|
-
// const outPath = path.join('_site', lang, relativePath);
|
|
279
|
+
const cachePath = path.join(CACHE_DIR, lang, 'pages', `${relativePath}.json`);
|
|
290
280
|
const isHomepage = relativePath === 'index.html';
|
|
291
281
|
const outPath = isHomepage
|
|
292
282
|
? path.join('_site', `${lang}.html`)
|
|
@@ -301,75 +291,75 @@ async function processTranslation() {
|
|
|
301
291
|
// Log
|
|
302
292
|
logger.log(`🌐 Started [${progress} - ${percentage}%]: ${logTag}`);
|
|
303
293
|
|
|
304
|
-
// Skip if the file is not in the meta or if it has no text nodes
|
|
305
|
-
let translated = null;
|
|
306
|
-
const entry = meta[relativePath];
|
|
307
|
-
const age = entry?.timestamp
|
|
308
|
-
? (Date.now() - new Date(entry.timestamp).getTime()) / (1000 * 60 * 60 * 24)
|
|
309
|
-
: Infinity;
|
|
310
|
-
const useCached = entry
|
|
311
|
-
&& entry.hash === hash
|
|
312
|
-
&& (RECHECK_DAYS === 0 || age < RECHECK_DAYS);
|
|
313
294
|
const startTime = Date.now();
|
|
314
295
|
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
296
|
+
// Load existing per-string cache for this page
|
|
297
|
+
let pageCache = {};
|
|
298
|
+
if (jetpack.exists(cachePath)) {
|
|
299
|
+
try {
|
|
300
|
+
pageCache = jetpack.read(cachePath, 'json') || {};
|
|
301
|
+
} catch (e) {
|
|
302
|
+
pageCache = {};
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Separate cached vs uncached strings
|
|
307
|
+
const translated = new Array(strings.length);
|
|
308
|
+
const uncachedIndices = [];
|
|
309
|
+
|
|
310
|
+
for (let i = 0; i < strings.length; i++) {
|
|
311
|
+
const cached = pageCache[stringHashes[i]];
|
|
312
|
+
if (cached !== undefined) {
|
|
313
|
+
translated[i] = cached;
|
|
314
|
+
stats.cachedStrings++;
|
|
321
315
|
} else {
|
|
322
|
-
|
|
323
|
-
timestamp: 0,
|
|
324
|
-
hash: '__fail__',
|
|
325
|
-
};
|
|
316
|
+
uncachedIndices.push(i);
|
|
326
317
|
}
|
|
327
318
|
}
|
|
328
319
|
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
logger.log(`📦 Success [${progress} - ${percentage}%]: ${logTag} - Using cache`);
|
|
336
|
-
stats.fromCache++;
|
|
337
|
-
stats.cachedFiles.push(logTag);
|
|
320
|
+
const cachedCount = strings.length - uncachedIndices.length;
|
|
321
|
+
|
|
322
|
+
// If everything is cached, skip API call
|
|
323
|
+
if (uncachedIndices.length === 0) {
|
|
324
|
+
const elapsedTime = ((Date.now() - startTime) / 1000).toFixed(2);
|
|
325
|
+
logger.log(`📦 Success [${progress} - ${percentage}%]: ${logTag} — All ${strings.length} strings from cache (${elapsedTime}s)`);
|
|
338
326
|
} else {
|
|
339
|
-
|
|
340
|
-
|
|
327
|
+
// Translate only the uncached strings
|
|
328
|
+
const uncachedStrings = uncachedIndices.map(i => strings[i]);
|
|
341
329
|
|
|
342
|
-
|
|
343
|
-
const
|
|
344
|
-
logger.log(`✅ Success [${progress} - ${percentage}%]: ${logTag} - Translated (Elapsed time: ${elapsedTime}s)`);
|
|
330
|
+
try {
|
|
331
|
+
const { result, usage } = await translateWithAPI(openAIKey, uncachedStrings, lang, logTag);
|
|
345
332
|
|
|
346
|
-
//
|
|
347
|
-
|
|
333
|
+
// Place translations back at their original indices and update cache
|
|
334
|
+
for (let j = 0; j < uncachedIndices.length; j++) {
|
|
335
|
+
const origIndex = uncachedIndices[j];
|
|
336
|
+
translated[origIndex] = result[j];
|
|
337
|
+
pageCache[stringHashes[origIndex]] = result[j];
|
|
338
|
+
}
|
|
348
339
|
|
|
349
340
|
// Update token totals
|
|
350
341
|
tokens.input += usage.input_tokens || 0;
|
|
351
342
|
tokens.output += usage.output_tokens || 0;
|
|
343
|
+
stats.newStrings += uncachedIndices.length;
|
|
352
344
|
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
// Set result
|
|
357
|
-
setResult(true);
|
|
358
|
-
stats.newlyProcessed++;
|
|
359
|
-
stats.processedFiles.push(logTag);
|
|
345
|
+
const elapsedTime = ((Date.now() - startTime) / 1000).toFixed(2);
|
|
346
|
+
logger.log(`✅ Success [${progress} - ${percentage}%]: ${logTag} — ${uncachedIndices.length} new + ${cachedCount} cached (${elapsedTime}s)`);
|
|
360
347
|
} catch (e) {
|
|
361
348
|
const elapsedTime = ((Date.now() - startTime) / 1000).toFixed(2);
|
|
362
|
-
logger.error(`❌ Failed [${progress} - ${percentage}%]: ${logTag} — ${e.message} (
|
|
349
|
+
logger.error(`❌ Failed [${progress} - ${percentage}%]: ${logTag} — ${e.message} (${elapsedTime}s)`);
|
|
363
350
|
|
|
364
|
-
//
|
|
365
|
-
|
|
351
|
+
// Fill uncached slots with originals
|
|
352
|
+
for (const i of uncachedIndices) {
|
|
353
|
+
translated[i] = strings[i];
|
|
354
|
+
}
|
|
366
355
|
|
|
367
|
-
|
|
368
|
-
setResult(false);
|
|
369
|
-
stats.failedFiles.push(logTag);
|
|
356
|
+
stats.failedPages.push(logTag);
|
|
370
357
|
}
|
|
371
358
|
}
|
|
372
359
|
|
|
360
|
+
// Save updated page cache
|
|
361
|
+
jetpack.write(cachePath, pageCache);
|
|
362
|
+
|
|
373
363
|
// Reset the DOM to avoid conflicts between languages
|
|
374
364
|
const $ = cheerio.load(originalHtml);
|
|
375
365
|
const textNodes = collectTextNodes($, { tag: false });
|
|
@@ -387,8 +377,7 @@ async function processTranslation() {
|
|
|
387
377
|
translation.includes(CONTROL)
|
|
388
378
|
&& n.node.attr('id') !== CONTROL
|
|
389
379
|
) {
|
|
390
|
-
logger.error(`❌ Failed: ${logTag} — Control tag mismatch at index ${i}`);
|
|
391
|
-
return setResult(false);
|
|
380
|
+
return logger.error(`❌ Failed: ${logTag} — Control tag mismatch at index ${i}`);
|
|
392
381
|
}
|
|
393
382
|
|
|
394
383
|
// Preserve original leading/trailing whitespace
|
|
@@ -419,12 +408,9 @@ async function processTranslation() {
|
|
|
419
408
|
|| controlTag.text() !== CONTROL
|
|
420
409
|
) {
|
|
421
410
|
logger.error(`❌ Failed: ${logTag} — Control tag mismatch or missing`);
|
|
422
|
-
return
|
|
411
|
+
return;
|
|
423
412
|
}
|
|
424
413
|
|
|
425
|
-
// Delete the control tag
|
|
426
|
-
// controlTag.remove();
|
|
427
|
-
|
|
428
414
|
// Set the lang attribute on the <html> tag
|
|
429
415
|
$('html').attr('lang', lang);
|
|
430
416
|
|
|
@@ -453,25 +439,7 @@ async function processTranslation() {
|
|
|
453
439
|
const sitemapXml = jetpack.read(sitemapPath);
|
|
454
440
|
await insertLanguageTags(cheerio.load(sitemapXml, { xmlMode: true }), languages, relativePath, sitemapPath);
|
|
455
441
|
|
|
456
|
-
|
|
457
|
-
// const formatted = await formatDocument($.html(), 'html');
|
|
458
|
-
|
|
459
|
-
// console.log('----relativePath', relativePath);
|
|
460
|
-
// console.log('----filePath', filePath);
|
|
461
|
-
// console.log('----outPath', outPath);
|
|
462
|
-
// console.log('----FORMATTED.ERROR', formatted.error);
|
|
463
|
-
|
|
464
|
-
// Write the translated file
|
|
465
|
-
// jetpack.write(outPath, formatted.content);
|
|
466
|
-
// logger.log(`✅ Wrote: ${outPath}`);
|
|
467
|
-
|
|
468
|
-
// Track updated files only if it's new or updated
|
|
469
|
-
// if (!useCached || !entry || entry.hash !== hash) {
|
|
470
|
-
// }
|
|
471
|
-
// Track updated files
|
|
472
|
-
updatedFiles.add(cachePath);
|
|
473
|
-
updatedFiles.add(metas[lang].path);
|
|
474
|
-
stats.totalProcessed++;
|
|
442
|
+
stats.totalPages++;
|
|
475
443
|
};
|
|
476
444
|
|
|
477
445
|
// Add to tasks
|
|
@@ -484,17 +452,9 @@ async function processTranslation() {
|
|
|
484
452
|
await Promise.all(tasks.map(task => q.add(task)));
|
|
485
453
|
|
|
486
454
|
// Log skipped files
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
if (meta.skipped.size > 0) {
|
|
491
|
-
logger.warn(` [${lang}] ${meta.skipped.size} skipped files:`);
|
|
492
|
-
meta.skipped.forEach(f => logger.warn(` ${f}`));
|
|
493
|
-
totalSkipped += meta.skipped.size;
|
|
494
|
-
}
|
|
495
|
-
}
|
|
496
|
-
if (totalSkipped === 0) {
|
|
497
|
-
logger.warn(' NONE');
|
|
455
|
+
if (skippedFiles.size > 0) {
|
|
456
|
+
logger.warn(`🚫 Skipped ${skippedFiles.size} files:`);
|
|
457
|
+
skippedFiles.forEach(f => logger.warn(` ${f}`));
|
|
498
458
|
}
|
|
499
459
|
|
|
500
460
|
// Save all updated meta files
|
|
@@ -526,13 +486,15 @@ async function processTranslation() {
|
|
|
526
486
|
logger.log(` End time: ${new Date(endTime).toLocaleTimeString()}`);
|
|
527
487
|
logger.log(` Total elapsed: ${elapsedFormatted}`);
|
|
528
488
|
|
|
529
|
-
//
|
|
530
|
-
|
|
531
|
-
logger.log(
|
|
532
|
-
logger.log(`
|
|
533
|
-
logger.log(`
|
|
534
|
-
|
|
535
|
-
|
|
489
|
+
// Processing stats
|
|
490
|
+
const totalStrings = stats.cachedStrings + stats.newStrings;
|
|
491
|
+
logger.log('\n📁 Processing:');
|
|
492
|
+
logger.log(` Pages processed: ${stats.totalPages}`);
|
|
493
|
+
logger.log(` Strings total: ${totalStrings.toLocaleString()}`);
|
|
494
|
+
logger.log(` From cache: ${stats.cachedStrings.toLocaleString()} (${totalStrings ? ((stats.cachedStrings / totalStrings) * 100).toFixed(1) : 0}%)`);
|
|
495
|
+
logger.log(` Newly translated: ${stats.newStrings.toLocaleString()} (${totalStrings ? ((stats.newStrings / totalStrings) * 100).toFixed(1) : 0}%)`);
|
|
496
|
+
if (stats.failedPages.length > 0) {
|
|
497
|
+
logger.log(` Failed pages: ${stats.failedPages.length}`);
|
|
536
498
|
}
|
|
537
499
|
|
|
538
500
|
// Token usage
|
|
@@ -563,40 +525,15 @@ async function processTranslation() {
|
|
|
563
525
|
forceRecreate: true, // ALWAYS create a fresh branch - no history needed
|
|
564
526
|
stats: {
|
|
565
527
|
timestamp: new Date().toISOString(),
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
},
|
|
576
|
-
tokenUsage: tokens.input > 0 || tokens.output > 0 ? {
|
|
577
|
-
inputTokens: tokens.input,
|
|
578
|
-
outputTokens: tokens.output,
|
|
579
|
-
totalTokens: tokens.input + tokens.output,
|
|
580
|
-
inputCost,
|
|
581
|
-
outputCost,
|
|
582
|
-
totalCost
|
|
583
|
-
} : undefined,
|
|
584
|
-
languageBreakdown: languages.map(lang => ({
|
|
585
|
-
language: lang,
|
|
586
|
-
total: stats.totalProcessed / languages.length,
|
|
587
|
-
fromCache: stats.cachedFiles.filter(f => f.startsWith(`[${lang}]`)).length,
|
|
588
|
-
newlyTranslated: stats.processedFiles.filter(f => f.startsWith(`[${lang}]`)).length,
|
|
589
|
-
failed: stats.failedFiles.filter(f => f.startsWith(`[${lang}]`)).length
|
|
590
|
-
})),
|
|
591
|
-
details: `Translated ${allFiles.length} pages to ${languages.length} languages (${languages.join(', ')})\n\n### Language Breakdown:\n${languages.map(lang => {
|
|
592
|
-
const langStats = {
|
|
593
|
-
total: stats.totalProcessed / languages.length,
|
|
594
|
-
fromCache: stats.cachedFiles.filter(f => f.startsWith(`[${lang}]`)).length,
|
|
595
|
-
newlyTranslated: stats.processedFiles.filter(f => f.startsWith(`[${lang}]`)).length,
|
|
596
|
-
failed: stats.failedFiles.filter(f => f.startsWith(`[${lang}]`)).length
|
|
597
|
-
};
|
|
598
|
-
return `**${lang.toUpperCase()}:** ${langStats.total} total (${langStats.fromCache} cached, ${langStats.newlyTranslated} new${langStats.failed > 0 ? `, ${langStats.failed} failed` : ''})`;
|
|
599
|
-
}).join('\n')}\n\n### Files from cache:\n${stats.cachedFiles.length > 0 ? stats.cachedFiles.slice(0, 10).map(f => `- ${f}`).join('\n') + (stats.cachedFiles.length > 10 ? `\n- ... and ${stats.cachedFiles.length - 10} more` : '') : 'None'}\n\n### Newly translated files:\n${stats.processedFiles.length > 0 ? stats.processedFiles.slice(0, 10).map(f => `- ${f}`).join('\n') + (stats.processedFiles.length > 10 ? `\n- ... and ${stats.processedFiles.length - 10} more` : '') : 'None'}${stats.failedFiles.length > 0 ? `\n\n### Failed translations:\n${stats.failedFiles.slice(0, 10).map(f => `- ${f}`).join('\n') + (stats.failedFiles.length > 10 ? `\n- ... and ${stats.failedFiles.length - 10} more` : '')}` : ''}`
|
|
528
|
+
pages: stats.totalPages,
|
|
529
|
+
strings: { cached: stats.cachedStrings, new: stats.newStrings, total: totalStrings },
|
|
530
|
+
failed: stats.failedPages.length,
|
|
531
|
+
languages: languages.join(', '),
|
|
532
|
+
timing: { startTime, endTime, elapsedMs },
|
|
533
|
+
tokenUsage: tokens.input > 0 || tokens.output > 0
|
|
534
|
+
? { input: tokens.input, output: tokens.output, total: tokens.input + tokens.output, cost: totalCost }
|
|
535
|
+
: undefined,
|
|
536
|
+
details: `Translated ${stats.totalPages} pages to ${languages.length} languages (${languages.join(', ')}): ${stats.cachedStrings} cached + ${stats.newStrings} new strings${stats.failedPages.length > 0 ? `, ${stats.failedPages.length} failed pages` : ''}`
|
|
600
537
|
}
|
|
601
538
|
});
|
|
602
539
|
}
|