wiki-plugin-shoppe 0.0.5 → 0.0.7

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,13 @@ 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-Another Post/
55
+ post.md
45
56
  albums/
46
57
  Vacation 2025/ ← photo album = subfolder
47
58
  photo1.jpg
package/client/shoppe.js CHANGED
@@ -64,13 +64,24 @@
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 other assets referenced in the markdown
82
+ info.json ← optional: { "title": "…", "description": "…" }
83
+ 02-Another Post/
84
+ post.md
74
85
  albums/
75
86
  Vacation 2025/ ← subfolder of images = photo album
76
87
  photo1.jpg
@@ -103,9 +114,16 @@
103
114
  <div id="sw-upload-status" class="sw-status"></div>
104
115
  </div>
105
116
 
106
- <!-- Owner: register -->
117
+ <!-- Owner: config + register -->
107
118
  <div class="sw-section" id="sw-owner-section" style="display:none">
108
- <h3>Register a new shoppe (owner only)</h3>
119
+ <h3>Allyabase connection (owner only)</h3>
120
+ <div class="sw-register">
121
+ <input type="text" id="sw-url-input" placeholder="https://dojo.allyabase.com/plugin/allyabase/sanora">
122
+ <button class="sw-btn sw-btn-blue" id="sw-url-btn">Save</button>
123
+ </div>
124
+ <div id="sw-url-status" class="sw-status"></div>
125
+
126
+ <h3 style="margin-top:20px">Register a new shoppe (owner only)</h3>
109
127
  <div class="sw-register">
110
128
  <input type="text" id="sw-name-input" placeholder="Shoppe name (e.g. Zach's Art Store)">
111
129
  <button class="sw-btn sw-btn-green" id="sw-register-btn">Register</button>
@@ -153,9 +171,13 @@
153
171
 
154
172
  async function checkOwner(container) {
155
173
  try {
156
- const resp = await fetch('/plugin/shoppe/tenants');
174
+ const resp = await fetch('/plugin/shoppe/config');
157
175
  if (resp.ok) {
158
176
  container.querySelector('#sw-owner-section').style.display = 'block';
177
+ const result = await resp.json();
178
+ if (result.sanoraUrl) {
179
+ container.querySelector('#sw-url-input').value = result.sanoraUrl;
180
+ }
159
181
  }
160
182
  } catch (err) { /* not owner, stay hidden */ }
161
183
  }
@@ -163,11 +185,17 @@
163
185
  // ── Listeners ───────────────────────────────────────────────────────────────
164
186
 
165
187
  function setupListeners(container) {
166
- const drop = container.querySelector('#sw-drop');
188
+ const drop = container.querySelector('#sw-drop');
167
189
  const fileInput = container.querySelector('#sw-file-input');
168
190
  const browseBtn = container.querySelector('#sw-browse-btn');
169
191
  const registerBtn = container.querySelector('#sw-register-btn');
170
192
  const nameInput = container.querySelector('#sw-name-input');
193
+ const urlBtn = container.querySelector('#sw-url-btn');
194
+ const urlInput = container.querySelector('#sw-url-input');
195
+
196
+ if (urlBtn) {
197
+ urlBtn.addEventListener('click', () => saveUrl(container));
198
+ }
171
199
 
172
200
  browseBtn.addEventListener('click', () => fileInput.click());
173
201
  fileInput.addEventListener('change', e => {
@@ -222,6 +250,33 @@
222
250
  }
223
251
  }
224
252
 
253
+ // ── Save URL (owner) ────────────────────────────────────────────────────────
254
+
255
+ async function saveUrl(container) {
256
+ const urlInput = container.querySelector('#sw-url-input');
257
+ const urlBtn = container.querySelector('#sw-url-btn');
258
+ const url = urlInput.value.trim();
259
+ if (!url) { showStatus(container, '#sw-url-status', 'Enter an allyabase URL first', 'error'); return; }
260
+
261
+ urlBtn.disabled = true;
262
+ urlBtn.textContent = 'Saving…';
263
+ try {
264
+ const resp = await fetch('/plugin/shoppe/config', {
265
+ method: 'POST',
266
+ headers: { 'Content-Type': 'application/json' },
267
+ body: JSON.stringify({ sanoraUrl: url })
268
+ });
269
+ const result = await resp.json();
270
+ if (!result.success) throw new Error(result.error || 'Save failed');
271
+ showStatus(container, '#sw-url-status', `✅ Connected to <strong>${url}</strong>`, 'success');
272
+ } catch (err) {
273
+ showStatus(container, '#sw-url-status', `❌ ${err.message}`, 'error');
274
+ } finally {
275
+ urlBtn.disabled = false;
276
+ urlBtn.textContent = 'Save';
277
+ }
278
+ }
279
+
225
280
  // ── Register (owner) ────────────────────────────────────────────────────────
226
281
 
227
282
  async function registerShoppe(container) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wiki-plugin-shoppe",
3
- "version": "0.0.5",
3
+ "version": "0.0.7",
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
@@ -7,12 +7,31 @@ const FormData = require('form-data');
7
7
  const AdmZip = require('adm-zip');
8
8
  const sessionless = require('sessionless-node');
9
9
 
10
- const SANORA_PORT = process.env.SANORA_PORT || 7243;
11
10
  const SHOPPE_BASE_EMOJI = process.env.SHOPPE_BASE_EMOJI || '🛍️🎨🎁';
12
11
 
13
12
  const TENANTS_FILE = path.join(__dirname, '../.shoppe-tenants.json');
13
+ const CONFIG_FILE = path.join(__dirname, '../.shoppe-config.json');
14
14
  const TMP_DIR = '/tmp/shoppe-uploads';
15
15
 
16
+ // ============================================================
17
+ // CONFIG (allyabase URL, etc.)
18
+ // ============================================================
19
+
20
+ function loadConfig() {
21
+ if (!fs.existsSync(CONFIG_FILE)) return {};
22
+ try { return JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8')); } catch (e) { return {}; }
23
+ }
24
+
25
+ function saveConfig(config) {
26
+ fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2));
27
+ }
28
+
29
+ function getSanoraUrl() {
30
+ const config = loadConfig();
31
+ if (config.sanoraUrl) return config.sanoraUrl.replace(/\/$/, '');
32
+ return `http://localhost:${process.env.SANORA_PORT || 7243}`;
33
+ }
34
+
16
35
  // Same diverse palette as BDO emojicoding
17
36
  const EMOJI_PALETTE = [
18
37
  '🌟', '🌙', '🌍', '🌊', '🔥', '💎', '🎨', '🎭', '🎪', '🎯',
@@ -70,7 +89,7 @@ async function registerTenant(name) {
70
89
  const message = timestamp + keys.pubKey;
71
90
  const signature = await sessionless.sign(message);
72
91
 
73
- const resp = await fetch(`http://localhost:${SANORA_PORT}/user/create`, {
92
+ const resp = await fetch(`${getSanoraUrl()}/user/create`, {
74
93
  method: 'PUT',
75
94
  headers: { 'Content-Type': 'application/json' },
76
95
  body: JSON.stringify({ timestamp, pubKey: keys.pubKey, signature })
@@ -138,7 +157,7 @@ async function sanoraCreateProduct(tenant, title, category, description, price,
138
157
  const signature = await sessionless.sign(message);
139
158
 
140
159
  const resp = await fetch(
141
- `http://localhost:${SANORA_PORT}/user/${uuid}/product/${encodeURIComponent(title)}`,
160
+ `${getSanoraUrl()}/user/${uuid}/product/${encodeURIComponent(title)}`,
142
161
  {
143
162
  method: 'PUT',
144
163
  headers: { 'Content-Type': 'application/json' },
@@ -171,7 +190,7 @@ async function sanoraUploadArtifact(tenant, title, fileBuffer, filename, artifac
171
190
  form.append('artifact', fileBuffer, { filename, contentType: getMimeType(filename) });
172
191
 
173
192
  const resp = await fetch(
174
- `http://localhost:${SANORA_PORT}/user/${uuid}/product/${encodeURIComponent(title)}/artifact`,
193
+ `${getSanoraUrl()}/user/${uuid}/product/${encodeURIComponent(title)}/artifact`,
175
194
  {
176
195
  method: 'PUT',
177
196
  headers: {
@@ -200,7 +219,7 @@ async function sanoraUploadImage(tenant, title, imageBuffer, filename) {
200
219
  form.append('image', imageBuffer, { filename, contentType: getMimeType(filename) });
201
220
 
202
221
  const resp = await fetch(
203
- `http://localhost:${SANORA_PORT}/user/${uuid}/product/${encodeURIComponent(title)}/image`,
222
+ `${getSanoraUrl()}/user/${uuid}/product/${encodeURIComponent(title)}/image`,
204
223
  {
205
224
  method: 'PUT',
206
225
  headers: {
@@ -247,19 +266,41 @@ async function processArchive(zipPath) {
247
266
  const results = { books: [], music: [], posts: [], albums: [], products: [] };
248
267
 
249
268
  // ---- books/ ----
269
+ // Each book is a subfolder containing the book file, cover.jpg, and info.json
250
270
  const booksDir = path.join(tmpDir, 'books');
251
271
  if (fs.existsSync(booksDir)) {
252
- for (const file of fs.readdirSync(booksDir)) {
253
- if (!BOOK_EXTS.has(path.extname(file).toLowerCase())) continue;
254
- const title = path.basename(file, path.extname(file));
272
+ for (const entry of fs.readdirSync(booksDir)) {
273
+ const entryPath = path.join(booksDir, entry);
274
+ if (!fs.statSync(entryPath).isDirectory()) continue;
255
275
  try {
256
- const buf = fs.readFileSync(path.join(booksDir, file));
257
- await sanoraCreateProduct(tenant, title, 'book', `Book: ${title}`, 0, 0, 'book');
258
- await sanoraUploadArtifact(tenant, title, buf, file, 'ebook');
259
- results.books.push({ title });
276
+ const infoPath = path.join(entryPath, 'info.json');
277
+ const info = fs.existsSync(infoPath)
278
+ ? JSON.parse(fs.readFileSync(infoPath, 'utf8'))
279
+ : {};
280
+ const title = info.title || entry;
281
+ const description = info.description || '';
282
+ const price = info.price || 0;
283
+
284
+ await sanoraCreateProduct(tenant, title, 'book', description, price, 0, 'book');
285
+
286
+ // Cover image
287
+ const covers = fs.readdirSync(entryPath).filter(f => IMAGE_EXTS.has(path.extname(f).toLowerCase()));
288
+ if (covers.length > 0) {
289
+ const coverBuf = fs.readFileSync(path.join(entryPath, covers[0]));
290
+ await sanoraUploadImage(tenant, title, coverBuf, covers[0]);
291
+ }
292
+
293
+ // Book file
294
+ const bookFiles = fs.readdirSync(entryPath).filter(f => BOOK_EXTS.has(path.extname(f).toLowerCase()));
295
+ if (bookFiles.length > 0) {
296
+ const buf = fs.readFileSync(path.join(entryPath, bookFiles[0]));
297
+ await sanoraUploadArtifact(tenant, title, buf, bookFiles[0], 'ebook');
298
+ }
299
+
300
+ results.books.push({ title, price });
260
301
  console.log(`[shoppe] 📚 book: ${title}`);
261
302
  } catch (err) {
262
- console.warn(`[shoppe] ⚠️ book ${file}: ${err.message}`);
303
+ console.warn(`[shoppe] ⚠️ book ${entry}: ${err.message}`);
263
304
  }
264
305
  }
265
306
  }
@@ -309,20 +350,66 @@ async function processArchive(zipPath) {
309
350
  }
310
351
 
311
352
  // ---- posts/ ----
353
+ // Each post is a numbered subfolder: "01-My Title/" containing post.md,
354
+ // optional assets (images etc.), and optional info.json for metadata overrides.
355
+ // Folders are sorted by their numeric prefix to build the table of contents.
312
356
  const postsDir = path.join(tmpDir, 'posts');
313
357
  if (fs.existsSync(postsDir)) {
314
- for (const file of fs.readdirSync(postsDir)) {
315
- if (!file.endsWith('.md')) continue;
316
- const title = path.basename(file, '.md');
358
+ const postFolders = fs.readdirSync(postsDir)
359
+ .filter(f => fs.statSync(path.join(postsDir, f)).isDirectory())
360
+ .sort(); // lexicographic sort respects numeric prefixes (01-, 02-, …)
361
+
362
+ for (let order = 0; order < postFolders.length; order++) {
363
+ const entry = postFolders[order];
364
+ const entryPath = path.join(postsDir, entry);
317
365
  try {
318
- const buf = fs.readFileSync(path.join(postsDir, file));
319
- const firstLine = buf.toString('utf8').split('\n')[0].replace(/^#+\s*/, '');
320
- await sanoraCreateProduct(tenant, title, 'post', firstLine || title, 0, 0, 'post,blog');
321
- await sanoraUploadArtifact(tenant, title, buf, file, 'text');
322
- results.posts.push({ title });
323
- console.log(`[shoppe] 📝 post: ${title}`);
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
+ : {};
371
+
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;
377
+ }
378
+ const mdBuf = fs.readFileSync(path.join(entryPath, mdFiles[0]));
379
+ const mdContent = mdBuf.toString('utf8');
380
+
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;
386
+
387
+ await sanoraCreateProduct(tenant, title, 'post', description, 0, 0, `post,blog,order:${order}`);
388
+
389
+ // Upload the markdown as the main artifact
390
+ await sanoraUploadArtifact(tenant, title, mdBuf, mdFiles[0], 'text');
391
+
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
+ }
398
+
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
+ }
408
+
409
+ results.posts.push({ title, order });
410
+ console.log(`[shoppe] 📝 post [${order + 1}]: ${title}`);
324
411
  } catch (err) {
325
- console.warn(`[shoppe] ⚠️ post ${file}: ${err.message}`);
412
+ console.warn(`[shoppe] ⚠️ post ${entry}: ${err.message}`);
326
413
  }
327
414
  }
328
415
  }
@@ -401,7 +488,7 @@ async function processArchive(zipPath) {
401
488
  // ============================================================
402
489
 
403
490
  async function getShoppeGoods(tenant) {
404
- const resp = await fetch(`http://localhost:${SANORA_PORT}/products/${tenant.uuid}`);
491
+ const resp = await fetch(`${getSanoraUrl()}/products/${tenant.uuid}`);
405
492
  const products = await resp.json();
406
493
 
407
494
  const goods = { books: [], music: [], posts: [], albums: [], products: [] };
@@ -412,8 +499,8 @@ async function getShoppeGoods(tenant) {
412
499
  description: product.description || '',
413
500
  price: product.price || 0,
414
501
  shipping: product.shipping || 0,
415
- image: product.image ? `http://localhost:${SANORA_PORT}/images/${product.image}` : null,
416
- url: `http://localhost:${SANORA_PORT}/products/${tenant.uuid}/${encodeURIComponent(title)}`
502
+ image: product.image ? `${getSanoraUrl()}/images/${product.image}` : null,
503
+ url: `${getSanoraUrl()}/products/${tenant.uuid}/${encodeURIComponent(title)}`
417
504
  };
418
505
  const bucket = goods[product.category];
419
506
  if (bucket) bucket.push(item);
@@ -597,6 +684,23 @@ async function startServer(params) {
597
684
  }
598
685
  });
599
686
 
687
+ // Get config (owner only)
688
+ app.get('/plugin/shoppe/config', owner, (req, res) => {
689
+ const config = loadConfig();
690
+ res.json({ success: true, sanoraUrl: config.sanoraUrl || '' });
691
+ });
692
+
693
+ // Save config (owner only)
694
+ app.post('/plugin/shoppe/config', owner, (req, res) => {
695
+ const { sanoraUrl } = req.body;
696
+ if (!sanoraUrl) return res.status(400).json({ success: false, error: 'sanoraUrl required' });
697
+ const config = loadConfig();
698
+ config.sanoraUrl = sanoraUrl;
699
+ saveConfig(config);
700
+ console.log('[shoppe] Sanora URL set to:', sanoraUrl);
701
+ res.json({ success: true });
702
+ });
703
+
600
704
  // Goods JSON (public)
601
705
  app.get('/plugin/shoppe/:identifier/goods', async (req, res) => {
602
706
  try {