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 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-template-rows: repeat(auto-fill, 1fr);
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-y: auto;
92
- overflow-x: hidden;
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
- opacity: 0.35;
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: 2;
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
- opacity: 0.85;
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: opacity 0.15s;
421
+ transition: filter 0.15s, box-shadow 0.15s;
411
422
 
412
423
  &:hover {
413
- opacity: 0.85;
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
- * Pure unix math no Date objects, no timezone issues.
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 interval = this._getIntervalSeconds(template.recurrence.pattern);
366
+ const { pattern } = template.recurrence;
365
367
  const occurrences = [];
366
368
  const seedUNIX = template.sendAt;
367
369
 
368
- // Walk backward from seed to find first occurrence >= startUNIX
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
- // Update day hint based on pattern
179
- if (pattern === 'weekly') {
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
- // Show month field only for yearly
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 = recurrence.pattern || 'monthly';
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
- // Show month row if yearly
370
- document.getElementById('recurrence-month-row').classList.toggle('d-none', recurrence.pattern !== 'yearly');
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
- $dayHint.textContent = recurrence.pattern === 'weekly' ? '(of week, 0=Sun)' : '(of month)';
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: rec.pattern || 'monthly',
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 (rec.pattern === 'yearly') {
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
- const maxVisible = 3;
114
- campaigns.slice(0, maxVisible).forEach((c) => { html += this._renderEventPill(c); });
115
- if (campaigns.length > maxVisible) {
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._formatTime(formatTimeUTC(campaign.sendAt));
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._formatTime(formatTimeUTC(campaign.sendAt));
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 time = formatTimeUTC(campaign.sendAt);
360
- const [hours, minutes] = time.split(':').map(Number);
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._formatTime(time);
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
 
@@ -18,7 +18,7 @@ concurrency:
18
18
  # contents: write
19
19
 
20
20
  env:
21
- { github.secrets }
21
+ { githubSecrets }
22
22
  RUBY_VERSION: '{ versions.ruby }'
23
23
  BUNDLER_VERSION: '{ versions.bundler }'
24
24
  NODE_VERSION: '{ versions.node }'
@@ -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-3">
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
- <input type="number"
372
- class="form-control"
373
- id="campaign-recurrence-hour"
374
- name="campaign.recurrence.hour"
375
- value="14"
376
- min="0"
377
- max="23">
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>
@@ -40,7 +40,7 @@ const githubSecrets = (() => {
40
40
  .map(l => l.split('=')[0].trim())
41
41
  .map(key => `${key}: \${{ secrets.${key} }}`);
42
42
 
43
- return { github: { secrets: lines.join('\n ') } };
43
+ return { githubSecrets: lines.join('\n ') };
44
44
  })();
45
45
 
46
46
  // File MAP
@@ -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 MAX_SOURCE_DIMENSION = 4096;
21
- const REWRITE_QUALITY = 80;
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 MAX_SOURCE_DIMENSION so gulp-responsive-modern + sharp don't stall
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 MAX_SOURCE_DIMENSION.
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 (>${MAX_SOURCE_DIMENSION}px longest side)...`);
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 <= MAX_SOURCE_DIMENSION) {
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: MAX_SOURCE_DIMENSION,
335
- height: MAX_SOURCE_DIMENSION,
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: REWRITE_QUALITY }).toBuffer()
342
- : await pipeline.jpeg({ quality: REWRITE_QUALITY, progressive: true, mozjpeg: true }).toBuffer();
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 ${MAX_SOURCE_DIMENSION}px, ${formatBytes(sizeBefore)} → ${formatBytes(sizeAfter)}`);
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 ${MAX_SOURCE_DIMENSION}px)`);
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 meta caches per language
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, invalidate the cache
216
+ // Check if the promptHash matches; if not, wipe all page caches for this language
222
217
  if (meta.prompt?.hash !== promptHash) {
223
- logger.warn(`⚠️ Prompt cache MISS [${lang}]: hash mismatch — invalidating ${Object.keys(meta).length - 1} cached entries.`);
224
- meta = {};
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 entries = Object.keys(meta).filter(k => k !== 'prompt').length;
227
- logger.log(`✅ Prompt cache HIT [${lang}]: ${entries} cached entries available.`);
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, skipped: new Set() };
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
- fromCache: 0,
240
- newlyProcessed: 0,
241
- totalProcessed: 0,
242
- failedFiles: [],
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 for translation
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
- // Update to work with the new SET protocol
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, hash: ${hash})`);
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 in parallel
276
+ // Translate this file for all languages
285
277
  for (const lang of languages) {
286
278
  const task = async () => {
287
- const meta = metas[lang].meta;
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
- function setResult(success) {
316
- if (success) {
317
- meta[relativePath] = {
318
- timestamp: new Date().toISOString(),
319
- hash,
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
- meta[relativePath] = {
323
- timestamp: 0,
324
- hash: '__fail__',
325
- };
316
+ uncachedIndices.push(i);
326
317
  }
327
318
  }
328
319
 
329
- // Check if we can use cached translation
330
- if (
331
- (useCached || process.env.UJ_TRANSLATION_CACHE === 'true')
332
- && jetpack.exists(cachePath)
333
- ) {
334
- translated = jetpack.read(cachePath, 'json');
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
- try {
340
- const { result, usage } = await translateWithAPI(openAIKey, strings, lang, logTag);
327
+ // Translate only the uncached strings
328
+ const uncachedStrings = uncachedIndices.map(i => strings[i]);
341
329
 
342
- // Log
343
- const elapsedTime = ((Date.now() - startTime) / 1000).toFixed(2);
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
- // Set translated result
347
- translated = result;
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
- // Save to cache (JSON array)
354
- jetpack.write(cachePath, translated);
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} (Elapsed time: ${elapsedTime}s)`);
349
+ logger.error(`❌ Failed [${progress} - ${percentage}%]: ${logTag} — ${e.message} (${elapsedTime}s)`);
363
350
 
364
- // Fallback to original strings
365
- translated = strings;
351
+ // Fill uncached slots with originals
352
+ for (const i of uncachedIndices) {
353
+ translated[i] = strings[i];
354
+ }
366
355
 
367
- // Save failure to cache
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 setResult(false);
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
- // Save output
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
- logger.warn('🚫 Skipped files:');
488
- let totalSkipped = 0;
489
- for (const [lang, meta] of Object.entries(metas)) {
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
- // File processing stats
530
- logger.log('\n📁 File Processing:');
531
- logger.log(` Total processed: ${stats.totalProcessed}`);
532
- logger.log(` From cache: ${stats.fromCache} (${((stats.fromCache / stats.totalProcessed) * 100).toFixed(1)}%)`);
533
- logger.log(` Newly translated: ${stats.newlyProcessed} (${((stats.newlyProcessed / stats.totalProcessed) * 100).toFixed(1)}%)`);
534
- if (stats.failedFiles.length > 0) {
535
- logger.log(` Failed: ${stats.failedFiles.length}`);
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
- sourceCount: allFiles.length,
567
- cachedCount: allCacheFiles.length,
568
- processedNow: stats.totalProcessed,
569
- fromCache: stats.fromCache,
570
- newlyProcessed: stats.newlyProcessed,
571
- timing: {
572
- startTime,
573
- endTime,
574
- elapsedMs
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ultimate-jekyll-manager",
3
- "version": "1.6.3",
3
+ "version": "1.6.5",
4
4
  "description": "Ultimate Jekyll dependency manager",
5
5
  "main": "dist/index.js",
6
6
  "exports": {