wiki-plugin-shoppe 0.0.32 → 0.0.33
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/client/shoppe.js +66 -10
- package/package.json +1 -1
- package/server/server.js +134 -22
package/client/shoppe.js
CHANGED
|
@@ -44,6 +44,10 @@
|
|
|
44
44
|
.sw-status.success { background: #d1fae5; color: #065f46; display: block; }
|
|
45
45
|
.sw-status.error { background: #fee2e2; color: #991b1b; display: block; }
|
|
46
46
|
.sw-status code { background: rgba(0,0,0,0.08); border-radius: 4px; padding: 1px 5px; font-size: 12px; }
|
|
47
|
+
.sw-progress-bar-track { background: rgba(0,0,0,0.12); border-radius: 4px; height: 8px; margin: 8px 0; overflow: hidden; }
|
|
48
|
+
.sw-progress-bar-fill { height: 100%; background: #1a56db; border-radius: 4px; width: 0%; transition: width 0.25s ease; }
|
|
49
|
+
.sw-progress-meta { display: flex; justify-content: space-between; font-size: 12px; opacity: 0.75; }
|
|
50
|
+
.sw-progress-item { margin-top: 6px; font-size: 13px; font-style: italic; }
|
|
47
51
|
.sw-remove { display: block; width: 100%; margin-top: 24px; padding: 8px; background: none; border: 1px solid #e5e5ea; border-radius: 8px; font-size: 12px; color: #aaa; cursor: pointer; text-align: center; }
|
|
48
52
|
.sw-remove:hover { border-color: #cc0000; color: #cc0000; }
|
|
49
53
|
</style>
|
|
@@ -313,25 +317,72 @@
|
|
|
313
317
|
// ── Upload ──────────────────────────────────────────────────────────────────
|
|
314
318
|
|
|
315
319
|
async function uploadArchive(file, container) {
|
|
320
|
+
const statusEl = container.querySelector('#sw-upload-status');
|
|
321
|
+
|
|
322
|
+
// Step 1: POST the file, get a jobId back immediately.
|
|
316
323
|
showStatus(container, '#sw-upload-status', `⏳ Uploading <strong>${file.name}</strong>…`, 'info');
|
|
317
324
|
const form = new FormData();
|
|
318
325
|
form.append('archive', file);
|
|
326
|
+
let jobId;
|
|
319
327
|
try {
|
|
320
328
|
const resp = await fetch('/plugin/shoppe/upload', { method: 'POST', body: form });
|
|
321
329
|
const result = await resp.json();
|
|
322
|
-
if (!result.success) throw new Error(result.error || 'Upload failed');
|
|
330
|
+
if (!result.success || !result.jobId) throw new Error(result.error || 'Upload failed');
|
|
331
|
+
jobId = result.jobId;
|
|
332
|
+
} catch (err) {
|
|
333
|
+
showStatus(container, '#sw-upload-status', `❌ ${err.message}`, 'error');
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// Step 2: Show progress UI and open SSE stream.
|
|
338
|
+
statusEl.className = 'sw-status info';
|
|
339
|
+
statusEl.style.display = 'block';
|
|
340
|
+
statusEl.innerHTML = `
|
|
341
|
+
<div id="sw-progress-title" style="font-weight:600;margin-bottom:6px;">⏳ Processing archive…</div>
|
|
342
|
+
<div class="sw-progress-bar-track"><div id="sw-progress-fill" class="sw-progress-bar-fill"></div></div>
|
|
343
|
+
<div class="sw-progress-meta">
|
|
344
|
+
<span id="sw-progress-count">0 / …</span>
|
|
345
|
+
<span id="sw-progress-pct">0%</span>
|
|
346
|
+
</div>
|
|
347
|
+
<div id="sw-progress-item" class="sw-progress-item"></div>
|
|
348
|
+
`;
|
|
349
|
+
|
|
350
|
+
const fillEl = statusEl.querySelector('#sw-progress-fill');
|
|
351
|
+
const countEl = statusEl.querySelector('#sw-progress-count');
|
|
352
|
+
const pctEl = statusEl.querySelector('#sw-progress-pct');
|
|
353
|
+
const itemEl = statusEl.querySelector('#sw-progress-item');
|
|
354
|
+
const titleEl = statusEl.querySelector('#sw-progress-title');
|
|
355
|
+
|
|
356
|
+
const es = new EventSource(`/plugin/shoppe/upload/progress/${jobId}`);
|
|
357
|
+
|
|
358
|
+
es.addEventListener('start', e => {
|
|
359
|
+
const { total, name } = JSON.parse(e.data);
|
|
360
|
+
titleEl.innerHTML = `⏳ Uploading <strong>${name}</strong> — ${total} item${total !== 1 ? 's' : ''}`;
|
|
361
|
+
countEl.textContent = `0 / ${total}`;
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
es.addEventListener('progress', e => {
|
|
365
|
+
const { current, total, label } = JSON.parse(e.data);
|
|
366
|
+
const pct = total > 0 ? Math.round(current / total * 100) : 0;
|
|
367
|
+
fillEl.style.width = pct + '%';
|
|
368
|
+
countEl.textContent = `${current} / ${total}`;
|
|
369
|
+
pctEl.textContent = pct + '%';
|
|
370
|
+
itemEl.textContent = label;
|
|
371
|
+
});
|
|
323
372
|
|
|
373
|
+
es.addEventListener('complete', e => {
|
|
374
|
+
es.close();
|
|
375
|
+
const result = JSON.parse(e.data);
|
|
324
376
|
const r = result.results;
|
|
325
377
|
const counts = [
|
|
326
|
-
r.books.length
|
|
327
|
-
r.music.length
|
|
328
|
-
r.posts.length
|
|
329
|
-
r.albums.length
|
|
330
|
-
r.products.length
|
|
378
|
+
r.books.length && `📚 ${r.books.length} book${r.books.length !== 1 ? 's' : ''}`,
|
|
379
|
+
r.music.length && `🎵 ${r.music.length} music item${r.music.length !== 1 ? 's' : ''}`,
|
|
380
|
+
r.posts.length && `📝 ${r.posts.length} post${r.posts.length !== 1 ? 's' : ''}`,
|
|
381
|
+
r.albums.length && `🖼️ ${r.albums.length} album${r.albums.length !== 1 ? 's' : ''}`,
|
|
382
|
+
r.products.length && `📦 ${r.products.length} product${r.products.length !== 1 ? 's' : ''}`,
|
|
331
383
|
r.appointments && r.appointments.length && `📅 ${r.appointments.length} appointment${r.appointments.length !== 1 ? 's' : ''}`,
|
|
332
384
|
r.subscriptions && r.subscriptions.length && `🎁 ${r.subscriptions.length} subscription tier${r.subscriptions.length !== 1 ? 's' : ''}`
|
|
333
385
|
].filter(Boolean).join(' · ') || 'no items found';
|
|
334
|
-
|
|
335
386
|
const warnings = (r.warnings && r.warnings.length > 0)
|
|
336
387
|
? `<br><br>⚠️ <strong>Warnings (${r.warnings.length}):</strong><br>${r.warnings.map(w => `• ${w}`).join('<br>')}`
|
|
337
388
|
: '';
|
|
@@ -340,9 +391,14 @@
|
|
|
340
391
|
<a href="/plugin/shoppe/${result.tenant.uuid}" target="_blank" class="sw-link" style="display:inline-block;margin-top:8px;">View your shoppe →</a>`,
|
|
341
392
|
'success');
|
|
342
393
|
loadDirectory(container);
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
es.addEventListener('error', e => {
|
|
397
|
+
es.close();
|
|
398
|
+
let msg = 'Upload failed';
|
|
399
|
+
try { msg = JSON.parse(e.data).message; } catch (err) { /* use default */ }
|
|
400
|
+
showStatus(container, '#sw-upload-status', `❌ ${msg}`, 'error');
|
|
401
|
+
});
|
|
346
402
|
}
|
|
347
403
|
|
|
348
404
|
// ── Save URL (owner) ────────────────────────────────────────────────────────
|
package/package.json
CHANGED
package/server/server.js
CHANGED
|
@@ -448,8 +448,13 @@ async function registerTenant(name) {
|
|
|
448
448
|
|
|
449
449
|
function getTenantByIdentifier(identifier) {
|
|
450
450
|
const tenants = loadTenants();
|
|
451
|
-
|
|
452
|
-
|
|
451
|
+
const entry = tenants[identifier];
|
|
452
|
+
if (entry) {
|
|
453
|
+
// String value = alias left behind after a UUID change (Redis reset); follow it.
|
|
454
|
+
if (typeof entry === 'string') return tenants[entry] || null;
|
|
455
|
+
return entry;
|
|
456
|
+
}
|
|
457
|
+
return Object.values(tenants).find(t => typeof t === 'object' && t.emojicode === identifier) || null;
|
|
453
458
|
}
|
|
454
459
|
|
|
455
460
|
// ============================================================
|
|
@@ -506,15 +511,29 @@ async function sanoraEnsureUser(tenant) {
|
|
|
506
511
|
console.log(`[shoppe] Sanora UUID changed ${tenant.uuid} → ${sanoraUser.uuid} (Redis was reset). Updating tenants.json.`);
|
|
507
512
|
const tenants = loadTenants();
|
|
508
513
|
const oldUuid = tenant.uuid;
|
|
509
|
-
delete tenants[oldUuid];
|
|
510
514
|
tenant.uuid = sanoraUser.uuid;
|
|
511
515
|
tenants[sanoraUser.uuid] = tenant;
|
|
516
|
+
// Keep old UUID as a forwarding alias so existing manifest.json / shared URLs still resolve.
|
|
517
|
+
tenants[oldUuid] = sanoraUser.uuid;
|
|
512
518
|
saveTenants(tenants);
|
|
513
519
|
}
|
|
514
520
|
|
|
515
521
|
return tenant; // tenant.uuid is now correct
|
|
516
522
|
}
|
|
517
523
|
|
|
524
|
+
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
|
|
525
|
+
|
|
526
|
+
// fetch() wrapper that retries on 429 with exponential backoff (1s, 2s, 4s).
|
|
527
|
+
async function fetchWithRetry(url, options, maxRetries = 3) {
|
|
528
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
529
|
+
const resp = await fetch(url, options);
|
|
530
|
+
if (resp.status !== 429 || attempt === maxRetries) return resp;
|
|
531
|
+
const delay = 1000 * Math.pow(2, attempt);
|
|
532
|
+
console.warn(`[shoppe] 429 rate limited on ${new URL(url).pathname}, retrying in ${delay}ms…`);
|
|
533
|
+
await sleep(delay);
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
518
537
|
async function sanoraCreateProduct(tenant, title, category, description, price, shipping, tags) {
|
|
519
538
|
const { uuid, keys } = tenant;
|
|
520
539
|
const timestamp = Date.now().toString();
|
|
@@ -524,7 +543,7 @@ async function sanoraCreateProduct(tenant, title, category, description, price,
|
|
|
524
543
|
sessionless.getKeys = () => keys;
|
|
525
544
|
const signature = await sessionless.sign(message);
|
|
526
545
|
|
|
527
|
-
const resp = await
|
|
546
|
+
const resp = await fetchWithRetry(
|
|
528
547
|
`${getSanoraUrl()}/user/${uuid}/product/${encodeURIComponent(title)}`,
|
|
529
548
|
{
|
|
530
549
|
method: 'PUT',
|
|
@@ -579,7 +598,7 @@ async function sanoraUploadArtifact(tenant, title, fileBuffer, filename, artifac
|
|
|
579
598
|
const form = new FormData();
|
|
580
599
|
form.append('artifact', fileBuffer, { filename, contentType: getMimeType(filename) });
|
|
581
600
|
|
|
582
|
-
const resp = await
|
|
601
|
+
const resp = await fetchWithRetry(
|
|
583
602
|
`${getSanoraUrl()}/user/${uuid}/product/${encodeURIComponent(title)}/artifact`,
|
|
584
603
|
{
|
|
585
604
|
method: 'PUT',
|
|
@@ -594,6 +613,10 @@ async function sanoraUploadArtifact(tenant, title, fileBuffer, filename, artifac
|
|
|
594
613
|
}
|
|
595
614
|
);
|
|
596
615
|
|
|
616
|
+
if (!resp.ok) {
|
|
617
|
+
const text = await resp.text().catch(() => '');
|
|
618
|
+
throw new Error(`Artifact upload failed (${resp.status}): ${text.slice(0, 200)}`);
|
|
619
|
+
}
|
|
597
620
|
const result = await resp.json();
|
|
598
621
|
if (result.error) throw new Error(`Artifact upload failed: ${result.error}`);
|
|
599
622
|
return result;
|
|
@@ -609,7 +632,7 @@ async function sanoraUploadImage(tenant, title, imageBuffer, filename) {
|
|
|
609
632
|
const form = new FormData();
|
|
610
633
|
form.append('image', imageBuffer, { filename, contentType: getMimeType(filename) });
|
|
611
634
|
|
|
612
|
-
const resp = await
|
|
635
|
+
const resp = await fetchWithRetry(
|
|
613
636
|
`${getSanoraUrl()}/user/${uuid}/product/${encodeURIComponent(title)}/image`,
|
|
614
637
|
{
|
|
615
638
|
method: 'PUT',
|
|
@@ -623,6 +646,10 @@ async function sanoraUploadImage(tenant, title, imageBuffer, filename) {
|
|
|
623
646
|
}
|
|
624
647
|
);
|
|
625
648
|
|
|
649
|
+
if (!resp.ok) {
|
|
650
|
+
const text = await resp.text().catch(() => '');
|
|
651
|
+
throw new Error(`Image upload failed (${resp.status}): ${text.slice(0, 200)}`);
|
|
652
|
+
}
|
|
626
653
|
const result = await resp.json();
|
|
627
654
|
if (result.error) throw new Error(`Image upload failed: ${result.error}`);
|
|
628
655
|
return result;
|
|
@@ -731,7 +758,46 @@ async function lucilleUploadVideo(tenant, title, fileBuffer, filename, lucilleUr
|
|
|
731
758
|
// ARCHIVE PROCESSING
|
|
732
759
|
// ============================================================
|
|
733
760
|
|
|
734
|
-
|
|
761
|
+
// ── Upload job store ─────────────────────────────────────────────────────────
|
|
762
|
+
// Each job buffers SSE events so the client can replay them if it connects late.
|
|
763
|
+
const uploadJobs = new Map(); // jobId → { sse: res|null, queue: [], done: false }
|
|
764
|
+
|
|
765
|
+
function countItems(root) {
|
|
766
|
+
let count = 0;
|
|
767
|
+
|
|
768
|
+
const booksDir = path.join(root, 'books');
|
|
769
|
+
if (fs.existsSync(booksDir))
|
|
770
|
+
count += fs.readdirSync(booksDir).filter(f => fs.statSync(path.join(booksDir, f)).isDirectory()).length;
|
|
771
|
+
|
|
772
|
+
const musicDir = path.join(root, 'music');
|
|
773
|
+
if (fs.existsSync(musicDir)) {
|
|
774
|
+
for (const entry of fs.readdirSync(musicDir)) {
|
|
775
|
+
const stat = fs.statSync(path.join(musicDir, entry));
|
|
776
|
+
if (stat.isDirectory()) count++;
|
|
777
|
+
else if (MUSIC_EXTS.has(path.extname(entry).toLowerCase())) count++;
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
const postsDir = path.join(root, 'posts');
|
|
782
|
+
if (fs.existsSync(postsDir)) {
|
|
783
|
+
for (const entry of fs.readdirSync(postsDir)) {
|
|
784
|
+
const entryPath = path.join(postsDir, entry);
|
|
785
|
+
if (!fs.statSync(entryPath).isDirectory()) continue;
|
|
786
|
+
const subDirs = fs.readdirSync(entryPath).filter(f => fs.statSync(path.join(entryPath, f)).isDirectory());
|
|
787
|
+
count += subDirs.length > 0 ? 1 + subDirs.length : 1;
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
for (const dirName of ['albums', 'products', 'subscriptions', 'videos', 'appointments']) {
|
|
792
|
+
const dir = path.join(root, dirName);
|
|
793
|
+
if (fs.existsSync(dir))
|
|
794
|
+
count += fs.readdirSync(dir).filter(f => fs.statSync(path.join(dir, f)).isDirectory()).length;
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
return count;
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
async function processArchive(zipPath, onProgress = () => {}) {
|
|
735
801
|
const tmpDir = path.join(TMP_DIR, `extract-${Date.now()}`);
|
|
736
802
|
fs.mkdirSync(tmpDir, { recursive: true });
|
|
737
803
|
// Use system unzip to stream-extract without loading entire archive into RAM.
|
|
@@ -804,6 +870,10 @@ async function processArchive(zipPath) {
|
|
|
804
870
|
// If Redis was wiped, this re-creates the user and updates tenant.uuid.
|
|
805
871
|
tenant = await sanoraEnsureUser(tenant);
|
|
806
872
|
|
|
873
|
+
const total = countItems(root);
|
|
874
|
+
let current = 0;
|
|
875
|
+
onProgress({ type: 'start', total, name: manifest.name });
|
|
876
|
+
|
|
807
877
|
const results = { books: [], music: [], posts: [], albums: [], products: [], videos: [], appointments: [], subscriptions: [], warnings: [] };
|
|
808
878
|
|
|
809
879
|
function readInfo(entryPath) {
|
|
@@ -832,6 +902,7 @@ async function processArchive(zipPath) {
|
|
|
832
902
|
const description = info.description || '';
|
|
833
903
|
const price = info.price || 0;
|
|
834
904
|
|
|
905
|
+
onProgress({ type: 'progress', current: ++current, total, label: `📚 ${title}` });
|
|
835
906
|
await sanoraCreateProductResilient(tenant, title, 'book', description, price, 0, buildTags('book', info.keywords));
|
|
836
907
|
|
|
837
908
|
// Cover image — use info.cover to pin a specific file, else first image found
|
|
@@ -874,6 +945,7 @@ async function processArchive(zipPath) {
|
|
|
874
945
|
try {
|
|
875
946
|
const description = info.description || `Album: ${albumTitle}`;
|
|
876
947
|
const price = info.price || 0;
|
|
948
|
+
onProgress({ type: 'progress', current: ++current, total, label: `🎵 ${albumTitle}` });
|
|
877
949
|
await sanoraCreateProductResilient(tenant, albumTitle, 'music', description, price, 0, buildTags('music,album', info.keywords));
|
|
878
950
|
const coverFile = info.cover ? (covers.find(f => f === info.cover) || covers[0]) : covers[0];
|
|
879
951
|
if (coverFile) {
|
|
@@ -903,6 +975,7 @@ async function processArchive(zipPath) {
|
|
|
903
975
|
const buf = fs.readFileSync(entryPath);
|
|
904
976
|
const description = trackInfo.description || `Track: ${title}`;
|
|
905
977
|
const price = trackInfo.price || 0;
|
|
978
|
+
onProgress({ type: 'progress', current: ++current, total, label: `🎵 ${title}` });
|
|
906
979
|
await sanoraCreateProductResilient(tenant, title, 'music', description, price, 0, buildTags('music,track', trackInfo.keywords));
|
|
907
980
|
await sanoraUploadArtifact(tenant, title, buf, entry, 'audio');
|
|
908
981
|
results.music.push({ title, type: 'track' });
|
|
@@ -943,6 +1016,7 @@ async function processArchive(zipPath) {
|
|
|
943
1016
|
// Register the series itself as a parent product
|
|
944
1017
|
try {
|
|
945
1018
|
const description = info.description || `A ${subDirs.length}-part series`;
|
|
1019
|
+
onProgress({ type: 'progress', current: ++current, total, label: `📝 ${seriesTitle} (series)` });
|
|
946
1020
|
await sanoraCreateProductResilient(tenant, seriesTitle, 'post-series', description, 0, 0, buildTags(`post,series,order:${order}`, info.keywords));
|
|
947
1021
|
|
|
948
1022
|
const covers = fs.readdirSync(entryPath).filter(f => IMAGE_EXTS.has(path.extname(f).toLowerCase()));
|
|
@@ -983,6 +1057,7 @@ async function processArchive(zipPath) {
|
|
|
983
1057
|
const productTitle = `${seriesTitle}: ${resolvedTitle}`;
|
|
984
1058
|
const description = partInfo.description || partFm.body.split('\n\n')[0].replace(/^#+\s*/, '').trim() || resolvedTitle;
|
|
985
1059
|
|
|
1060
|
+
onProgress({ type: 'progress', current: ++current, total, label: `📝 ${productTitle}` });
|
|
986
1061
|
await sanoraCreateProductResilient(tenant, productTitle, 'post', description, 0, 0,
|
|
987
1062
|
buildTags(`post,blog,series:${seriesTitle},part:${partIndex + 1},order:${order}`, partInfo.keywords));
|
|
988
1063
|
|
|
@@ -1025,6 +1100,7 @@ async function processArchive(zipPath) {
|
|
|
1025
1100
|
const firstLine = fm.body.split('\n').find(l => l.trim()).replace(/^#+\s*/, '');
|
|
1026
1101
|
const description = info.description || fm.body.split('\n\n')[0].replace(/^#+\s*/, '').trim() || firstLine || title;
|
|
1027
1102
|
|
|
1103
|
+
onProgress({ type: 'progress', current: ++current, total, label: `📝 ${title}` });
|
|
1028
1104
|
await sanoraCreateProductResilient(tenant, title, 'post', description, 0, 0, buildTags(`post,blog,order:${order}`, info.keywords));
|
|
1029
1105
|
await sanoraUploadArtifact(tenant, title, mdBuf, mdFiles[0], 'text');
|
|
1030
1106
|
|
|
@@ -1062,6 +1138,7 @@ async function processArchive(zipPath) {
|
|
|
1062
1138
|
if (!fs.statSync(entryPath).isDirectory()) continue;
|
|
1063
1139
|
const images = fs.readdirSync(entryPath).filter(f => IMAGE_EXTS.has(path.extname(f).toLowerCase()));
|
|
1064
1140
|
try {
|
|
1141
|
+
onProgress({ type: 'progress', current: ++current, total, label: `🖼️ ${entry}` });
|
|
1065
1142
|
await sanoraCreateProductResilient(tenant, entry, 'album', `Photo album: ${entry}`, 0, 0, 'album,photos');
|
|
1066
1143
|
if (images.length > 0) {
|
|
1067
1144
|
const coverBuf = fs.readFileSync(path.join(entryPath, images[0]));
|
|
@@ -1099,6 +1176,7 @@ async function processArchive(zipPath) {
|
|
|
1099
1176
|
const price = info.price || 0;
|
|
1100
1177
|
const shipping = info.shipping || 0;
|
|
1101
1178
|
|
|
1179
|
+
onProgress({ type: 'progress', current: ++current, total, label: `📦 ${title}` });
|
|
1102
1180
|
await sanoraCreateProductResilient(tenant, title, 'product', description, price, shipping, buildTags(`product,physical,order:${order}`, info.keywords));
|
|
1103
1181
|
|
|
1104
1182
|
// Hero image: prefer hero.jpg / hero.png, fall back to first image
|
|
@@ -1140,6 +1218,7 @@ async function processArchive(zipPath) {
|
|
|
1140
1218
|
renewalDays: info.renewalDays || 30
|
|
1141
1219
|
};
|
|
1142
1220
|
|
|
1221
|
+
onProgress({ type: 'progress', current: ++current, total, label: `🎁 ${title}` });
|
|
1143
1222
|
await sanoraCreateProductResilient(tenant, title, 'subscription', description, price, 0, buildTags('subscription', info.keywords));
|
|
1144
1223
|
|
|
1145
1224
|
// Upload tier metadata (benefits list, renewal period) as a JSON artifact
|
|
@@ -1215,6 +1294,7 @@ async function processArchive(zipPath) {
|
|
|
1215
1294
|
(lucilleVideoId ? `,lucille-id:${lucilleVideoId},lucille-url:${lucilleBase}` : '');
|
|
1216
1295
|
|
|
1217
1296
|
// Sanora catalog entry (for discovery / storefront)
|
|
1297
|
+
onProgress({ type: 'progress', current: ++current, total, label: `🎬 ${title}` });
|
|
1218
1298
|
await sanoraCreateProductResilient(tenant, title, 'video', description, price, 0, videoTags);
|
|
1219
1299
|
|
|
1220
1300
|
// Cover / poster image (optional)
|
|
@@ -1261,6 +1341,7 @@ async function processArchive(zipPath) {
|
|
|
1261
1341
|
advanceDays: info.advanceDays || 30
|
|
1262
1342
|
};
|
|
1263
1343
|
|
|
1344
|
+
onProgress({ type: 'progress', current: ++current, total, label: `📅 ${title}` });
|
|
1264
1345
|
await sanoraCreateProductResilient(tenant, title, 'appointment', description, price, 0, buildTags('appointment', info.keywords));
|
|
1265
1346
|
|
|
1266
1347
|
// Upload schedule as a JSON artifact so the booking page can retrieve it
|
|
@@ -2019,22 +2100,53 @@ async function startServer(params) {
|
|
|
2019
2100
|
});
|
|
2020
2101
|
|
|
2021
2102
|
// Upload goods archive (auth via manifest uuid+emojicode)
|
|
2022
|
-
app.post('/plugin/shoppe/upload', upload.single('archive'),
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
return res.status(400).json({ success: false, error: 'No archive uploaded' });
|
|
2026
|
-
}
|
|
2027
|
-
console.log('[shoppe] Processing archive:', req.file.originalname);
|
|
2028
|
-
const result = await processArchive(req.file.path);
|
|
2029
|
-
res.json({ success: true, ...result });
|
|
2030
|
-
} catch (err) {
|
|
2031
|
-
console.error('[shoppe] upload error:', err);
|
|
2032
|
-
res.status(500).json({ success: false, error: err.message });
|
|
2033
|
-
} finally {
|
|
2034
|
-
if (req.file && fs.existsSync(req.file.path)) {
|
|
2035
|
-
try { fs.unlinkSync(req.file.path); } catch (e) {}
|
|
2036
|
-
}
|
|
2103
|
+
app.post('/plugin/shoppe/upload', upload.single('archive'), (req, res) => {
|
|
2104
|
+
if (!req.file) {
|
|
2105
|
+
return res.status(400).json({ success: false, error: 'No archive uploaded' });
|
|
2037
2106
|
}
|
|
2107
|
+
|
|
2108
|
+
const jobId = crypto.randomBytes(8).toString('hex');
|
|
2109
|
+
const job = { sse: null, queue: [], done: false };
|
|
2110
|
+
uploadJobs.set(jobId, job);
|
|
2111
|
+
setTimeout(() => uploadJobs.delete(jobId), 15 * 60 * 1000); // clean up after 15 min
|
|
2112
|
+
|
|
2113
|
+
res.json({ success: true, jobId });
|
|
2114
|
+
|
|
2115
|
+
function emit(type, data) {
|
|
2116
|
+
job.queue.push({ type, data });
|
|
2117
|
+
if (job.sse) job.sse.write(`event: ${type}\ndata: ${JSON.stringify(data)}\n\n`);
|
|
2118
|
+
}
|
|
2119
|
+
|
|
2120
|
+
const zipPath = req.file.path;
|
|
2121
|
+
console.log('[shoppe] Processing archive:', req.file.originalname);
|
|
2122
|
+
processArchive(zipPath, emit)
|
|
2123
|
+
.then(result => emit('complete', { success: true, ...result }))
|
|
2124
|
+
.catch(err => { console.error('[shoppe] upload error:', err); emit('error', { message: err.message }); })
|
|
2125
|
+
.finally(() => {
|
|
2126
|
+
job.done = true;
|
|
2127
|
+
if (job.sse) { job.sse.end(); job.sse = null; }
|
|
2128
|
+
if (fs.existsSync(zipPath)) try { fs.unlinkSync(zipPath); } catch (e) {}
|
|
2129
|
+
});
|
|
2130
|
+
});
|
|
2131
|
+
|
|
2132
|
+
app.get('/plugin/shoppe/upload/progress/:jobId', (req, res) => {
|
|
2133
|
+
const job = uploadJobs.get(req.params.jobId);
|
|
2134
|
+
if (!job) return res.status(404).json({ error: 'Unknown job' });
|
|
2135
|
+
|
|
2136
|
+
res.setHeader('Content-Type', 'text/event-stream');
|
|
2137
|
+
res.setHeader('Cache-Control', 'no-cache');
|
|
2138
|
+
res.setHeader('Connection', 'keep-alive');
|
|
2139
|
+
res.flushHeaders();
|
|
2140
|
+
|
|
2141
|
+
// Replay buffered events for late-connecting clients.
|
|
2142
|
+
for (const evt of job.queue) {
|
|
2143
|
+
res.write(`event: ${evt.type}\ndata: ${JSON.stringify(evt.data)}\n\n`);
|
|
2144
|
+
}
|
|
2145
|
+
|
|
2146
|
+
if (job.done) { res.end(); return; }
|
|
2147
|
+
|
|
2148
|
+
job.sse = res;
|
|
2149
|
+
req.on('close', () => { if (job.sse === res) job.sse = null; });
|
|
2038
2150
|
});
|
|
2039
2151
|
|
|
2040
2152
|
// Get config (owner only)
|