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 +15 -4
- package/client/shoppe.js +61 -6
- package/package.json +1 -1
- package/server/server.js +130 -26
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
|
|
35
|
-
|
|
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
|
-
|
|
44
|
-
|
|
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/
|
|
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/
|
|
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>
|
|
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/
|
|
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
|
|
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
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
253
|
-
|
|
254
|
-
|
|
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
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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 ${
|
|
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
|
-
|
|
315
|
-
|
|
316
|
-
|
|
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
|
-
|
|
319
|
-
const
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
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 ${
|
|
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(
|
|
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 ?
|
|
416
|
-
url:
|
|
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 {
|