wiki-plugin-shoppe 0.0.6 → 0.0.8

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/CLAUDE.md CHANGED
@@ -31,8 +31,14 @@ The first 3 emoji are fixed per wiki instance (`SHOPPE_BASE_EMOJI`, default `
31
31
  my-shoppe.zip
32
32
  manifest.json ← required: { uuid, emojicode, name }
33
33
  books/
34
- My Novel.epub
35
- Technical Guide.pdf
34
+ My Novel/ ← subfolder per book
35
+ my-novel.epub
36
+ cover.jpg
37
+ info.json ← { "title": "…", "description": "…", "price": 0 }
38
+ Technical Guide/
39
+ guide.pdf
40
+ cover.jpg
41
+ info.json
36
42
  music/
37
43
  My Album/ ← album = subfolder
38
44
  cover.jpg
@@ -40,8 +46,20 @@ my-shoppe.zip
40
46
  02-track.mp3
41
47
  Standalone Track.mp3 ← standalone track = file directly in music/
42
48
  posts/
43
- 2025-01-hello-world.md
44
- 2025-02-another-post.md
49
+ 01-Hello World/ ← numeric prefix determines table of contents order
50
+ post.md ← the post content (required)
51
+ cover.jpg ← optional cover image
52
+ screenshot.png ← any assets referenced in the markdown
53
+ info.json ← optional: { "title": "…", "description": "…" }
54
+ 02-My Series/ ← subdirectories = multi-part series
55
+ cover.jpg ← optional series cover
56
+ intro.md ← optional series intro
57
+ info.json ← optional: { "title": "…", "description": "…" }
58
+ 01-Part One/
59
+ post.md
60
+ diagram.png
61
+ 02-Part Two/
62
+ post.md
45
63
  albums/
46
64
  Vacation 2025/ ← photo album = subfolder
47
65
  photo1.jpg
package/client/shoppe.js CHANGED
@@ -64,13 +64,30 @@
64
64
  <div class="sw-step-body"><strong>Build your shoppe folder</strong> with this structure, then zip the whole thing:
65
65
  <div class="sw-tree">my-shoppe.zip
66
66
  manifest.json ← { "uuid": "…", "emojicode": "…", "name": "My Shoppe" }
67
- books/ ← .epub .pdf .mobi
67
+ books/
68
+ My Novel/ ← subfolder per book
69
+ my-novel.epub
70
+ cover.jpg
71
+ info.json ← { "title": "…", "description": "…", "price": 0 }
68
72
  music/
69
73
  My Album/ ← subfolder = album (add cover.jpg inside)
70
74
  cover.jpg
71
75
  01-track.mp3
72
76
  standalone.mp3 ← file directly here = single track
73
- posts/ ← .md files
77
+ posts/
78
+ 01-Hello World/ ← number prefix sets table of contents order
79
+ post.md ← the post content
80
+ cover.jpg ← optional cover image
81
+ screenshot.png ← any assets referenced in the markdown
82
+ info.json ← optional: { "title": "…", "description": "…" }
83
+ 02-My Series/ ← subdirectories = multi-part series
84
+ cover.jpg ← optional series cover
85
+ info.json ← optional: { "title": "…", "description": "…" }
86
+ 01-Part One/
87
+ post.md
88
+ diagram.png
89
+ 02-Part Two/
90
+ post.md
74
91
  albums/
75
92
  Vacation 2025/ ← subfolder of images = photo album
76
93
  photo1.jpg
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wiki-plugin-shoppe",
3
- "version": "0.0.6",
3
+ "version": "0.0.8",
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
@@ -246,8 +246,16 @@ async function processArchive(zipPath) {
246
246
  zip.extractAllTo(tmpDir, true);
247
247
 
248
248
  try {
249
- // Read and validate manifest
250
- const manifestPath = path.join(tmpDir, 'manifest.json');
249
+ // Find manifest.json handle zips that wrap everything in a top-level folder
250
+ let root = tmpDir;
251
+ let manifestPath = path.join(tmpDir, 'manifest.json');
252
+ if (!fs.existsSync(manifestPath)) {
253
+ const entries = fs.readdirSync(tmpDir);
254
+ if (entries.length === 1 && fs.statSync(path.join(tmpDir, entries[0])).isDirectory()) {
255
+ root = path.join(tmpDir, entries[0]);
256
+ manifestPath = path.join(root, 'manifest.json');
257
+ }
258
+ }
251
259
  if (!fs.existsSync(manifestPath)) {
252
260
  throw new Error('Archive is missing manifest.json');
253
261
  }
@@ -266,26 +274,48 @@ async function processArchive(zipPath) {
266
274
  const results = { books: [], music: [], posts: [], albums: [], products: [] };
267
275
 
268
276
  // ---- books/ ----
269
- const booksDir = path.join(tmpDir, 'books');
277
+ // Each book is a subfolder containing the book file, cover.jpg, and info.json
278
+ const booksDir = path.join(root, 'books');
270
279
  if (fs.existsSync(booksDir)) {
271
- for (const file of fs.readdirSync(booksDir)) {
272
- if (!BOOK_EXTS.has(path.extname(file).toLowerCase())) continue;
273
- const title = path.basename(file, path.extname(file));
280
+ for (const entry of fs.readdirSync(booksDir)) {
281
+ const entryPath = path.join(booksDir, entry);
282
+ if (!fs.statSync(entryPath).isDirectory()) continue;
274
283
  try {
275
- const buf = fs.readFileSync(path.join(booksDir, file));
276
- await sanoraCreateProduct(tenant, title, 'book', `Book: ${title}`, 0, 0, 'book');
277
- await sanoraUploadArtifact(tenant, title, buf, file, 'ebook');
278
- results.books.push({ title });
284
+ const infoPath = path.join(entryPath, 'info.json');
285
+ const info = fs.existsSync(infoPath)
286
+ ? JSON.parse(fs.readFileSync(infoPath, 'utf8'))
287
+ : {};
288
+ const title = info.title || entry;
289
+ const description = info.description || '';
290
+ const price = info.price || 0;
291
+
292
+ await sanoraCreateProduct(tenant, title, 'book', description, price, 0, 'book');
293
+
294
+ // Cover image
295
+ const covers = fs.readdirSync(entryPath).filter(f => IMAGE_EXTS.has(path.extname(f).toLowerCase()));
296
+ if (covers.length > 0) {
297
+ const coverBuf = fs.readFileSync(path.join(entryPath, covers[0]));
298
+ await sanoraUploadImage(tenant, title, coverBuf, covers[0]);
299
+ }
300
+
301
+ // Book file
302
+ const bookFiles = fs.readdirSync(entryPath).filter(f => BOOK_EXTS.has(path.extname(f).toLowerCase()));
303
+ if (bookFiles.length > 0) {
304
+ const buf = fs.readFileSync(path.join(entryPath, bookFiles[0]));
305
+ await sanoraUploadArtifact(tenant, title, buf, bookFiles[0], 'ebook');
306
+ }
307
+
308
+ results.books.push({ title, price });
279
309
  console.log(`[shoppe] 📚 book: ${title}`);
280
310
  } catch (err) {
281
- console.warn(`[shoppe] ⚠️ book ${file}: ${err.message}`);
311
+ console.warn(`[shoppe] ⚠️ book ${entry}: ${err.message}`);
282
312
  }
283
313
  }
284
314
  }
285
315
 
286
316
  // ---- music/ ----
287
317
  // Albums are subfolders; standalone files are individual tracks
288
- const musicDir = path.join(tmpDir, 'music');
318
+ const musicDir = path.join(root, 'music');
289
319
  if (fs.existsSync(musicDir)) {
290
320
  for (const entry of fs.readdirSync(musicDir)) {
291
321
  const entryPath = path.join(musicDir, entry);
@@ -328,27 +358,151 @@ async function processArchive(zipPath) {
328
358
  }
329
359
 
330
360
  // ---- posts/ ----
331
- const postsDir = path.join(tmpDir, 'posts');
361
+ // Each post is a numbered subfolder: "01-My Title/" containing post.md,
362
+ // optional assets (images etc.), and optional info.json for metadata overrides.
363
+ // Folders are sorted by their numeric prefix to build the table of contents.
364
+ const postsDir = path.join(root, 'posts');
332
365
  if (fs.existsSync(postsDir)) {
333
- for (const file of fs.readdirSync(postsDir)) {
334
- if (!file.endsWith('.md')) continue;
335
- const title = path.basename(file, '.md');
336
- try {
337
- const buf = fs.readFileSync(path.join(postsDir, file));
338
- const firstLine = buf.toString('utf8').split('\n')[0].replace(/^#+\s*/, '');
339
- await sanoraCreateProduct(tenant, title, 'post', firstLine || title, 0, 0, 'post,blog');
340
- await sanoraUploadArtifact(tenant, title, buf, file, 'text');
341
- results.posts.push({ title });
342
- console.log(`[shoppe] 📝 post: ${title}`);
343
- } catch (err) {
344
- console.warn(`[shoppe] ⚠️ post ${file}: ${err.message}`);
366
+ const postFolders = fs.readdirSync(postsDir)
367
+ .filter(f => fs.statSync(path.join(postsDir, f)).isDirectory())
368
+ .sort(); // lexicographic sort respects numeric prefixes (01-, 02-, …)
369
+
370
+ for (let order = 0; order < postFolders.length; order++) {
371
+ const entry = postFolders[order];
372
+ const entryPath = path.join(postsDir, entry);
373
+ const folderTitle = entry.replace(/^\d+-/, '');
374
+
375
+ const infoPath = path.join(entryPath, 'info.json');
376
+ const info = fs.existsSync(infoPath)
377
+ ? JSON.parse(fs.readFileSync(infoPath, 'utf8'))
378
+ : {};
379
+ const seriesTitle = info.title || folderTitle;
380
+
381
+ // Check if this is a multi-part series (has numbered subdirectories)
382
+ const subDirs = fs.readdirSync(entryPath)
383
+ .filter(f => fs.statSync(path.join(entryPath, f)).isDirectory())
384
+ .sort();
385
+ const mdFiles = fs.readdirSync(entryPath).filter(f => f.endsWith('.md'));
386
+ const isSeries = subDirs.length > 0;
387
+
388
+ if (isSeries) {
389
+ // Register the series itself as a parent product
390
+ try {
391
+ const description = info.description || `A ${subDirs.length}-part series`;
392
+ await sanoraCreateProduct(tenant, seriesTitle, 'post-series', description, 0, 0, `post,series,order:${order}`);
393
+
394
+ const covers = fs.readdirSync(entryPath).filter(f => IMAGE_EXTS.has(path.extname(f).toLowerCase()));
395
+ if (covers.length > 0) {
396
+ const coverBuf = fs.readFileSync(path.join(entryPath, covers[0]));
397
+ await sanoraUploadImage(tenant, seriesTitle, coverBuf, covers[0]);
398
+ }
399
+
400
+ // Optional series-level intro .md
401
+ if (mdFiles.length > 0) {
402
+ const mdBuf = fs.readFileSync(path.join(entryPath, mdFiles[0]));
403
+ await sanoraUploadArtifact(tenant, seriesTitle, mdBuf, mdFiles[0], 'text');
404
+ }
405
+
406
+ console.log(`[shoppe] 📝 series [${order + 1}]: ${seriesTitle} (${subDirs.length} parts)`);
407
+ } catch (err) {
408
+ console.warn(`[shoppe] ⚠️ series ${entry}: ${err.message}`);
409
+ }
410
+
411
+ // Register each part
412
+ for (let partIndex = 0; partIndex < subDirs.length; partIndex++) {
413
+ const partEntry = subDirs[partIndex];
414
+ const partPath = path.join(entryPath, partEntry);
415
+ const partFolderTitle = partEntry.replace(/^\d+-/, '');
416
+
417
+ const partInfoPath = path.join(partPath, 'info.json');
418
+ const partInfo = fs.existsSync(partInfoPath)
419
+ ? JSON.parse(fs.readFileSync(partInfoPath, 'utf8'))
420
+ : {};
421
+ const partTitle = partInfo.title || partFolderTitle;
422
+ // Sanora product title must be unique — namespace under series
423
+ const productTitle = `${seriesTitle}: ${partTitle}`;
424
+
425
+ try {
426
+ const partMdFiles = fs.readdirSync(partPath).filter(f => f.endsWith('.md'));
427
+ if (partMdFiles.length === 0) {
428
+ console.warn(`[shoppe] ⚠️ part ${partEntry}: no .md file, skipping`);
429
+ continue;
430
+ }
431
+
432
+ const mdBuf = fs.readFileSync(path.join(partPath, partMdFiles[0]));
433
+ const firstHeading = mdBuf.toString('utf8').split('\n')[0].replace(/^#+\s*/, '');
434
+ const description = partInfo.description || firstHeading || partTitle;
435
+
436
+ await sanoraCreateProduct(tenant, productTitle, 'post', description, 0, 0,
437
+ `post,blog,series:${seriesTitle},part:${partIndex + 1},order:${order}`);
438
+
439
+ await sanoraUploadArtifact(tenant, productTitle, mdBuf, partMdFiles[0], 'text');
440
+
441
+ const partCovers = fs.readdirSync(partPath).filter(f => IMAGE_EXTS.has(path.extname(f).toLowerCase()));
442
+ if (partCovers.length > 0) {
443
+ const coverBuf = fs.readFileSync(path.join(partPath, partCovers[0]));
444
+ await sanoraUploadImage(tenant, productTitle, coverBuf, partCovers[0]);
445
+ }
446
+
447
+ const partAssets = fs.readdirSync(partPath).filter(f =>
448
+ !f.endsWith('.md') && f !== 'info.json' && f !== partCovers[0] &&
449
+ IMAGE_EXTS.has(path.extname(f).toLowerCase())
450
+ );
451
+ for (const asset of partAssets) {
452
+ const buf = fs.readFileSync(path.join(partPath, asset));
453
+ await sanoraUploadArtifact(tenant, productTitle, buf, asset, 'image');
454
+ }
455
+
456
+ console.log(`[shoppe] part ${partIndex + 1}: ${partTitle}`);
457
+ } catch (err) {
458
+ console.warn(`[shoppe] ⚠️ part ${partEntry}: ${err.message}`);
459
+ }
460
+ }
461
+
462
+ results.posts.push({ title: seriesTitle, order, parts: subDirs.length });
463
+
464
+ } else {
465
+ // Single post
466
+ try {
467
+ if (mdFiles.length === 0) {
468
+ console.warn(`[shoppe] ⚠️ post ${entry}: no .md file found, skipping`);
469
+ continue;
470
+ }
471
+ const mdBuf = fs.readFileSync(path.join(entryPath, mdFiles[0]));
472
+ const firstHeading = mdBuf.toString('utf8').split('\n')[0].replace(/^#+\s*/, '');
473
+ const title = info.title || folderTitle;
474
+ const description = info.description || firstHeading || title;
475
+
476
+ await sanoraCreateProduct(tenant, title, 'post', description, 0, 0, `post,blog,order:${order}`);
477
+ await sanoraUploadArtifact(tenant, title, mdBuf, mdFiles[0], 'text');
478
+
479
+ const covers = fs.readdirSync(entryPath).filter(f => IMAGE_EXTS.has(path.extname(f).toLowerCase()));
480
+ if (covers.length > 0) {
481
+ const coverBuf = fs.readFileSync(path.join(entryPath, covers[0]));
482
+ await sanoraUploadImage(tenant, title, coverBuf, covers[0]);
483
+ }
484
+
485
+ const assets = fs.readdirSync(entryPath).filter(f =>
486
+ !f.endsWith('.md') && f !== 'info.json' && f !== covers[0] &&
487
+ IMAGE_EXTS.has(path.extname(f).toLowerCase())
488
+ );
489
+ for (const asset of assets) {
490
+ const buf = fs.readFileSync(path.join(entryPath, asset));
491
+ await sanoraUploadArtifact(tenant, title, buf, asset, 'image');
492
+ }
493
+
494
+ results.posts.push({ title, order });
495
+ console.log(`[shoppe] 📝 post [${order + 1}]: ${title}`);
496
+ } catch (err) {
497
+ console.warn(`[shoppe] ⚠️ post ${entry}: ${err.message}`);
498
+ }
345
499
  }
346
500
  }
347
501
  }
348
502
 
349
503
  // ---- albums/ ----
350
504
  // Each subfolder is a photo album
351
- const albumsDir = path.join(tmpDir, 'albums');
505
+ const albumsDir = path.join(root, 'albums');
352
506
  if (fs.existsSync(albumsDir)) {
353
507
  for (const entry of fs.readdirSync(albumsDir)) {
354
508
  const entryPath = path.join(albumsDir, entry);
@@ -374,7 +528,7 @@ async function processArchive(zipPath) {
374
528
 
375
529
  // ---- products/ ----
376
530
  // Each subfolder is a physical product with cover.jpg + info.json
377
- const productsDir = path.join(tmpDir, 'products');
531
+ const productsDir = path.join(root, 'products');
378
532
  if (fs.existsSync(productsDir)) {
379
533
  for (const entry of fs.readdirSync(productsDir)) {
380
534
  const entryPath = path.join(productsDir, entry);