nappup 1.2.0 → 1.3.0

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.0",
9
+ "version": "1.3.0",
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
+ }
@@ -7,13 +7,14 @@ for (let z = 0; z < ALPHABET.length; z++) {
7
7
  }
8
8
 
9
9
  function polymod (values) {
10
+ const GEN = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3]
10
11
  let chk = 1
11
12
  for (let p = 0; p < values.length; ++p) {
12
13
  const top = chk >> 25
13
14
  chk = (chk & 0x1ffffff) << 5 ^ values[p]
14
15
  for (let i = 0; i < 5; ++i) {
15
16
  if ((top >> i) & 1) {
16
- chk ^= 0x3b6a57b2 >> i
17
+ chk ^= GEN[i]
17
18
  }
18
19
  }
19
20
  }
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 }
@@ -280,23 +348,58 @@ async function maybeUploadStall ({
280
348
  ['c', '*']
281
349
  ]
282
350
 
351
+ if (self) tags.push(['self', self])
352
+
353
+ if (countries) {
354
+ countries.forEach(c => tags.push(['country', c]))
355
+ }
356
+
357
+ if (categories) {
358
+ let count = 0
359
+ for (const [cat, subcats] of categories) {
360
+ if (count >= 3) break
361
+ if (Array.isArray(subcats)) {
362
+ for (const sub of subcats) {
363
+ if (count >= 3) break
364
+ if (NAPP_CATEGORIES[cat] && NAPP_CATEGORIES[cat].includes(sub)) {
365
+ tags.push(['l', `napp.${cat}:${sub}`])
366
+ count++
367
+ }
368
+ }
369
+ }
370
+ }
371
+ }
372
+
373
+ if (hashtags) {
374
+ hashtags.slice(0, 3).forEach(([tag, label]) => {
375
+ const t = tag.replace(/\s/g, '').toLowerCase()
376
+ const row = ['t', t]
377
+ if (label) row.push(label)
378
+ tags.push(row)
379
+ })
380
+ }
381
+
283
382
  let hasIcon = false
284
383
  let hasName = false
285
384
  if (iconRootHash && iconMimeType) {
286
385
  hasIcon = true
287
386
  tags.push(['icon', iconRootHash, iconMimeType])
288
- tags.push(['auto', 'icon'])
387
+ if (isIconAuto) tags.push(['auto', 'icon'])
289
388
  }
290
389
 
291
390
  if (trimmedName) {
292
391
  hasName = true
293
- tags.push(['name', trimmedName])
294
- tags.push(['auto', 'name'])
392
+ const row = ['name', trimmedName]
393
+ if (nameLang) row.push(nameLang)
394
+ tags.push(row)
395
+ if (isNameAuto) tags.push(['auto', 'name'])
295
396
  }
296
397
 
297
398
  if (trimmedSummary) {
298
- tags.push(['summary', trimmedSummary])
299
- tags.push(['auto', 'summary'])
399
+ const row = ['summary', trimmedSummary]
400
+ if (summaryLang) row.push(summaryLang)
401
+ tags.push(row)
402
+ if (isSummaryAuto) tags.push(['auto', 'summary'])
300
403
  }
301
404
 
302
405
  if (!hasIcon || !hasName) return { pause }
@@ -314,6 +417,69 @@ async function maybeUploadStall ({
314
417
  : []
315
418
  let changed = false
316
419
 
420
+ // Helper to remove tags by key
421
+ const removeTags = (key) => {
422
+ let idx
423
+ while ((idx = tags.findIndex(t => Array.isArray(t) && t[0] === key)) !== -1) {
424
+ tags.splice(idx, 1)
425
+ changed = true
426
+ }
427
+ }
428
+
429
+ // Helper to remove 'l' tags with specific prefix
430
+ const removeLTags = (prefix) => {
431
+ let idx
432
+ while ((idx = tags.findIndex(t => Array.isArray(t) && t[0] === 'l' && t[1].startsWith(prefix))) !== -1) {
433
+ tags.splice(idx, 1)
434
+ changed = true
435
+ }
436
+ }
437
+
438
+ // Update self
439
+ if (self) {
440
+ removeTags('self')
441
+ tags.push(['self', self])
442
+ changed = true
443
+ }
444
+
445
+ // Update countries
446
+ if (countries) {
447
+ removeTags('country')
448
+ countries.forEach(c => tags.push(['country', c]))
449
+ changed = true
450
+ }
451
+
452
+ // Update categories
453
+ if (categories) {
454
+ removeLTags('napp.')
455
+ let count = 0
456
+ for (const [cat, subcats] of categories) {
457
+ if (count >= 3) break
458
+ if (Array.isArray(subcats)) {
459
+ for (const sub of subcats) {
460
+ if (count >= 3) break
461
+ if (NAPP_CATEGORIES[cat] && NAPP_CATEGORIES[cat].includes(sub)) {
462
+ tags.push(['l', `napp.${cat}:${sub}`])
463
+ count++
464
+ }
465
+ }
466
+ }
467
+ }
468
+ changed = true
469
+ }
470
+
471
+ // Update hashtags
472
+ if (hashtags) {
473
+ removeTags('t')
474
+ hashtags.slice(0, 3).forEach(([tag, label]) => {
475
+ const t = tag.replace(/\s/g, '').toLowerCase()
476
+ const row = ['t', t]
477
+ if (label) row.push(label)
478
+ tags.push(row)
479
+ })
480
+ changed = true
481
+ }
482
+
317
483
  const ensureTagValue = (key, updater) => {
318
484
  const index = tags.findIndex(tag => Array.isArray(tag) && tag[0] === key)
319
485
  if (index === -1) {
@@ -345,26 +511,43 @@ async function maybeUploadStall ({
345
511
  })
346
512
 
347
513
  const hasAuto = (field) => tags.some(tag => Array.isArray(tag) && tag[0] === 'auto' && tag[1] === field)
514
+ const removeAuto = (field) => {
515
+ const idx = tags.findIndex(tag => Array.isArray(tag) && tag[0] === 'auto' && tag[1] === field)
516
+ if (idx !== -1) {
517
+ tags.splice(idx, 1)
518
+ changed = true
519
+ }
520
+ }
348
521
 
349
- if (trimmedName && hasAuto('name')) {
350
- ensureTagValue('name', (existing) => {
351
- if (existing && existing[1] === trimmedName) return existing
352
- return ['name', trimmedName]
353
- })
522
+ if (trimmedName) {
523
+ if (!isNameAuto || hasAuto('name')) {
524
+ ensureTagValue('name', (_) => {
525
+ const row = ['name', trimmedName]
526
+ if (nameLang) row.push(nameLang)
527
+ return row
528
+ })
529
+ if (!isNameAuto) removeAuto('name')
530
+ }
354
531
  }
355
532
 
356
- if (trimmedSummary && hasAuto('summary')) {
357
- ensureTagValue('summary', (existing) => {
358
- if (existing && existing[1] === trimmedSummary) return existing
359
- return ['summary', trimmedSummary]
360
- })
533
+ if (trimmedSummary) {
534
+ if (!isSummaryAuto || hasAuto('summary')) {
535
+ ensureTagValue('summary', (_) => {
536
+ const row = ['summary', trimmedSummary]
537
+ if (summaryLang) row.push(summaryLang)
538
+ return row
539
+ })
540
+ if (!isSummaryAuto) removeAuto('summary')
541
+ }
361
542
  }
362
543
 
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
- })
544
+ if (iconRootHash && iconMimeType) {
545
+ if (!isIconAuto || hasAuto('icon')) {
546
+ ensureTagValue('icon', (_) => {
547
+ return ['icon', iconRootHash, iconMimeType]
548
+ })
549
+ if (!isIconAuto) removeAuto('icon')
550
+ }
368
551
  }
369
552
 
370
553
  if (!changed) return { pause }