@a83/orbiter-admin 0.3.9 → 0.3.11

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@a83/orbiter-admin",
3
- "version": "0.3.9",
3
+ "version": "0.3.11",
4
4
  "description": "Standalone admin server for Orbiter CMS",
5
5
  "type": "module",
6
6
  "main": "./src/server.js",
@@ -101,15 +101,21 @@
101
101
  /* Status */
102
102
  .status-bar { display:flex; align-items:center; gap:8px; padding:8px 0; margin-bottom:10px; }
103
103
  .status-dot { width:7px; height:7px; border-radius:50%; flex-shrink:0; }
104
- .status-dot.published { background:var(--jade); }
105
- .status-dot.draft { background:var(--gold); }
104
+ .status-dot.published { background:var(--jade); }
105
+ .status-dot.draft { background:var(--gold); }
106
+ .status-dot.scheduled { background:var(--accent); }
106
107
  .status-lbl { font-size:11px; }
107
- .status-lbl.published { color:var(--jade); }
108
- .status-lbl.draft { color:var(--gold); }
108
+ .status-lbl.published { color:var(--jade); }
109
+ .status-lbl.draft { color:var(--gold); }
110
+ .status-lbl.scheduled { color:var(--accent); }
109
111
  .btn-publish { display:block; width:100%; padding:9px; background:var(--gold); border:none; color:var(--bg0); font-family:var(--mono); font-size:10px; letter-spacing:.12em; cursor:pointer; transition:background .15s; margin-bottom:5px; border-radius:var(--radius); }
110
112
  .btn-publish:hover { background:#7a5520; }
111
- .btn-draft { display:block; width:100%; padding:8px; background:transparent; border:1px solid var(--line); color:var(--mid); font-family:var(--mono); font-size:10px; cursor:pointer; transition:all .12s; border-radius:var(--radius); }
113
+ .btn-draft { display:block; width:100%; padding:8px; background:transparent; border:1px solid var(--line); color:var(--mid); font-family:var(--mono); font-size:10px; cursor:pointer; transition:all .12s; border-radius:var(--radius); margin-bottom:5px; }
112
114
  .btn-draft:hover { border-color:var(--mid); color:var(--text); }
115
+ .btn-schedule { display:block; width:100%; padding:8px; background:transparent; border:1px solid var(--accent); color:var(--accent); font-family:var(--mono); font-size:10px; cursor:pointer; transition:all .12s; border-radius:var(--radius); margin-bottom:5px; }
116
+ .btn-schedule:hover { background:var(--accent-bg); }
117
+ .schedule-picker { display:none; gap:4px; margin-bottom:5px; }
118
+ .schedule-picker.open { display:flex; }
113
119
 
114
120
  /* Schema fields in sidebar */
115
121
  .field-input,.field-select { width:100%; background:var(--bg0); border:1px solid var(--line); padding:6px 8px; color:var(--heading); font-family:var(--mono); font-size:11px; outline:none; appearance:none; transition:border-color .15s; border-radius:var(--radius); box-sizing:border-box; }
@@ -539,8 +545,10 @@
539
545
 
540
546
  function renderMetaPanel() {
541
547
  const panel = document.getElementById('meta-panel');
542
- const status = IS_NEW ? 'draft' : (entryData?.status ?? 'draft');
548
+ const status = IS_NEW ? 'draft' : (entryData?.status ?? 'draft');
543
549
  const updatedAt = entryData?.updated_at;
550
+ const publishAt = entryData?.publish_at ?? null;
551
+ const publishAtInput = publishAt ? publishAt.replace(' ', 'T').slice(0, 16) : '';
544
552
 
545
553
  let fieldsHtml = '';
546
554
  const seoFields = {};
@@ -625,10 +633,11 @@
625
633
  }
626
634
 
627
635
  const versHtml = versionsData.slice(0,8).map((v,i)=>`
628
- <div class="version-row">
636
+ <div class="version-row" data-vid="${v.id}">
629
637
  <div class="v-dot${i===0?' cur':''}"></div>
630
638
  <div class="v-hash">${v.id.slice(0,7)}</div>
631
639
  <div class="v-time">${new Date(v.created_at).toLocaleDateString()}</div>
640
+ ${i===0 ? '' : `<button class="btn-row v-restore-btn" data-vid="${v.id}" style="margin-left:auto;font-size:9px;padding:2px 6px">Restore</button>`}
632
641
  </div>
633
642
  `).join('');
634
643
 
@@ -653,10 +662,16 @@
653
662
  <div class="meta-section">
654
663
  <div class="status-bar">
655
664
  <div class="status-dot ${status}" id="status-dot"></div>
656
- <div class="status-lbl ${status}" id="status-lbl">${status==='published'?'Published':'Draft'}</div>
665
+ <div class="status-lbl ${status}" id="status-lbl">${status==='published'?'Published':status==='scheduled'?'Scheduled':'Draft'}</div>
666
+ ${status==='scheduled'&&publishAt?`<span style="font-size:10px;color:var(--muted);margin-left:auto">${new Date(publishAt.replace(' ','T')).toLocaleDateString()}</span>`:''}
657
667
  </div>
658
- <button type="button" class="btn-publish" id="btn-publish">Publish</button>
659
- <button type="button" class="btn-draft" id="btn-draft">Save as draft</button>
668
+ <button type="button" class="btn-publish" id="btn-publish">${status==='published'?'Republish':'Publish now'}</button>
669
+ <button type="button" class="btn-draft" id="btn-draft">${status==='scheduled'?'Cancel schedule':'Save as draft'}</button>
670
+ <div class="schedule-picker${status==='scheduled'?' open':''}" id="schedule-picker">
671
+ <input type="datetime-local" class="field-input" id="publish-at-input" value="${publishAtInput}" style="flex:1;font-size:10px;padding:5px 8px;" />
672
+ <button type="button" id="btn-schedule-confirm" style="padding:5px 10px;background:var(--accent);border:none;color:var(--bg0);font-family:var(--mono);font-size:10px;cursor:pointer;border-radius:var(--radius);white-space:nowrap;">Set</button>
673
+ </div>
674
+ <button type="button" class="btn-schedule" id="btn-schedule">${status==='scheduled'?'Reschedule':'Schedule'}</button>
660
675
  </div>
661
676
  <div class="meta-section">
662
677
  <div class="meta-label">Details</div>
@@ -669,6 +684,7 @@
669
684
  <select class="field-select" id="status-select">
670
685
  <option value="draft" ${status==='draft' ?'selected':''}>Draft</option>
671
686
  <option value="published" ${status==='published' ?'selected':''}>Published</option>
687
+ <option value="scheduled" ${status==='scheduled' ?'selected':''}>Scheduled</option>
672
688
  </select>
673
689
  </div>
674
690
  ${updatedAt ? `<div class="meta-field"><div class="field-label">Modified</div><div class="field-readonly">${new Date(updatedAt).toLocaleDateString()}</div></div>` : ''}
@@ -729,9 +745,30 @@
729
745
  });
730
746
  });
731
747
 
732
- // Publish / draft buttons
733
- document.getElementById('btn-publish').addEventListener('click',()=>saveEntry('published'));
734
- document.getElementById('btn-draft').addEventListener('click',()=>saveEntry('draft'));
748
+ // Publish / draft / schedule buttons
749
+ document.getElementById('btn-publish').addEventListener('click', () => saveEntry('published'));
750
+ document.getElementById('btn-draft').addEventListener('click', () => saveEntry('draft'));
751
+ document.getElementById('btn-schedule').addEventListener('click', () => {
752
+ document.getElementById('schedule-picker').classList.toggle('open');
753
+ });
754
+ document.getElementById('btn-schedule-confirm').addEventListener('click', () => {
755
+ const val = document.getElementById('publish-at-input').value;
756
+ if (!val) { alert('Please select a date and time.'); return; }
757
+ saveEntry('scheduled');
758
+ });
759
+
760
+ // Version restore buttons
761
+ panel.querySelectorAll('.v-restore-btn').forEach(btn => {
762
+ btn.addEventListener('click', async () => {
763
+ if (!confirm('Restore this version? Current content will be overwritten.')) return;
764
+ const vid = btn.dataset.vid;
765
+ const targetSlug = currentSlug || SLUG;
766
+ const res = await fetch(`/api/collections/${COLLECTION}/entries/${targetSlug}/versions/${vid}/restore`, {
767
+ method: 'POST', credentials: 'include',
768
+ });
769
+ if (res.ok) { location.reload(); } else { alert('Restore failed.'); }
770
+ });
771
+ });
735
772
 
736
773
  // Slug input → slug preview
737
774
  document.getElementById('slug-input').addEventListener('input', e=>{
@@ -802,8 +839,15 @@
802
839
  function updateStatusUI(status) {
803
840
  document.getElementById('status-dot').className = 'status-dot ' + status;
804
841
  document.getElementById('status-lbl').className = 'status-lbl ' + status;
805
- document.getElementById('status-lbl').textContent = status==='published'?'Published':'Draft';
806
- document.getElementById('status-select').value = status;
842
+ document.getElementById('status-lbl').textContent = status === 'published' ? 'Published' : status === 'scheduled' ? 'Scheduled' : 'Draft';
843
+ const sel = document.getElementById('status-select');
844
+ if (sel) sel.value = status;
845
+ const publishBtn = document.getElementById('btn-publish');
846
+ const draftBtn = document.getElementById('btn-draft');
847
+ const schedBtn = document.getElementById('btn-schedule');
848
+ if (publishBtn) publishBtn.textContent = status === 'published' ? 'Republish' : 'Publish now';
849
+ if (draftBtn) draftBtn.textContent = status === 'scheduled' ? 'Cancel schedule' : 'Save as draft';
850
+ if (schedBtn) schedBtn.textContent = status === 'scheduled' ? 'Reschedule' : 'Schedule';
807
851
  }
808
852
 
809
853
  function renderTags(key) {
@@ -880,6 +924,14 @@
880
924
  let currentPath = location.pathname + location.search;
881
925
 
882
926
  async function saveEntry(status, isAutosave=false) {
927
+ // Resolve publish_at for scheduled entries
928
+ let publish_at = null;
929
+ if (status === 'scheduled') {
930
+ const raw = document.getElementById('publish-at-input')?.value;
931
+ if (!raw) { if (!isAutosave) alert('Please select a date and time.'); return; }
932
+ publish_at = raw.replace('T', ' ') + ':00';
933
+ }
934
+
883
935
  const title = document.getElementById('title-input').value;
884
936
  syncToHidden();
885
937
  const body = document.getElementById('body-input').value;
@@ -907,7 +959,7 @@
907
959
  const res = await fetch(`/api/collections/${COLLECTION}/entries`,{
908
960
  method:'POST', credentials:'include',
909
961
  headers:{'Content-Type':'application/json'},
910
- body: JSON.stringify({ slug, data, status }),
962
+ body: JSON.stringify({ slug, data, status, publish_at }),
911
963
  });
912
964
  const json = await res.json();
913
965
  if (json.slug || json.id) {
@@ -926,7 +978,7 @@
926
978
  const res = await fetch(`/api/collections/${COLLECTION}/entries/${targetSlug}`,{
927
979
  method:'PUT', credentials:'include',
928
980
  headers:{'Content-Type':'application/json'},
929
- body: JSON.stringify({ slug, data, status }),
981
+ body: JSON.stringify({ slug, data, status, publish_at }),
930
982
  });
931
983
  if (res.ok) {
932
984
  const json = await res.json();
@@ -90,7 +90,11 @@
90
90
  <h1 class="page-title" id="page-title">Entries</h1>
91
91
  <p class="page-sub" id="page-sub"></p>
92
92
  </div>
93
- <button class="btn btn-primary" id="new-btn" style="flex-shrink:0;margin-top:4px">+ New entry</button>
93
+ <div style="display:flex;gap:6px;align-items:center;flex-shrink:0;margin-top:4px">
94
+ <a id="export-btn" href="#" class="btn btn-ghost btn-sm" title="Export as CSV">↓ CSV</a>
95
+ <label class="btn btn-ghost btn-sm" style="cursor:pointer" title="Import from CSV">↑ CSV<input type="file" id="import-file" accept=".csv" style="display:none" /></label>
96
+ <button class="btn btn-primary" id="new-btn">+ New entry</button>
97
+ </div>
94
98
  </div>
95
99
 
96
100
  <!-- Filter + table glass card -->
@@ -100,6 +104,7 @@
100
104
  <button class="filter-tab active" data-status="">All</button>
101
105
  <button class="filter-tab" data-status="published">Published</button>
102
106
  <button class="filter-tab" data-status="draft">Drafts</button>
107
+ <button class="filter-tab" data-status="scheduled">Scheduled</button>
103
108
  <button class="filter-tab" data-status="trash" style="margin-left:auto">🗑 Trash</button>
104
109
  </div>
105
110
 
@@ -187,8 +192,9 @@
187
192
  const title = e.data?.title || e.slug;
188
193
  const updated = e.updated_at ? e.updated_at.split(' ')[0] : '—';
189
194
  const deleted = e.deleted_at ? e.deleted_at.split(' ')[0] : '—';
190
- const nextStatus = e.status === 'published' ? 'draft' : 'published';
191
- const toggleLabel = e.status === 'published' ? 'Unpublish' : 'Publish';
195
+ const nextStatus = e.status === 'published' ? 'draft' : 'published';
196
+ const toggleLabel = e.status === 'published' ? 'Unpublish' : e.status === 'scheduled' ? 'Publish now' : 'Publish';
197
+ const schedInfo = e.status === 'scheduled' && e.publish_at ? ` · ${e.publish_at.split(' ')[0]}` : '';
192
198
  return `<tr data-slug="${e.slug}"${canSort ? ' draggable="true"' : ''}>
193
199
  ${canSort ? `<td class="drag-col"><span class="drag-handle" title="Drag to reorder">⠿</span></td>` : ''}
194
200
  <td class="cb-col"><input type="checkbox" class="row-cb" data-slug="${e.slug}" ${selected.has(e.slug) ? 'checked' : ''} /></td>
@@ -196,7 +202,7 @@
196
202
  <div style="color:var(--heading);font-weight:500">${title}</div>
197
203
  <div style="font-family:var(--mono);font-size:10px;color:var(--muted);margin-top:2px">${e.slug}</div>
198
204
  </td>
199
- <td>${isTrash ? `<span style="font-family:var(--mono);font-size:11px;color:var(--muted)">${deleted}</span>` : `<span class="badge badge-${e.status}">${e.status}</span>`}</td>
205
+ <td>${isTrash ? `<span style="font-family:var(--mono);font-size:11px;color:var(--muted)">${deleted}</span>` : `<span class="badge badge-${e.status}">${e.status}${schedInfo}</span>`}</td>
200
206
  <td style="font-family:var(--mono);font-size:11px;color:var(--muted)">${isTrash ? '' : updated}</td>
201
207
  <td style="width:1%;white-space:nowrap">
202
208
  <div class="row-actions">
@@ -364,6 +370,29 @@
364
370
  await bulkAction('permanent');
365
371
  });
366
372
 
373
+ // CSV Export
374
+ document.getElementById('export-btn').href = `/api/collections/${colId}/entries/export.csv`;
375
+
376
+ // CSV Import
377
+ document.getElementById('import-file').addEventListener('change', async e => {
378
+ const file = e.target.files[0];
379
+ if (!file) return;
380
+ const text = await file.text();
381
+ const res = await fetch(`/api/collections/${colId}/entries/import.csv`, {
382
+ method: 'POST', credentials: 'include',
383
+ headers: { 'Content-Type': 'text/csv' },
384
+ body: text,
385
+ });
386
+ const json = await res.json();
387
+ if (res.ok) {
388
+ alert(`Import complete: ${json.created} created, ${json.updated} updated${json.skipped ? ', ' + json.skipped + ' skipped' : ''}.`);
389
+ renderEntries();
390
+ } else {
391
+ alert('Import failed: ' + (json.error ?? 'Unknown error'));
392
+ }
393
+ e.target.value = '';
394
+ });
395
+
367
396
  // New entry modal
368
397
  const overlay = document.getElementById('modal-overlay');
369
398
  document.getElementById('new-btn').addEventListener('click', () => {
package/public/style.css CHANGED
@@ -936,8 +936,9 @@ td { padding: 10px 20px; font-size: 12px; color: var(--text); border-bottom: 1px
936
936
  tr:last-child td { border-bottom: none; }
937
937
  tr:hover td { background: var(--row-hover); }
938
938
  .badge { display: inline-block; font-size: 10px; font-family: var(--mono); padding: 2px 7px; border-radius: 2px; letter-spacing: 0.04em; }
939
- .badge-published { background: var(--jade-bg); color: var(--jade); }
940
- .badge-draft { background: var(--gold-bg); color: var(--gold); }
939
+ .badge-published { background: var(--jade-bg); color: var(--jade); }
940
+ .badge-draft { background: var(--gold-bg); color: var(--gold); }
941
+ .badge-scheduled { background: var(--accent-bg); color: var(--accent); }
941
942
 
942
943
  /* ── Buttons ─────────────────────────────────────── */
943
944
  .btn { display: inline-flex; align-items: center; gap: 6px; padding: 6px 14px; font-size: 12px; font-family: var(--sans); border-radius: var(--radius); cursor: pointer; transition: all 0.12s; border: 1px solid transparent; font-weight: 400; letter-spacing: 0.01em; }
@@ -99,6 +99,8 @@ entryRoutes.put('/:collectionId/entries/:slug', async (c) => {
99
99
  db.logAudit(updated.id, username, 'publish');
100
100
  } else if (body.status === 'draft' && before?.status === 'published') {
101
101
  db.logAudit(updated.id, username, 'unpublish');
102
+ } else if (body.status === 'scheduled' && before?.status !== 'scheduled') {
103
+ db.logAudit(updated.id, username, 'schedule');
102
104
  } else {
103
105
  db.logAudit(updated.id, username, 'update');
104
106
  }
@@ -168,6 +170,92 @@ entryRoutes.get('/:collectionId/entries/:slug/versions', (c) => {
168
170
  return c.json(versions);
169
171
  });
170
172
 
173
+ // POST /api/collections/:id/entries/:slug/versions/:versionId/restore
174
+ entryRoutes.post('/:collectionId/entries/:slug/versions/:versionId/restore', (c) => {
175
+ const { collectionId, slug, versionId } = c.req.param();
176
+ const db = openPod(c.get('podPath'));
177
+ const entry = db.getEntry(collectionId, slug);
178
+ if (!entry) { db.close(); return c.json({ error: 'Not found' }, 404); }
179
+ const ok = db.restoreVersion(entry.id, versionId);
180
+ if (ok) db.logAudit(entry.id, c.get('user')?.username ?? 'unknown', 'restore_version');
181
+ db.close();
182
+ if (!ok) return c.json({ error: 'Version not found' }, 404);
183
+ return c.json({ ok: true });
184
+ });
185
+
186
+ // GET /api/collections/:id/entries/export.csv
187
+ entryRoutes.get('/:collectionId/entries/export.csv', (c) => {
188
+ const { collectionId } = c.req.param();
189
+ const db = openPod(c.get('podPath'));
190
+ const col = db.getCollection(collectionId);
191
+ if (!col) { db.close(); return c.json({ error: 'Collection not found' }, 404); }
192
+ const entries = db.getEntries(collectionId);
193
+ const schema = col.schema ? JSON.parse(col.schema) : {};
194
+ const fields = Object.keys(schema);
195
+ const headers = ['slug', 'status', ...fields];
196
+ const csvEsc = v => `"${String(v ?? '').replace(/"/g, '""')}"`;
197
+ const rows = [headers.map(csvEsc).join(',')];
198
+ for (const e of entries) {
199
+ rows.push(headers.map(h => {
200
+ if (h === 'slug') return csvEsc(e.slug);
201
+ if (h === 'status') return csvEsc(e.status);
202
+ const v = e.data[h];
203
+ return csvEsc(Array.isArray(v) ? v.join(';') : v);
204
+ }).join(','));
205
+ }
206
+ db.close();
207
+ return new Response(rows.join('\n'), {
208
+ headers: {
209
+ 'Content-Type': 'text/csv; charset=utf-8',
210
+ 'Content-Disposition': `attachment; filename="${collectionId}.csv"`,
211
+ },
212
+ });
213
+ });
214
+
215
+ // POST /api/collections/:id/entries/import.csv
216
+ entryRoutes.post('/:collectionId/entries/import.csv', async (c) => {
217
+ const { collectionId } = c.req.param();
218
+ const db = openPod(c.get('podPath'));
219
+ if (!db.getCollection(collectionId)) { db.close(); return c.json({ error: 'Collection not found' }, 404); }
220
+ const text = await c.req.text();
221
+ const lines = text.split(/\r?\n/).filter(Boolean);
222
+ if (lines.length < 2) { db.close(); return c.json({ error: 'Empty CSV' }, 400); }
223
+ const parseCsv = line => {
224
+ const cols = []; let cur = ''; let inQ = false;
225
+ for (let i = 0; i < line.length; i++) {
226
+ const ch = line[i];
227
+ if (ch === '"' && !inQ) { inQ = true; continue; }
228
+ if (ch === '"' && inQ && line[i+1] === '"') { cur += '"'; i++; continue; }
229
+ if (ch === '"' && inQ) { inQ = false; continue; }
230
+ if (ch === ',' && !inQ) { cols.push(cur); cur = ''; continue; }
231
+ cur += ch;
232
+ }
233
+ cols.push(cur);
234
+ return cols;
235
+ };
236
+ const headers = parseCsv(lines[0]);
237
+ let created = 0, updated = 0, skipped = 0;
238
+ for (const line of lines.slice(1)) {
239
+ const vals = parseCsv(line);
240
+ const row = Object.fromEntries(headers.map((h, i) => [h, vals[i] ?? '']));
241
+ if (!row.slug) { skipped++; continue; }
242
+ const data = {};
243
+ for (const h of headers.filter(h => h !== 'slug' && h !== 'status')) {
244
+ data[h] = row[h];
245
+ }
246
+ const status = ['draft','published'].includes(row.status) ? row.status : 'draft';
247
+ if (db.getEntry(collectionId, row.slug)) {
248
+ db.updateEntry(collectionId, row.slug, { slug: row.slug, data, status });
249
+ updated++;
250
+ } else {
251
+ db.createEntry(collectionId, row.slug, data, status);
252
+ created++;
253
+ }
254
+ }
255
+ db.close();
256
+ return c.json({ ok: true, created, updated, skipped });
257
+ });
258
+
171
259
  // POST /api/collections/:id/entries/:slug/duplicate
172
260
  entryRoutes.post('/:collectionId/entries/:slug/duplicate', (c) => {
173
261
  const { collectionId, slug } = c.req.param();
@@ -186,12 +274,16 @@ entryRoutes.post('/:collectionId/entries/:slug/duplicate', (c) => {
186
274
  // PATCH /api/collections/:id/entries/:slug/status
187
275
  entryRoutes.patch('/:collectionId/entries/:slug/status', async (c) => {
188
276
  const { collectionId, slug } = c.req.param();
189
- const { status } = await c.req.json();
190
- if (!['draft', 'published'].includes(status)) return c.json({ error: 'Invalid status' }, 400);
277
+ const { status, publish_at } = await c.req.json();
278
+ if (!['draft', 'published', 'scheduled'].includes(status)) return c.json({ error: 'Invalid status' }, 400);
279
+ if (status === 'scheduled' && !publish_at) return c.json({ error: 'publish_at required for scheduled status' }, 400);
191
280
  const db = openPod(c.get('podPath'));
192
281
  const entry = db.getEntry(collectionId, slug);
193
282
  if (!entry) { db.close(); return c.json({ error: 'Not found' }, 404); }
194
- db.updateEntry(collectionId, slug, { slug, data: entry.data, status });
283
+ const pa = status === 'scheduled' ? publish_at : null;
284
+ db.updateEntry(collectionId, slug, { slug, data: entry.data, status, publish_at: pa });
285
+ const username = c.get('user')?.username ?? 'unknown';
286
+ if (status === 'scheduled') db.logAudit(entry.id, username, 'schedule');
195
287
  db.close();
196
288
  if (status === 'published') fireWebhook(c.get('podPath'));
197
289
  return c.json({ ok: true });
package/src/server.js CHANGED
@@ -93,3 +93,24 @@ serve({ fetch: createApp(POD_PATH).fetch, port: PORT }, () => {
93
93
  console.log(`Orbiter Admin API → http://localhost:${PORT}`);
94
94
  console.log(`Pod: ${POD_PATH}`);
95
95
  });
96
+
97
+ // Scheduled publishing — check every 60 s
98
+ setInterval(() => {
99
+ try {
100
+ const db = openPod(POD_PATH);
101
+ const due = db.getScheduledDue();
102
+ if (!due.length) { db.close(); return; }
103
+ const now = new Date().toISOString().replace('T', ' ').replace(/\.\d{3}Z$/, '');
104
+ for (const entry of due) {
105
+ db.db.prepare("UPDATE _entries SET status = 'published', publish_at = NULL, updated_at = ? WHERE id = ?").run(now, entry.id);
106
+ db.logAudit(entry.id, 'scheduler', 'publish');
107
+ }
108
+ const webhookUrl = db.getMeta('build.webhook_url') ?? '';
109
+ if (webhookUrl) db.setMeta('build.last_triggered', new Date().toISOString());
110
+ db.close();
111
+ console.log(`[scheduler] Published ${due.length} scheduled entr${due.length === 1 ? 'y' : 'ies'}`);
112
+ if (webhookUrl) fetch(webhookUrl, { method: 'POST' }).catch(() => {});
113
+ } catch (e) {
114
+ console.warn('[scheduler]', e.message);
115
+ }
116
+ }, 60_000);