wiki-plugin-shoppe 0.0.31 → 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 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 && `📚 ${r.books.length} book${r.books.length !== 1 ? 's' : ''}`,
327
- r.music.length && `🎵 ${r.music.length} music item${r.music.length !== 1 ? 's' : ''}`,
328
- r.posts.length && `📝 ${r.posts.length} post${r.posts.length !== 1 ? 's' : ''}`,
329
- r.albums.length && `🖼️ ${r.albums.length} album${r.albums.length !== 1 ? 's' : ''}`,
330
- r.products.length && `📦 ${r.products.length} product${r.products.length !== 1 ? 's' : ''}`,
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
- } catch (err) {
344
- showStatus(container, '#sw-upload-status', `❌ ${err.message}`, 'error');
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wiki-plugin-shoppe",
3
- "version": "0.0.31",
3
+ "version": "0.0.33",
4
4
  "description": "Multi-tenant digital goods shoppe for federated wiki, powered by Sanora",
5
5
  "keywords": [
6
6
  "wiki",
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
- if (tenants[identifier]) return tenants[identifier];
452
- return Object.values(tenants).find(t => t.emojicode === identifier) || null;
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 fetch(
546
+ const resp = await fetchWithRetry(
528
547
  `${getSanoraUrl()}/user/${uuid}/product/${encodeURIComponent(title)}`,
529
548
  {
530
549
  method: 'PUT',
@@ -543,11 +562,32 @@ async function sanoraCreateProduct(tenant, title, category, description, price,
543
562
  }
544
563
  );
545
564
 
565
+ if (!resp.ok) {
566
+ const text = await resp.text().catch(() => '');
567
+ throw new Error(`Create product failed (${resp.status}): ${text.slice(0, 200)}`);
568
+ }
546
569
  const product = await resp.json();
547
570
  if (product.error) throw new Error(`Create product failed: ${product.error}`);
548
571
  return product;
549
572
  }
550
573
 
574
+ // Wrapper used by processArchive. On "not found" (Sanora Redis cleared mid-upload),
575
+ // re-registers the tenant and retries once. tenant.uuid may be updated in place.
576
+ async function sanoraCreateProductResilient(tenant, title, category, description, price, shipping, tags) {
577
+ try {
578
+ return await sanoraCreateProduct(tenant, title, category, description, price, shipping, tags);
579
+ } catch (err) {
580
+ if (err.message.includes('not found') || err.message.includes('404')) {
581
+ console.warn(`[shoppe] Sanora user lost mid-upload, re-registering and retrying: ${title}`);
582
+ const updated = await sanoraEnsureUser(tenant);
583
+ // Mutate tenant in place so all subsequent calls use the new UUID
584
+ tenant.uuid = updated.uuid;
585
+ return await sanoraCreateProduct(tenant, title, category, description, price, shipping, tags);
586
+ }
587
+ throw err;
588
+ }
589
+ }
590
+
551
591
  async function sanoraUploadArtifact(tenant, title, fileBuffer, filename, artifactType) {
552
592
  const { uuid, keys } = tenant;
553
593
  const timestamp = Date.now().toString();
@@ -558,7 +598,7 @@ async function sanoraUploadArtifact(tenant, title, fileBuffer, filename, artifac
558
598
  const form = new FormData();
559
599
  form.append('artifact', fileBuffer, { filename, contentType: getMimeType(filename) });
560
600
 
561
- const resp = await fetch(
601
+ const resp = await fetchWithRetry(
562
602
  `${getSanoraUrl()}/user/${uuid}/product/${encodeURIComponent(title)}/artifact`,
563
603
  {
564
604
  method: 'PUT',
@@ -573,6 +613,10 @@ async function sanoraUploadArtifact(tenant, title, fileBuffer, filename, artifac
573
613
  }
574
614
  );
575
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
+ }
576
620
  const result = await resp.json();
577
621
  if (result.error) throw new Error(`Artifact upload failed: ${result.error}`);
578
622
  return result;
@@ -588,7 +632,7 @@ async function sanoraUploadImage(tenant, title, imageBuffer, filename) {
588
632
  const form = new FormData();
589
633
  form.append('image', imageBuffer, { filename, contentType: getMimeType(filename) });
590
634
 
591
- const resp = await fetch(
635
+ const resp = await fetchWithRetry(
592
636
  `${getSanoraUrl()}/user/${uuid}/product/${encodeURIComponent(title)}/image`,
593
637
  {
594
638
  method: 'PUT',
@@ -602,6 +646,10 @@ async function sanoraUploadImage(tenant, title, imageBuffer, filename) {
602
646
  }
603
647
  );
604
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
+ }
605
653
  const result = await resp.json();
606
654
  if (result.error) throw new Error(`Image upload failed: ${result.error}`);
607
655
  return result;
@@ -710,7 +758,46 @@ async function lucilleUploadVideo(tenant, title, fileBuffer, filename, lucilleUr
710
758
  // ARCHIVE PROCESSING
711
759
  // ============================================================
712
760
 
713
- async function processArchive(zipPath) {
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 = () => {}) {
714
801
  const tmpDir = path.join(TMP_DIR, `extract-${Date.now()}`);
715
802
  fs.mkdirSync(tmpDir, { recursive: true });
716
803
  // Use system unzip to stream-extract without loading entire archive into RAM.
@@ -783,6 +870,10 @@ async function processArchive(zipPath) {
783
870
  // If Redis was wiped, this re-creates the user and updates tenant.uuid.
784
871
  tenant = await sanoraEnsureUser(tenant);
785
872
 
873
+ const total = countItems(root);
874
+ let current = 0;
875
+ onProgress({ type: 'start', total, name: manifest.name });
876
+
786
877
  const results = { books: [], music: [], posts: [], albums: [], products: [], videos: [], appointments: [], subscriptions: [], warnings: [] };
787
878
 
788
879
  function readInfo(entryPath) {
@@ -811,7 +902,8 @@ async function processArchive(zipPath) {
811
902
  const description = info.description || '';
812
903
  const price = info.price || 0;
813
904
 
814
- await sanoraCreateProduct(tenant, title, 'book', description, price, 0, buildTags('book', info.keywords));
905
+ onProgress({ type: 'progress', current: ++current, total, label: `📚 ${title}` });
906
+ await sanoraCreateProductResilient(tenant, title, 'book', description, price, 0, buildTags('book', info.keywords));
815
907
 
816
908
  // Cover image — use info.cover to pin a specific file, else first image found
817
909
  const covers = fs.readdirSync(entryPath).filter(f => IMAGE_EXTS.has(path.extname(f).toLowerCase()));
@@ -853,7 +945,8 @@ async function processArchive(zipPath) {
853
945
  try {
854
946
  const description = info.description || `Album: ${albumTitle}`;
855
947
  const price = info.price || 0;
856
- await sanoraCreateProduct(tenant, albumTitle, 'music', description, price, 0, buildTags('music,album', info.keywords));
948
+ onProgress({ type: 'progress', current: ++current, total, label: `🎵 ${albumTitle}` });
949
+ await sanoraCreateProductResilient(tenant, albumTitle, 'music', description, price, 0, buildTags('music,album', info.keywords));
857
950
  const coverFile = info.cover ? (covers.find(f => f === info.cover) || covers[0]) : covers[0];
858
951
  if (coverFile) {
859
952
  const coverBuf = fs.readFileSync(path.join(entryPath, coverFile));
@@ -882,7 +975,8 @@ async function processArchive(zipPath) {
882
975
  const buf = fs.readFileSync(entryPath);
883
976
  const description = trackInfo.description || `Track: ${title}`;
884
977
  const price = trackInfo.price || 0;
885
- await sanoraCreateProduct(tenant, title, 'music', description, price, 0, buildTags('music,track', trackInfo.keywords));
978
+ onProgress({ type: 'progress', current: ++current, total, label: `🎵 ${title}` });
979
+ await sanoraCreateProductResilient(tenant, title, 'music', description, price, 0, buildTags('music,track', trackInfo.keywords));
886
980
  await sanoraUploadArtifact(tenant, title, buf, entry, 'audio');
887
981
  results.music.push({ title, type: 'track' });
888
982
  console.log(`[shoppe] 🎵 track: ${title}`);
@@ -922,7 +1016,8 @@ async function processArchive(zipPath) {
922
1016
  // Register the series itself as a parent product
923
1017
  try {
924
1018
  const description = info.description || `A ${subDirs.length}-part series`;
925
- await sanoraCreateProduct(tenant, seriesTitle, 'post-series', description, 0, 0, buildTags(`post,series,order:${order}`, info.keywords));
1019
+ onProgress({ type: 'progress', current: ++current, total, label: `📝 ${seriesTitle} (series)` });
1020
+ await sanoraCreateProductResilient(tenant, seriesTitle, 'post-series', description, 0, 0, buildTags(`post,series,order:${order}`, info.keywords));
926
1021
 
927
1022
  const covers = fs.readdirSync(entryPath).filter(f => IMAGE_EXTS.has(path.extname(f).toLowerCase()));
928
1023
  if (covers.length > 0) {
@@ -962,7 +1057,8 @@ async function processArchive(zipPath) {
962
1057
  const productTitle = `${seriesTitle}: ${resolvedTitle}`;
963
1058
  const description = partInfo.description || partFm.body.split('\n\n')[0].replace(/^#+\s*/, '').trim() || resolvedTitle;
964
1059
 
965
- await sanoraCreateProduct(tenant, productTitle, 'post', description, 0, 0,
1060
+ onProgress({ type: 'progress', current: ++current, total, label: `📝 ${productTitle}` });
1061
+ await sanoraCreateProductResilient(tenant, productTitle, 'post', description, 0, 0,
966
1062
  buildTags(`post,blog,series:${seriesTitle},part:${partIndex + 1},order:${order}`, partInfo.keywords));
967
1063
 
968
1064
  await sanoraUploadArtifact(tenant, productTitle, mdBuf, partMdFiles[0], 'text');
@@ -1004,7 +1100,8 @@ async function processArchive(zipPath) {
1004
1100
  const firstLine = fm.body.split('\n').find(l => l.trim()).replace(/^#+\s*/, '');
1005
1101
  const description = info.description || fm.body.split('\n\n')[0].replace(/^#+\s*/, '').trim() || firstLine || title;
1006
1102
 
1007
- await sanoraCreateProduct(tenant, title, 'post', description, 0, 0, buildTags(`post,blog,order:${order}`, info.keywords));
1103
+ onProgress({ type: 'progress', current: ++current, total, label: `📝 ${title}` });
1104
+ await sanoraCreateProductResilient(tenant, title, 'post', description, 0, 0, buildTags(`post,blog,order:${order}`, info.keywords));
1008
1105
  await sanoraUploadArtifact(tenant, title, mdBuf, mdFiles[0], 'text');
1009
1106
 
1010
1107
  const covers = fs.readdirSync(entryPath).filter(f => IMAGE_EXTS.has(path.extname(f).toLowerCase()));
@@ -1041,7 +1138,8 @@ async function processArchive(zipPath) {
1041
1138
  if (!fs.statSync(entryPath).isDirectory()) continue;
1042
1139
  const images = fs.readdirSync(entryPath).filter(f => IMAGE_EXTS.has(path.extname(f).toLowerCase()));
1043
1140
  try {
1044
- await sanoraCreateProduct(tenant, entry, 'album', `Photo album: ${entry}`, 0, 0, 'album,photos');
1141
+ onProgress({ type: 'progress', current: ++current, total, label: `🖼️ ${entry}` });
1142
+ await sanoraCreateProductResilient(tenant, entry, 'album', `Photo album: ${entry}`, 0, 0, 'album,photos');
1045
1143
  if (images.length > 0) {
1046
1144
  const coverBuf = fs.readFileSync(path.join(entryPath, images[0]));
1047
1145
  await sanoraUploadImage(tenant, entry, coverBuf, images[0]);
@@ -1078,7 +1176,8 @@ async function processArchive(zipPath) {
1078
1176
  const price = info.price || 0;
1079
1177
  const shipping = info.shipping || 0;
1080
1178
 
1081
- await sanoraCreateProduct(tenant, title, 'product', description, price, shipping, buildTags(`product,physical,order:${order}`, info.keywords));
1179
+ onProgress({ type: 'progress', current: ++current, total, label: `📦 ${title}` });
1180
+ await sanoraCreateProductResilient(tenant, title, 'product', description, price, shipping, buildTags(`product,physical,order:${order}`, info.keywords));
1082
1181
 
1083
1182
  // Hero image: prefer hero.jpg / hero.png, fall back to first image
1084
1183
  const images = fs.readdirSync(entryPath).filter(f => IMAGE_EXTS.has(path.extname(f).toLowerCase()));
@@ -1119,7 +1218,8 @@ async function processArchive(zipPath) {
1119
1218
  renewalDays: info.renewalDays || 30
1120
1219
  };
1121
1220
 
1122
- await sanoraCreateProduct(tenant, title, 'subscription', description, price, 0, buildTags('subscription', info.keywords));
1221
+ onProgress({ type: 'progress', current: ++current, total, label: `🎁 ${title}` });
1222
+ await sanoraCreateProductResilient(tenant, title, 'subscription', description, price, 0, buildTags('subscription', info.keywords));
1123
1223
 
1124
1224
  // Upload tier metadata (benefits list, renewal period) as a JSON artifact
1125
1225
  const tierBuf = Buffer.from(JSON.stringify(tierMeta));
@@ -1194,7 +1294,8 @@ async function processArchive(zipPath) {
1194
1294
  (lucilleVideoId ? `,lucille-id:${lucilleVideoId},lucille-url:${lucilleBase}` : '');
1195
1295
 
1196
1296
  // Sanora catalog entry (for discovery / storefront)
1197
- await sanoraCreateProduct(tenant, title, 'video', description, price, 0, videoTags);
1297
+ onProgress({ type: 'progress', current: ++current, total, label: `🎬 ${title}` });
1298
+ await sanoraCreateProductResilient(tenant, title, 'video', description, price, 0, videoTags);
1198
1299
 
1199
1300
  // Cover / poster image (optional)
1200
1301
  const images = fs.readdirSync(entryPath).filter(f => IMAGE_EXTS.has(path.extname(f).toLowerCase()));
@@ -1240,7 +1341,8 @@ async function processArchive(zipPath) {
1240
1341
  advanceDays: info.advanceDays || 30
1241
1342
  };
1242
1343
 
1243
- await sanoraCreateProduct(tenant, title, 'appointment', description, price, 0, buildTags('appointment', info.keywords));
1344
+ onProgress({ type: 'progress', current: ++current, total, label: `📅 ${title}` });
1345
+ await sanoraCreateProductResilient(tenant, title, 'appointment', description, price, 0, buildTags('appointment', info.keywords));
1244
1346
 
1245
1347
  // Upload schedule as a JSON artifact so the booking page can retrieve it
1246
1348
  const scheduleBuf = Buffer.from(JSON.stringify(schedule));
@@ -1998,22 +2100,53 @@ async function startServer(params) {
1998
2100
  });
1999
2101
 
2000
2102
  // Upload goods archive (auth via manifest uuid+emojicode)
2001
- app.post('/plugin/shoppe/upload', upload.single('archive'), async (req, res) => {
2002
- try {
2003
- if (!req.file) {
2004
- return res.status(400).json({ success: false, error: 'No archive uploaded' });
2005
- }
2006
- console.log('[shoppe] Processing archive:', req.file.originalname);
2007
- const result = await processArchive(req.file.path);
2008
- res.json({ success: true, ...result });
2009
- } catch (err) {
2010
- console.error('[shoppe] upload error:', err);
2011
- res.status(500).json({ success: false, error: err.message });
2012
- } finally {
2013
- if (req.file && fs.existsSync(req.file.path)) {
2014
- try { fs.unlinkSync(req.file.path); } catch (e) {}
2015
- }
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' });
2016
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; });
2017
2150
  });
2018
2151
 
2019
2152
  // Get config (owner only)