wiki-plugin-shoppe 0.0.7 → 0.0.9

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
@@ -51,8 +51,15 @@ my-shoppe.zip
51
51
  cover.jpg ← optional cover image
52
52
  screenshot.png ← any assets referenced in the markdown
53
53
  info.json ← optional: { "title": "…", "description": "…" }
54
- 02-Another Post/
55
- post.md
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
56
63
  albums/
57
64
  Vacation 2025/ ← photo album = subfolder
58
65
  photo1.jpg
package/client/shoppe.js CHANGED
@@ -78,10 +78,16 @@
78
78
  01-Hello World/ ← number prefix sets table of contents order
79
79
  post.md ← the post content
80
80
  cover.jpg ← optional cover image
81
- screenshot.png ← any other assets referenced in the markdown
81
+ screenshot.png ← any assets referenced in the markdown
82
82
  info.json ← optional: { "title": "…", "description": "…" }
83
- 02-Another Post/
84
- post.md
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
85
91
  albums/
86
92
  Vacation 2025/ ← subfolder of images = photo album
87
93
  photo1.jpg
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wiki-plugin-shoppe",
3
- "version": "0.0.7",
3
+ "version": "0.0.9",
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
@@ -9,9 +9,10 @@ const sessionless = require('sessionless-node');
9
9
 
10
10
  const SHOPPE_BASE_EMOJI = process.env.SHOPPE_BASE_EMOJI || '🛍️🎨🎁';
11
11
 
12
- const TENANTS_FILE = path.join(__dirname, '../.shoppe-tenants.json');
13
- const CONFIG_FILE = path.join(__dirname, '../.shoppe-config.json');
14
- const TMP_DIR = '/tmp/shoppe-uploads';
12
+ const DATA_DIR = path.join(process.env.HOME || '/root', '.shoppe');
13
+ const TENANTS_FILE = path.join(DATA_DIR, 'tenants.json');
14
+ const CONFIG_FILE = path.join(DATA_DIR, 'config.json');
15
+ const TMP_DIR = '/tmp/shoppe-uploads';
15
16
 
16
17
  // ============================================================
17
18
  // CONFIG (allyabase URL, etc.)
@@ -246,8 +247,16 @@ async function processArchive(zipPath) {
246
247
  zip.extractAllTo(tmpDir, true);
247
248
 
248
249
  try {
249
- // Read and validate manifest
250
- const manifestPath = path.join(tmpDir, 'manifest.json');
250
+ // Find manifest.json handle zips that wrap everything in a top-level folder
251
+ let root = tmpDir;
252
+ let manifestPath = path.join(tmpDir, 'manifest.json');
253
+ if (!fs.existsSync(manifestPath)) {
254
+ const entries = fs.readdirSync(tmpDir);
255
+ if (entries.length === 1 && fs.statSync(path.join(tmpDir, entries[0])).isDirectory()) {
256
+ root = path.join(tmpDir, entries[0]);
257
+ manifestPath = path.join(root, 'manifest.json');
258
+ }
259
+ }
251
260
  if (!fs.existsSync(manifestPath)) {
252
261
  throw new Error('Archive is missing manifest.json');
253
262
  }
@@ -267,7 +276,7 @@ async function processArchive(zipPath) {
267
276
 
268
277
  // ---- books/ ----
269
278
  // Each book is a subfolder containing the book file, cover.jpg, and info.json
270
- const booksDir = path.join(tmpDir, 'books');
279
+ const booksDir = path.join(root, 'books');
271
280
  if (fs.existsSync(booksDir)) {
272
281
  for (const entry of fs.readdirSync(booksDir)) {
273
282
  const entryPath = path.join(booksDir, entry);
@@ -307,7 +316,7 @@ async function processArchive(zipPath) {
307
316
 
308
317
  // ---- music/ ----
309
318
  // Albums are subfolders; standalone files are individual tracks
310
- const musicDir = path.join(tmpDir, 'music');
319
+ const musicDir = path.join(root, 'music');
311
320
  if (fs.existsSync(musicDir)) {
312
321
  for (const entry of fs.readdirSync(musicDir)) {
313
322
  const entryPath = path.join(musicDir, entry);
@@ -353,7 +362,7 @@ async function processArchive(zipPath) {
353
362
  // Each post is a numbered subfolder: "01-My Title/" containing post.md,
354
363
  // optional assets (images etc.), and optional info.json for metadata overrides.
355
364
  // Folders are sorted by their numeric prefix to build the table of contents.
356
- const postsDir = path.join(tmpDir, 'posts');
365
+ const postsDir = path.join(root, 'posts');
357
366
  if (fs.existsSync(postsDir)) {
358
367
  const postFolders = fs.readdirSync(postsDir)
359
368
  .filter(f => fs.statSync(path.join(postsDir, f)).isDirectory())
@@ -362,61 +371,139 @@ async function processArchive(zipPath) {
362
371
  for (let order = 0; order < postFolders.length; order++) {
363
372
  const entry = postFolders[order];
364
373
  const entryPath = path.join(postsDir, entry);
365
- try {
366
- // Metadata: info.json overrides folder name and md heading
367
- const infoPath = path.join(entryPath, 'info.json');
368
- const info = fs.existsSync(infoPath)
369
- ? JSON.parse(fs.readFileSync(infoPath, 'utf8'))
370
- : {};
374
+ const folderTitle = entry.replace(/^\d+-/, '');
375
+
376
+ const infoPath = path.join(entryPath, 'info.json');
377
+ const info = fs.existsSync(infoPath)
378
+ ? JSON.parse(fs.readFileSync(infoPath, 'utf8'))
379
+ : {};
380
+ const seriesTitle = info.title || folderTitle;
381
+
382
+ // Check if this is a multi-part series (has numbered subdirectories)
383
+ const subDirs = fs.readdirSync(entryPath)
384
+ .filter(f => fs.statSync(path.join(entryPath, f)).isDirectory())
385
+ .sort();
386
+ const mdFiles = fs.readdirSync(entryPath).filter(f => f.endsWith('.md'));
387
+ const isSeries = subDirs.length > 0;
388
+
389
+ if (isSeries) {
390
+ // Register the series itself as a parent product
391
+ try {
392
+ const description = info.description || `A ${subDirs.length}-part series`;
393
+ await sanoraCreateProduct(tenant, seriesTitle, 'post-series', description, 0, 0, `post,series,order:${order}`);
394
+
395
+ const covers = fs.readdirSync(entryPath).filter(f => IMAGE_EXTS.has(path.extname(f).toLowerCase()));
396
+ if (covers.length > 0) {
397
+ const coverBuf = fs.readFileSync(path.join(entryPath, covers[0]));
398
+ await sanoraUploadImage(tenant, seriesTitle, coverBuf, covers[0]);
399
+ }
400
+
401
+ // Optional series-level intro .md
402
+ if (mdFiles.length > 0) {
403
+ const mdBuf = fs.readFileSync(path.join(entryPath, mdFiles[0]));
404
+ await sanoraUploadArtifact(tenant, seriesTitle, mdBuf, mdFiles[0], 'text');
405
+ }
406
+
407
+ console.log(`[shoppe] 📝 series [${order + 1}]: ${seriesTitle} (${subDirs.length} parts)`);
408
+ } catch (err) {
409
+ console.warn(`[shoppe] ⚠️ series ${entry}: ${err.message}`);
410
+ }
371
411
 
372
- // Find the .md file
373
- const mdFiles = fs.readdirSync(entryPath).filter(f => f.endsWith('.md'));
374
- if (mdFiles.length === 0) {
375
- console.warn(`[shoppe] ⚠️ post ${entry}: no .md file found, skipping`);
376
- continue;
412
+ // Register each part
413
+ for (let partIndex = 0; partIndex < subDirs.length; partIndex++) {
414
+ const partEntry = subDirs[partIndex];
415
+ const partPath = path.join(entryPath, partEntry);
416
+ const partFolderTitle = partEntry.replace(/^\d+-/, '');
417
+
418
+ const partInfoPath = path.join(partPath, 'info.json');
419
+ const partInfo = fs.existsSync(partInfoPath)
420
+ ? JSON.parse(fs.readFileSync(partInfoPath, 'utf8'))
421
+ : {};
422
+ const partTitle = partInfo.title || partFolderTitle;
423
+ // Sanora product title must be unique — namespace under series
424
+ const productTitle = `${seriesTitle}: ${partTitle}`;
425
+
426
+ try {
427
+ const partMdFiles = fs.readdirSync(partPath).filter(f => f.endsWith('.md'));
428
+ if (partMdFiles.length === 0) {
429
+ console.warn(`[shoppe] ⚠️ part ${partEntry}: no .md file, skipping`);
430
+ continue;
431
+ }
432
+
433
+ const mdBuf = fs.readFileSync(path.join(partPath, partMdFiles[0]));
434
+ const firstHeading = mdBuf.toString('utf8').split('\n')[0].replace(/^#+\s*/, '');
435
+ const description = partInfo.description || firstHeading || partTitle;
436
+
437
+ await sanoraCreateProduct(tenant, productTitle, 'post', description, 0, 0,
438
+ `post,blog,series:${seriesTitle},part:${partIndex + 1},order:${order}`);
439
+
440
+ await sanoraUploadArtifact(tenant, productTitle, mdBuf, partMdFiles[0], 'text');
441
+
442
+ const partCovers = fs.readdirSync(partPath).filter(f => IMAGE_EXTS.has(path.extname(f).toLowerCase()));
443
+ if (partCovers.length > 0) {
444
+ const coverBuf = fs.readFileSync(path.join(partPath, partCovers[0]));
445
+ await sanoraUploadImage(tenant, productTitle, coverBuf, partCovers[0]);
446
+ }
447
+
448
+ const partAssets = fs.readdirSync(partPath).filter(f =>
449
+ !f.endsWith('.md') && f !== 'info.json' && f !== partCovers[0] &&
450
+ IMAGE_EXTS.has(path.extname(f).toLowerCase())
451
+ );
452
+ for (const asset of partAssets) {
453
+ const buf = fs.readFileSync(path.join(partPath, asset));
454
+ await sanoraUploadArtifact(tenant, productTitle, buf, asset, 'image');
455
+ }
456
+
457
+ console.log(`[shoppe] part ${partIndex + 1}: ${partTitle}`);
458
+ } catch (err) {
459
+ console.warn(`[shoppe] ⚠️ part ${partEntry}: ${err.message}`);
460
+ }
377
461
  }
378
- const mdBuf = fs.readFileSync(path.join(entryPath, mdFiles[0]));
379
- const mdContent = mdBuf.toString('utf8');
380
462
 
381
- // Derive title: info.json > folder name stripped of numeric prefix
382
- const folderTitle = entry.replace(/^\d+-/, '');
383
- const firstHeading = mdContent.split('\n')[0].replace(/^#+\s*/, '');
384
- const title = info.title || folderTitle;
385
- const description = info.description || firstHeading || title;
463
+ results.posts.push({ title: seriesTitle, order, parts: subDirs.length });
386
464
 
387
- await sanoraCreateProduct(tenant, title, 'post', description, 0, 0, `post,blog,order:${order}`);
465
+ } else {
466
+ // Single post
467
+ try {
468
+ if (mdFiles.length === 0) {
469
+ console.warn(`[shoppe] ⚠️ post ${entry}: no .md file found, skipping`);
470
+ continue;
471
+ }
472
+ const mdBuf = fs.readFileSync(path.join(entryPath, mdFiles[0]));
473
+ const firstHeading = mdBuf.toString('utf8').split('\n')[0].replace(/^#+\s*/, '');
474
+ const title = info.title || folderTitle;
475
+ const description = info.description || firstHeading || title;
388
476
 
389
- // Upload the markdown as the main artifact
390
- await sanoraUploadArtifact(tenant, title, mdBuf, mdFiles[0], 'text');
477
+ await sanoraCreateProduct(tenant, title, 'post', description, 0, 0, `post,blog,order:${order}`);
478
+ await sanoraUploadArtifact(tenant, title, mdBuf, mdFiles[0], 'text');
391
479
 
392
- // Upload cover image if present
393
- const covers = fs.readdirSync(entryPath).filter(f => IMAGE_EXTS.has(path.extname(f).toLowerCase()));
394
- if (covers.length > 0) {
395
- const coverBuf = fs.readFileSync(path.join(entryPath, covers[0]));
396
- await sanoraUploadImage(tenant, title, coverBuf, covers[0]);
397
- }
480
+ const covers = fs.readdirSync(entryPath).filter(f => IMAGE_EXTS.has(path.extname(f).toLowerCase()));
481
+ if (covers.length > 0) {
482
+ const coverBuf = fs.readFileSync(path.join(entryPath, covers[0]));
483
+ await sanoraUploadImage(tenant, title, coverBuf, covers[0]);
484
+ }
398
485
 
399
- // Upload remaining assets (images not used as cover)
400
- const assets = fs.readdirSync(entryPath).filter(f =>
401
- !f.endsWith('.md') && f !== 'info.json' && f !== covers[0] &&
402
- IMAGE_EXTS.has(path.extname(f).toLowerCase())
403
- );
404
- for (const asset of assets) {
405
- const buf = fs.readFileSync(path.join(entryPath, asset));
406
- await sanoraUploadArtifact(tenant, title, buf, asset, 'image');
407
- }
486
+ const assets = fs.readdirSync(entryPath).filter(f =>
487
+ !f.endsWith('.md') && f !== 'info.json' && f !== covers[0] &&
488
+ IMAGE_EXTS.has(path.extname(f).toLowerCase())
489
+ );
490
+ for (const asset of assets) {
491
+ const buf = fs.readFileSync(path.join(entryPath, asset));
492
+ await sanoraUploadArtifact(tenant, title, buf, asset, 'image');
493
+ }
408
494
 
409
- results.posts.push({ title, order });
410
- console.log(`[shoppe] 📝 post [${order + 1}]: ${title}`);
411
- } catch (err) {
412
- console.warn(`[shoppe] ⚠️ post ${entry}: ${err.message}`);
495
+ results.posts.push({ title, order });
496
+ console.log(`[shoppe] 📝 post [${order + 1}]: ${title}`);
497
+ } catch (err) {
498
+ console.warn(`[shoppe] ⚠️ post ${entry}: ${err.message}`);
499
+ }
413
500
  }
414
501
  }
415
502
  }
416
503
 
417
504
  // ---- albums/ ----
418
505
  // Each subfolder is a photo album
419
- const albumsDir = path.join(tmpDir, 'albums');
506
+ const albumsDir = path.join(root, 'albums');
420
507
  if (fs.existsSync(albumsDir)) {
421
508
  for (const entry of fs.readdirSync(albumsDir)) {
422
509
  const entryPath = path.join(albumsDir, entry);
@@ -442,7 +529,7 @@ async function processArchive(zipPath) {
442
529
 
443
530
  // ---- products/ ----
444
531
  // Each subfolder is a physical product with cover.jpg + info.json
445
- const productsDir = path.join(tmpDir, 'products');
532
+ const productsDir = path.join(root, 'products');
446
533
  if (fs.existsSync(productsDir)) {
447
534
  for (const entry of fs.readdirSync(productsDir)) {
448
535
  const entryPath = path.join(productsDir, entry);
@@ -619,7 +706,8 @@ function generateShoppeHTML(tenant, goods) {
619
706
  async function startServer(params) {
620
707
  const app = params.app;
621
708
 
622
- if (!fs.existsSync(TMP_DIR)) fs.mkdirSync(TMP_DIR, { recursive: true });
709
+ if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true });
710
+ if (!fs.existsSync(TMP_DIR)) fs.mkdirSync(TMP_DIR, { recursive: true });
623
711
  console.log('🛍️ wiki-plugin-shoppe starting...');
624
712
 
625
713
  const owner = (req, res, next) => {