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 +66 -10
- package/package.json +1 -1
- package/server/server.js +166 -33
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',
|
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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'),
|
|
2002
|
-
|
|
2003
|
-
|
|
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)
|