nappup 1.2.1 → 1.3.1

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/package.json CHANGED
@@ -6,7 +6,7 @@
6
6
  "url": "git+https://github.com/44billion/nappup.git"
7
7
  },
8
8
  "license": "GPL-3.0-or-later",
9
- "version": "1.2.1",
9
+ "version": "1.3.1",
10
10
  "description": "Nostr App Uploader",
11
11
  "type": "module",
12
12
  "scripts": {
@@ -0,0 +1,23 @@
1
+ export const NAPP_CATEGORIES = {
2
+ other: [
3
+ 'other'
4
+ ],
5
+ games: [
6
+ 'other', 'action', 'rpg', 'strategy', 'shooter', 'fighting', 'simulation', 'puzzle', 'board', 'gambling', 'racing', 'sports', 'ar', 'vr'
7
+ ],
8
+ money: [
9
+ 'other', 'crypto', 'loans', 'investments', 'wallet', 'gambling', 'raffle', 'crowdfunding', 'donation', 'jobs'
10
+ ],
11
+ shopping: [
12
+ 'other', 'marketplace', 'store', 'auction'
13
+ ],
14
+ social: [
15
+ 'other', 'network', 'messenger', 'blog', 'dating'
16
+ ],
17
+ audiovisual: [
18
+ 'other', 'podcast', 'music', 'video', 'news'
19
+ ],
20
+ utilities: [
21
+ 'other', 'weather', 'office', 'finances', 'learning', 'text editor', 'image editor', 'audio editor', 'video editor', 'ar', 'vr', 'ai'
22
+ ]
23
+ }
package/src/index.js CHANGED
@@ -6,6 +6,7 @@ import NostrSigner from '#services/nostr-signer.js'
6
6
  import { streamToChunks, streamToText } from '#helpers/stream.js'
7
7
  import { isNostrAppDTagSafe, deriveNostrAppDTag } from '#helpers/app.js'
8
8
  import { extractHtmlMetadata, findFavicon, findIndexFile } from '#helpers/app-metadata.js'
9
+ import { NAPP_CATEGORIES } from '#config/napp-categories.js'
9
10
 
10
11
  export default async function (...args) {
11
12
  try {
@@ -34,14 +35,29 @@ export async function toApp (fileList, nostrSigner, { log = () => {}, dTag, chan
34
35
  let nmmr
35
36
  const fileMetadata = []
36
37
 
38
+ // Check for .well-known/napp.json
39
+ const nappJsonFile = fileList.find(f => f.webkitRelativePath.split('/').slice(1).join('/') === '.well-known/napp.json')
40
+ let nappJson = {}
41
+ if (nappJsonFile) {
42
+ try {
43
+ const text = await streamToText(nappJsonFile.stream())
44
+ nappJson = JSON.parse(text)
45
+ fileList = fileList.filter(f => f !== nappJsonFile)
46
+ } catch (e) {
47
+ log('Failed to parse .well-known/napp.json', e)
48
+ }
49
+ }
50
+
37
51
  const indexFile = findIndexFile(fileList)
38
- let stallName, stallSummary
39
- if (indexFile) {
52
+ let stallName = nappJson.stallName?.[0]?.[0]
53
+ let stallSummary = nappJson.stallSummary?.[0]?.[0]
54
+
55
+ if (indexFile && (!stallName || !stallSummary)) {
40
56
  try {
41
57
  const htmlContent = await streamToText(indexFile.stream())
42
58
  const { name, description } = extractHtmlMetadata(htmlContent)
43
- stallName = name
44
- stallSummary = description
59
+ if (!stallName) stallName = name
60
+ if (!stallSummary) stallSummary = description
45
61
  } catch (err) {
46
62
  log('Error extracting HTML metadata:', err)
47
63
  }
@@ -49,8 +65,41 @@ export async function toApp (fileList, nostrSigner, { log = () => {}, dTag, chan
49
65
  const faviconFile = findFavicon(fileList)
50
66
  let iconMetadata
51
67
 
52
- log(`Processing ${fileList.length} files`)
53
68
  let pause = 1000
69
+
70
+ // Upload icon from napp.json if present
71
+ if (nappJson.stallIcon?.[0]?.[0]) {
72
+ try {
73
+ const dataUrl = nappJson.stallIcon[0][0]
74
+ const res = await fetch(dataUrl)
75
+ const blob = await res.blob()
76
+ const mimeType = blob.type
77
+ const extension = mimeType.split('/')[1] || 'bin'
78
+ const filename = `icon.${extension}`
79
+
80
+ log('Uploading icon from napp.json')
81
+
82
+ nmmr = new NMMR()
83
+ const stream = blob.stream()
84
+ let chunkLength = 0
85
+ for await (const chunk of streamToChunks(stream, 51000)) {
86
+ chunkLength++
87
+ await nmmr.append(chunk)
88
+ }
89
+
90
+ if (chunkLength) {
91
+ ;({ pause } = (await uploadBinaryDataChunks({ nmmr, signer: nostrSigner, filename, chunkLength, log, pause, mimeType, shouldReupload })))
92
+ iconMetadata = {
93
+ rootHash: nmmr.getRoot(),
94
+ mimeType
95
+ }
96
+ }
97
+ } catch (e) {
98
+ log('Failed to upload icon from napp.json', e)
99
+ }
100
+ }
101
+
102
+ log(`Processing ${fileList.length} files`)
54
103
  for (const file of fileList) {
55
104
  nmmr = new NMMR()
56
105
  const stream = file.stream()
@@ -85,12 +134,21 @@ export async function toApp (fileList, nostrSigner, { log = () => {}, dTag, chan
85
134
  dTag,
86
135
  channel,
87
136
  name: stallName,
137
+ nameLang: nappJson.stallName?.[0]?.[1],
138
+ isNameAuto: !nappJson.stallName?.[0]?.[0],
88
139
  summary: stallSummary,
140
+ summaryLang: nappJson.stallSummary?.[0]?.[1],
141
+ isSummaryAuto: !nappJson.stallSummary?.[0]?.[0],
89
142
  icon: iconMetadata,
143
+ isIconAuto: !nappJson.stallIcon?.[0]?.[0],
90
144
  signer: nostrSigner,
91
145
  writeRelays,
92
146
  log,
93
- pause
147
+ pause,
148
+ self: nappJson.self?.[0]?.[0],
149
+ countries: nappJson.country,
150
+ categories: nappJson.category,
151
+ hashtags: nappJson.hashtag
94
152
  })))
95
153
 
96
154
  log(`Uploading bundle #${dTag}`)
@@ -244,18 +302,28 @@ async function maybeUploadStall ({
244
302
  dTag,
245
303
  channel,
246
304
  name,
305
+ nameLang,
306
+ isNameAuto,
247
307
  summary,
308
+ summaryLang,
309
+ isSummaryAuto,
248
310
  icon,
311
+ isIconAuto,
249
312
  signer,
250
313
  writeRelays,
251
314
  log,
252
- pause
315
+ pause,
316
+ self,
317
+ countries,
318
+ categories,
319
+ hashtags
253
320
  }) {
254
321
  const trimmedName = typeof name === 'string' ? name.trim() : ''
255
322
  const trimmedSummary = typeof summary === 'string' ? summary.trim() : ''
256
323
  const iconRootHash = icon?.rootHash
257
324
  const iconMimeType = icon?.mimeType
258
- const hasMetadata = Boolean(trimmedName) || Boolean(trimmedSummary) || Boolean(iconRootHash)
325
+ const hasMetadata = Boolean(trimmedName) || Boolean(trimmedSummary) || Boolean(iconRootHash) ||
326
+ Boolean(self) || (countries && countries.length > 0) || (categories && categories.length > 0) || (hashtags && hashtags.length > 0)
259
327
 
260
328
  const previous = await getPreviousStall(dTag, writeRelays, signer, channel)
261
329
  if (!previous && !hasMetadata) return { pause }
@@ -276,27 +344,63 @@ async function maybeUploadStall ({
276
344
 
277
345
  if (!previous) {
278
346
  const tags = [
279
- ['d', dTag],
280
- ['c', '*']
347
+ ['d', dTag]
281
348
  ]
282
349
 
350
+ if (countries && countries.length > 0) {
351
+ countries.forEach(c => tags.push(['c', c]))
352
+ } else {
353
+ tags.push(['c', '*'])
354
+ }
355
+
356
+ if (self) tags.push(['self', self])
357
+
358
+ if (categories) {
359
+ let count = 0
360
+ for (const [cat, subcats] of categories) {
361
+ if (count >= 3) break
362
+ if (Array.isArray(subcats)) {
363
+ for (const sub of subcats) {
364
+ if (count >= 3) break
365
+ if (NAPP_CATEGORIES[cat] && NAPP_CATEGORIES[cat].includes(sub)) {
366
+ tags.push(['l', `napp.${cat}:${sub}`])
367
+ count++
368
+ }
369
+ }
370
+ }
371
+ }
372
+ }
373
+
374
+ if (hashtags) {
375
+ hashtags.slice(0, 3).forEach(([tag, label]) => {
376
+ const t = tag.replace(/\s/g, '').toLowerCase()
377
+ const row = ['t', t]
378
+ if (label) row.push(label)
379
+ tags.push(row)
380
+ })
381
+ }
382
+
283
383
  let hasIcon = false
284
384
  let hasName = false
285
385
  if (iconRootHash && iconMimeType) {
286
386
  hasIcon = true
287
387
  tags.push(['icon', iconRootHash, iconMimeType])
288
- tags.push(['auto', 'icon'])
388
+ if (isIconAuto) tags.push(['auto', 'icon'])
289
389
  }
290
390
 
291
391
  if (trimmedName) {
292
392
  hasName = true
293
- tags.push(['name', trimmedName])
294
- tags.push(['auto', 'name'])
393
+ const row = ['name', trimmedName]
394
+ if (nameLang) row.push(nameLang)
395
+ tags.push(row)
396
+ if (isNameAuto) tags.push(['auto', 'name'])
295
397
  }
296
398
 
297
399
  if (trimmedSummary) {
298
- tags.push(['summary', trimmedSummary])
299
- tags.push(['auto', 'summary'])
400
+ const row = ['summary', trimmedSummary]
401
+ if (summaryLang) row.push(summaryLang)
402
+ tags.push(row)
403
+ if (isSummaryAuto) tags.push(['auto', 'summary'])
300
404
  }
301
405
 
302
406
  if (!hasIcon || !hasName) return { pause }
@@ -314,6 +418,73 @@ async function maybeUploadStall ({
314
418
  : []
315
419
  let changed = false
316
420
 
421
+ // Helper to remove tags by key
422
+ const removeTags = (key) => {
423
+ let idx
424
+ while ((idx = tags.findIndex(t => Array.isArray(t) && t[0] === key)) !== -1) {
425
+ tags.splice(idx, 1)
426
+ changed = true
427
+ }
428
+ }
429
+
430
+ // Helper to remove 'l' tags with specific prefix
431
+ const removeLTags = (prefix) => {
432
+ let idx
433
+ while ((idx = tags.findIndex(t => Array.isArray(t) && t[0] === 'l' && t[1].startsWith(prefix))) !== -1) {
434
+ tags.splice(idx, 1)
435
+ changed = true
436
+ }
437
+ }
438
+
439
+ // Update self
440
+ if (self) {
441
+ removeTags('self')
442
+ tags.push(['self', self])
443
+ changed = true
444
+ }
445
+
446
+ // Update countries
447
+ if (countries) {
448
+ removeTags('c')
449
+ if (countries.length === 0) {
450
+ tags.push(['c', '*'])
451
+ } else {
452
+ countries.forEach(c => tags.push(['c', c]))
453
+ }
454
+ changed = true
455
+ }
456
+
457
+ // Update categories
458
+ if (categories) {
459
+ removeLTags('napp.')
460
+ let count = 0
461
+ for (const [cat, subcats] of categories) {
462
+ if (count >= 3) break
463
+ if (Array.isArray(subcats)) {
464
+ for (const sub of subcats) {
465
+ if (count >= 3) break
466
+ if (NAPP_CATEGORIES[cat] && NAPP_CATEGORIES[cat].includes(sub)) {
467
+ tags.push(['l', `napp.${cat}:${sub}`])
468
+ count++
469
+ }
470
+ }
471
+ }
472
+ }
473
+ changed = true
474
+ }
475
+
476
+ // Update hashtags
477
+ if (hashtags) {
478
+ removeTags('t')
479
+ hashtags.slice(0, 3).forEach(([tag, label]) => {
480
+ const t = tag.replace(/\s/g, '').toLowerCase()
481
+ const row = ['t', t]
482
+ if (label) row.push(label)
483
+ tags.push(row)
484
+ })
485
+ changed = true
486
+ }
487
+
317
488
  const ensureTagValue = (key, updater) => {
318
489
  const index = tags.findIndex(tag => Array.isArray(tag) && tag[0] === key)
319
490
  if (index === -1) {
@@ -337,34 +508,53 @@ async function maybeUploadStall ({
337
508
  return ['d', dTag]
338
509
  })
339
510
 
340
- ensureTagValue('c', (existing) => {
341
- if (!existing) return ['c', '*']
342
- const currentValue = typeof existing[1] === 'string' ? existing[1].trim() : ''
343
- if (currentValue === '') return ['c', '*']
344
- return existing
345
- })
511
+ if (!countries) {
512
+ ensureTagValue('c', (existing) => {
513
+ if (!existing) return ['c', '*']
514
+ const currentValue = typeof existing[1] === 'string' ? existing[1].trim() : ''
515
+ if (currentValue === '') return ['c', '*']
516
+ return existing
517
+ })
518
+ }
346
519
 
347
520
  const hasAuto = (field) => tags.some(tag => Array.isArray(tag) && tag[0] === 'auto' && tag[1] === field)
521
+ const removeAuto = (field) => {
522
+ const idx = tags.findIndex(tag => Array.isArray(tag) && tag[0] === 'auto' && tag[1] === field)
523
+ if (idx !== -1) {
524
+ tags.splice(idx, 1)
525
+ changed = true
526
+ }
527
+ }
348
528
 
349
- if (trimmedName && hasAuto('name')) {
350
- ensureTagValue('name', (existing) => {
351
- if (existing && existing[1] === trimmedName) return existing
352
- return ['name', trimmedName]
353
- })
529
+ if (trimmedName) {
530
+ if (!isNameAuto || hasAuto('name')) {
531
+ ensureTagValue('name', (_) => {
532
+ const row = ['name', trimmedName]
533
+ if (nameLang) row.push(nameLang)
534
+ return row
535
+ })
536
+ if (!isNameAuto) removeAuto('name')
537
+ }
354
538
  }
355
539
 
356
- if (trimmedSummary && hasAuto('summary')) {
357
- ensureTagValue('summary', (existing) => {
358
- if (existing && existing[1] === trimmedSummary) return existing
359
- return ['summary', trimmedSummary]
360
- })
540
+ if (trimmedSummary) {
541
+ if (!isSummaryAuto || hasAuto('summary')) {
542
+ ensureTagValue('summary', (_) => {
543
+ const row = ['summary', trimmedSummary]
544
+ if (summaryLang) row.push(summaryLang)
545
+ return row
546
+ })
547
+ if (!isSummaryAuto) removeAuto('summary')
548
+ }
361
549
  }
362
550
 
363
- if (iconRootHash && iconMimeType && hasAuto('icon')) {
364
- ensureTagValue('icon', (existing) => {
365
- if (existing && existing[1] === iconRootHash && existing[2] === iconMimeType) return existing
366
- return ['icon', iconRootHash, iconMimeType]
367
- })
551
+ if (iconRootHash && iconMimeType) {
552
+ if (!isIconAuto || hasAuto('icon')) {
553
+ ensureTagValue('icon', (_) => {
554
+ return ['icon', iconRootHash, iconMimeType]
555
+ })
556
+ if (!isIconAuto) removeAuto('icon')
557
+ }
368
558
  }
369
559
 
370
560
  if (!changed) return { pause }