nappup 1.9.1 → 2.0.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/src/index.js CHANGED
@@ -1,13 +1,13 @@
1
1
  import NMMR from 'nmmr'
2
- import { appEncode } from '#helpers/nip19.js'
3
- import nostrRelays, { nappRelays } from '#services/nostr-relays.js'
2
+ import { appEncode } from 'libp2r2p/nip19'
3
+ import nostrRelays from '#services/nostr-relays.js'
4
4
  import { getRelays } from '#helpers/signer.js'
5
5
  import { streamToChunks, streamToText } from '#helpers/stream.js'
6
6
  import { isNostrAppDTagSafe, GENERIC_BUILD_FOLDER_NAMES } from '#helpers/app.js'
7
7
  import { extractHtmlMetadata, findFavicon, findIndexFile } from '#helpers/app-metadata.js'
8
- import { NAPP_CATEGORIES } from '#config/napp-categories.js'
9
8
  import { getBlossomServers, healthCheckServers, uploadFilesToBlossom } from '#services/blossom-upload.js'
10
- import { uploadBinaryDataChunks, throttledSendEvent } from '#services/irfs-upload.js'
9
+ import { uploadBinaryDataChunks } from '#services/irfs-upload.js'
10
+ import { uploadSiteManifest } from '#services/site-manifest.js'
11
11
 
12
12
  // TL;DR
13
13
  // import publishApp from 'nappup'
@@ -47,7 +47,7 @@ export default async function (fileList, nostrSigner, opts = {}) {
47
47
  }
48
48
 
49
49
  /**
50
- * Publishes a site to Nostr relays and/or blossom servers.
50
+ * Publishes a site to Nostr relays and/or Blossom servers.
51
51
  *
52
52
  * The optional `onEvent` callback receives structured progress events.
53
53
  * Every event has `type` (string) and `progress` (0–100 integer).
@@ -56,27 +56,31 @@ export default async function (fileList, nostrSigner, opts = {}) {
56
56
  * 'init' — { totalFiles, totalSteps, dTag, relayCount, blossomCount }
57
57
  * 'media-uploaded' — { mediaType: 'icon'|'key_art'|'screenshot', service: 'blossom'|'irfs'|null }
58
58
  * 'file-uploaded' — { filename, service: 'blossom'|'irfs' }
59
- * 'listing-published' — app listing metadata published
60
- * 'manifest-published' — site manifest published
59
+ * 'manifest-published' unified site manifest and app metadata published
61
60
  * 'complete' — { napp } (terminal, progress === 100)
62
61
  * 'error' — { error } (terminal, error is rethrown)
63
- *
64
- * Terminal events ('complete' or 'error') signal that no more events will follow.
65
- * The 'error' event is only emitted when using the default export wrapper.
66
- * Direct `toApp` callers receive the thrown error via normal async/await.
67
62
  */
68
- export async function toApp (fileList, nostrSigner, { log = () => {}, onEvent = () => {}, dTag, channel = 'main', shouldReupload = false } = {}) {
69
- let _steps = 0
70
- let _totalSteps = 1
71
- const emit = (event) => { try { onEvent({ ...event, progress: event.type === 'complete' ? 100 : Math.round((_steps / _totalSteps) * 100) }) } catch (_) {} }
63
+ export async function toApp (fileList, nostrSigner, {
64
+ log = () => {}, onEvent = () => {}, dTag, channel = 'main', shouldReupload = false
65
+ } = {}) {
66
+ let steps = 0
67
+ let totalSteps = 1
68
+ const emit = (event) => {
69
+ try {
70
+ onEvent({
71
+ ...event,
72
+ progress: event.type === 'complete' ? 100 : Math.round((steps / totalSteps) * 100)
73
+ })
74
+ } catch (_) {}
75
+ }
72
76
  if (!nostrSigner && typeof window !== 'undefined') nostrSigner = window.nostr
73
77
  if (!nostrSigner) throw new Error('No Nostr signer found')
74
- if (typeof window !== 'undefined' && nostrSigner === window.nostr) {
75
- nostrSigner.getRelays = getRelays
76
- }
77
- const writeRelays = [...new Set((await nostrSigner.getRelays()).write.map(r => r.trim().replace(/\/$/, '')))]
78
+ if (typeof window !== 'undefined' && nostrSigner === window.nostr) nostrSigner.getRelays = getRelays
79
+
80
+ const writeRelays = [...new Set((await nostrSigner.getRelays()).write
81
+ .map(relay => relay.trim().replace(/\/$/, '')))]
78
82
  log(`Found ${writeRelays.length} outbox relays for pubkey ${await nostrSigner.getPublicKey()}:\n${writeRelays.join(', ')}`)
79
- if (writeRelays.length === 0) throw new Error('No outbox relays found')
83
+ if (!writeRelays.length) throw new Error('No outbox relays found')
80
84
 
81
85
  if (typeof dTag === 'string') {
82
86
  if (!isNostrAppDTagSafe(dTag)) throw new Error('dTag must be a non-empty string with at most 260 characters')
@@ -86,63 +90,57 @@ export async function toApp (fileList, nostrSigner, { log = () => {}, onEvent =
86
90
  throw new Error(`Folder name "${folderName}" is a generic build folder. Please provide a d tag with the -d flag.`)
87
91
  }
88
92
  dTag = folderName
89
- if (!isNostrAppDTagSafe(dTag)) throw new Error('Could not derive a valid d tag from the folder name. Please provide one with the -d flag.')
93
+ if (!isNostrAppDTagSafe(dTag)) {
94
+ throw new Error('Could not derive a valid d tag from the folder name. Please provide one with the -d flag.')
95
+ }
90
96
  }
91
- const fileMetadata = []
92
97
 
93
- // Check for .well-known/napp.json
94
- const nappJsonFile = fileList.find(f => f.webkitRelativePath.split('/').slice(1).join('/') === '.well-known/napp.json')
98
+ const fileMetadata = []
99
+ const nappJsonFile = fileList.find(file => file.webkitRelativePath.split('/').slice(1).join('/') === '.well-known/napp.json')
95
100
  let nappJson = {}
96
101
  if (nappJsonFile) {
97
102
  try {
98
- const text = await streamToText(nappJsonFile.stream())
99
- nappJson = JSON.parse(text)
100
- fileList = fileList.filter(f => f !== nappJsonFile)
101
- } catch (e) {
102
- log('Failed to parse .well-known/napp.json', e)
103
+ nappJson = JSON.parse(await streamToText(nappJsonFile.stream()))
104
+ fileList = fileList.filter(file => file !== nappJsonFile)
105
+ } catch (error) {
106
+ log('Failed to parse .well-known/napp.json', error)
103
107
  }
104
108
  }
105
109
 
106
110
  const indexFile = findIndexFile(fileList)
107
- let listingName = nappJson.name?.[0]?.[0]
108
- let listingSummary = nappJson.summary?.[0]?.[0]
109
-
110
- if (indexFile && (!listingName || !listingSummary)) {
111
+ let manifestName = nappJson.name?.[0]?.[0]
112
+ let manifestSummary = nappJson.summary?.[0]?.[0]
113
+ if (indexFile && (!manifestName || !manifestSummary)) {
111
114
  try {
112
- const htmlContent = await streamToText(indexFile.stream())
113
- const { name, description } = extractHtmlMetadata(htmlContent)
114
- if (!listingName) listingName = name
115
- if (!listingSummary) listingSummary = description
116
- } catch (err) {
117
- log('Error extracting HTML metadata:', err)
115
+ const { name, description } = extractHtmlMetadata(await streamToText(indexFile.stream()))
116
+ if (!manifestName) manifestName = name
117
+ if (!manifestSummary) manifestSummary = description
118
+ } catch (error) {
119
+ log('Error extracting HTML metadata:', error)
118
120
  }
119
121
  }
122
+
120
123
  const faviconFile = findFavicon(fileList)
121
124
  let iconMetadata
122
-
123
125
  let pause = 1000
124
126
 
125
- // Check for blossom servers
126
127
  log('Checking for blossom servers...')
127
128
  const blossomServerUrls = await getBlossomServers(nostrSigner, writeRelays)
128
129
  let healthyBlossomServers = []
129
- if (blossomServerUrls.length > 0) {
130
+ if (blossomServerUrls.length) {
130
131
  log(`Found ${blossomServerUrls.length} blossom servers: ${blossomServerUrls.join(', ')}`)
131
132
  healthyBlossomServers = await healthCheckServers(blossomServerUrls, nostrSigner, { log })
132
133
  log(`${healthyBlossomServers.length} of ${blossomServerUrls.length} blossom servers are healthy`)
133
134
  } else {
134
135
  log('No blossom servers configured, will use relay-based file upload (irfs)')
135
136
  }
137
+ const uploadService = healthyBlossomServers.length ? 'blossom' : 'irfs'
136
138
 
137
- const uploadService = healthyBlossomServers.length > 0 ? 'blossom' : 'irfs'
138
-
139
- // Helper: upload a data URL to the chosen service, returns { rootHash, mimeType }
140
139
  const uploadMediaFromDataUrl = async (dataUrl, mediaName) => {
141
- const res = await fetch(dataUrl)
142
- const blob = await res.blob()
143
- const mimeType = blob.type
144
- const extension = mimeType.split('/')[1] || 'bin'
145
- const filename = `${mediaName}.${extension}`
140
+ const response = await fetch(dataUrl)
141
+ const blob = await response.blob()
142
+ const mimeType = blob.type || 'application/octet-stream'
143
+ const filename = `${mediaName}.${mimeType.split('/')[1] || 'bin'}`
146
144
 
147
145
  if (uploadService === 'blossom') {
148
146
  const { uploadedFiles, failedFiles } = await uploadFilesToBlossom({
@@ -152,78 +150,82 @@ export async function toApp (fileList, nostrSigner, { log = () => {}, onEvent =
152
150
  shouldReupload,
153
151
  log
154
152
  })
155
- if (failedFiles.length > 0) throw new Error(`Blossom upload failed for ${mediaName}`)
156
- return { rootHash: uploadedFiles[0].sha256, mimeType }
153
+ if (failedFiles.length) throw new Error(`Blossom upload failed for ${mediaName}`)
154
+ return { rootHash: uploadedFiles[0].sha256, mimeType, size: blob.size }
157
155
  }
158
156
 
159
157
  const nmmr = new NMMR()
160
- const stream = blob.stream()
161
158
  let chunkLength = 0
162
- for await (const chunk of streamToChunks(stream, 51000)) {
159
+ for await (const chunk of streamToChunks(blob.stream(), 51000)) {
163
160
  chunkLength++
164
161
  await nmmr.append(chunk)
165
162
  }
166
163
  if (!chunkLength) return null
167
- ;({ pause } = (await uploadBinaryDataChunks({ nmmr, signer: nostrSigner, filename, chunkLength, log, pause, mimeType, shouldReupload })))
168
- return { rootHash: nmmr.getRoot(), mimeType }
164
+ ;({ pause } = await uploadBinaryDataChunks({
165
+ nmmr, signer: nostrSigner, filename, chunkLength, log, pause, shouldReupload
166
+ }))
167
+ return { rootHash: nmmr.getRoot(), mimeType, size: blob.size }
169
168
  }
170
169
 
171
- // Count media uploads for progress tracking
172
170
  const hasIconUpload = Boolean(nappJson.icon?.[0]?.[0])
173
- const keyArtEntries = nappJson.keyArt || []
174
- const screenshotEntries = nappJson.screenshot || []
175
- const mediaUploadCount = (hasIconUpload ? 1 : 0) + keyArtEntries.length + screenshotEntries.length
176
- _totalSteps = fileList.length + mediaUploadCount + 2
177
- emit({ type: 'init', totalFiles: fileList.length, totalSteps: _totalSteps, dTag, relayCount: writeRelays.length, blossomCount: healthyBlossomServers.length })
171
+ const keyArtEntries = Array.isArray(nappJson.keyArt) ? nappJson.keyArt : []
172
+ const screenshotEntries = Array.isArray(nappJson.screenshot) ? nappJson.screenshot : []
173
+ totalSteps = fileList.length + (hasIconUpload ? 1 : 0) + keyArtEntries.length + screenshotEntries.length + 1
174
+ emit({
175
+ type: 'init', totalFiles: fileList.length, totalSteps, dTag,
176
+ relayCount: writeRelays.length, blossomCount: healthyBlossomServers.length
177
+ })
178
178
 
179
- // Upload icon from napp.json if present
180
- if (nappJson.icon?.[0]?.[0]) {
179
+ if (hasIconUpload) {
181
180
  try {
182
181
  log('Uploading icon from napp.json')
183
182
  iconMetadata = await uploadMediaFromDataUrl(nappJson.icon[0][0], 'icon')
184
- } catch (e) {
185
- log('Failed to upload icon from napp.json', e)
183
+ } catch (error) {
184
+ log('Failed to upload icon from napp.json', error)
186
185
  }
187
- _steps++
186
+ steps++
188
187
  emit({ type: 'media-uploaded', mediaType: 'icon', service: iconMetadata ? uploadService : null })
189
188
  }
190
189
 
191
- // Upload key art from napp.json
192
190
  const keyArtMetadata = []
193
191
  for (const entry of keyArtEntries) {
194
- const dataUrl = entry[0]
195
- const country = entry[1]
196
- if (!dataUrl) { _steps++; emit({ type: 'media-uploaded', mediaType: 'key_art', service: null }); continue }
197
- try {
198
- log(`Uploading key art from napp.json${country ? ` (${country})` : ''}`)
199
- const uploaded = await uploadMediaFromDataUrl(dataUrl, 'key_art')
200
- if (uploaded) keyArtMetadata.push({ ...uploaded, country })
201
- } catch (e) {
202
- log('Failed to upload key art from napp.json', e)
192
+ const [dataUrl, country] = entry
193
+ if (dataUrl) {
194
+ try {
195
+ log(`Uploading key art from napp.json${country ? ` (${country})` : ''}`)
196
+ const uploaded = await uploadMediaFromDataUrl(dataUrl, 'key_art')
197
+ if (uploaded) keyArtMetadata.push({ ...uploaded, country })
198
+ } catch (error) {
199
+ log('Failed to upload key art from napp.json', error)
200
+ }
203
201
  }
204
- _steps++
205
- emit({ type: 'media-uploaded', mediaType: 'key_art', service: keyArtMetadata.length > 0 ? uploadService : null })
202
+ steps++
203
+ emit({
204
+ type: 'media-uploaded', mediaType: 'key_art',
205
+ service: dataUrl && keyArtMetadata.length ? uploadService : null
206
+ })
206
207
  }
207
208
 
208
- // Upload screenshots from napp.json
209
209
  const screenshotMetadata = []
210
210
  for (const entry of screenshotEntries) {
211
- const dataUrl = entry[0]
212
- const country = entry[1]
213
- if (!dataUrl) { _steps++; emit({ type: 'media-uploaded', mediaType: 'screenshot', service: null }); continue }
214
- try {
215
- log(`Uploading screenshot from napp.json${country ? ` (${country})` : ''}`)
216
- const uploaded = await uploadMediaFromDataUrl(dataUrl, 'screenshot')
217
- if (uploaded) screenshotMetadata.push({ ...uploaded, country })
218
- } catch (e) {
219
- log('Failed to upload screenshot from napp.json', e)
211
+ const [dataUrl, country] = entry
212
+ if (dataUrl) {
213
+ try {
214
+ log(`Uploading screenshot from napp.json${country ? ` (${country})` : ''}`)
215
+ const uploaded = await uploadMediaFromDataUrl(dataUrl, 'screenshot')
216
+ if (uploaded) screenshotMetadata.push({ ...uploaded, country })
217
+ } catch (error) {
218
+ log('Failed to upload screenshot from napp.json', error)
219
+ }
220
220
  }
221
- _steps++
222
- emit({ type: 'media-uploaded', mediaType: 'screenshot', service: screenshotMetadata.length > 0 ? uploadService : null })
221
+ steps++
222
+ emit({
223
+ type: 'media-uploaded', mediaType: 'screenshot',
224
+ service: dataUrl && screenshotMetadata.length ? uploadService : null
225
+ })
223
226
  }
224
227
 
225
228
  log(`Processing ${fileList.length} files`)
226
-
227
229
  if (uploadService === 'blossom') {
228
230
  const { uploadedFiles, failedFiles } = await uploadFilesToBlossom({
229
231
  fileList,
@@ -232,70 +234,62 @@ export async function toApp (fileList, nostrSigner, { log = () => {}, onEvent =
232
234
  shouldReupload,
233
235
  log
234
236
  })
235
-
236
- if (failedFiles.length > 0) {
237
- throw new Error(`${failedFiles.length} file(s) failed to upload to blossom`)
238
- }
237
+ if (failedFiles.length) throw new Error(`${failedFiles.length} file(s) failed to upload to blossom`)
239
238
 
240
239
  for (const uploaded of uploadedFiles) {
241
- fileMetadata.push({
240
+ const metadata = {
242
241
  rootHash: uploaded.sha256,
243
242
  filename: uploaded.filename,
244
- mimeType: uploaded.mimeType
245
- })
246
-
247
- if (faviconFile && uploaded.file === faviconFile) {
248
- iconMetadata = {
249
- rootHash: uploaded.sha256,
250
- mimeType: uploaded.mimeType
251
- }
243
+ mimeType: uploaded.mimeType,
244
+ size: uploaded.size
252
245
  }
253
-
254
- _steps++
246
+ fileMetadata.push(metadata)
247
+ if (faviconFile && uploaded.file === faviconFile) iconMetadata = { ...metadata }
248
+ steps++
255
249
  emit({ type: 'file-uploaded', filename: uploaded.filename, service: 'blossom' })
256
250
  }
257
251
  } else {
258
252
  for (const file of fileList) {
259
253
  const nmmr = new NMMR()
260
- const stream = file.stream()
261
-
262
254
  let chunkLength = 0
263
- for await (const chunk of streamToChunks(stream, 51000)) {
255
+ for await (const chunk of streamToChunks(file.stream(), 51000)) {
264
256
  chunkLength++
265
257
  await nmmr.append(chunk)
266
258
  }
267
- if (chunkLength) {
268
- // remove root dir
269
- const filename = file.webkitRelativePath.split('/').slice(1).join('/')
270
- log(`Uploading ${chunkLength} file parts of ${filename}`)
271
- ;({ pause } = (await uploadBinaryDataChunks({ nmmr, signer: nostrSigner, filename, chunkLength, log, pause, mimeType: file.type || 'application/octet-stream', shouldReupload })))
272
- fileMetadata.push({
273
- rootHash: nmmr.getRoot(),
274
- filename,
275
- mimeType: file.type || 'application/octet-stream'
276
- })
277
-
278
- if (faviconFile && file === faviconFile) {
279
- iconMetadata = {
280
- rootHash: nmmr.getRoot(),
281
- mimeType: file.type || 'application/octet-stream'
282
- }
283
- }
259
+ // Empty IRFS blobs deliberately have no chunks and no manifest reference.
260
+ if (!chunkLength) {
261
+ steps++
262
+ emit({ type: 'file-uploaded', filename: file.webkitRelativePath.split('/').slice(1).join('/'), service: 'irfs' })
263
+ continue
264
+ }
284
265
 
285
- _steps++
286
- emit({ type: 'file-uploaded', filename, service: 'irfs' })
266
+ const filename = file.webkitRelativePath.split('/').slice(1).join('/')
267
+ log(`Uploading ${chunkLength} file parts of ${filename}`)
268
+ ;({ pause } = await uploadBinaryDataChunks({
269
+ nmmr, signer: nostrSigner, filename, chunkLength, log, pause, shouldReupload
270
+ }))
271
+ const metadata = {
272
+ rootHash: nmmr.getRoot(),
273
+ filename,
274
+ mimeType: file.type || 'application/octet-stream',
275
+ size: file.size
287
276
  }
277
+ fileMetadata.push(metadata)
278
+ if (faviconFile && file === faviconFile) iconMetadata = { ...metadata }
279
+ steps++
280
+ emit({ type: 'file-uploaded', filename, service: 'irfs' })
288
281
  }
289
282
  }
290
283
 
291
- log(`Uploading app listing event for ${dTag}`)
292
- ;({ pause } = (await maybeUploadAppListing({
284
+ log(`Uploading unified site manifest ${dTag}`)
285
+ const manifest = await uploadSiteManifest({
293
286
  dTag,
294
287
  channel,
295
- name: listingName,
288
+ fileMetadata,
289
+ name: manifestName,
296
290
  nameLang: nappJson.name?.[0]?.[1],
297
291
  isNameAuto: !nappJson.name?.[0]?.[0],
298
- summary: listingSummary,
292
+ summary: manifestSummary,
299
293
  summaryLang: nappJson.summary?.[0]?.[1],
300
294
  isSummaryAuto: !nappJson.summary?.[0]?.[0],
301
295
  icon: iconMetadata,
@@ -305,7 +299,6 @@ export async function toApp (fileList, nostrSigner, { log = () => {}, onEvent =
305
299
  screenshots: screenshotMetadata,
306
300
  uploadService,
307
301
  signer: nostrSigner,
308
- writeRelays,
309
302
  log,
310
303
  pause,
311
304
  shouldReupload,
@@ -313,505 +306,16 @@ export async function toApp (fileList, nostrSigner, { log = () => {}, onEvent =
313
306
  countries: nappJson.country,
314
307
  categories: nappJson.category,
315
308
  hashtags: nappJson.hashtag
316
- })))
317
- _steps++
318
- emit({ type: 'listing-published' })
319
-
320
- log(`Uploading site manifest ${dTag}`)
321
- const manifest = await uploadSiteManifest({ dTag, channel, fileMetadata, uploadService, signer: nostrSigner, pause, shouldReupload, log })
309
+ })
322
310
 
323
311
  const appEntity = appEncode({
324
- dTag: manifest.tags.find(v => v[0] === 'd')[1],
312
+ dTag: manifest.tags.find(tag => tag[0] === 'd')[1],
325
313
  pubkey: manifest.pubkey,
326
314
  relays: [],
327
315
  kind: manifest.kind
328
316
  })
329
- _steps++
317
+ steps++
330
318
  emit({ type: 'manifest-published' })
331
-
332
319
  log(`Visit at https://44billion.net/${appEntity}`)
333
320
  emit({ type: 'complete', napp: appEntity })
334
321
  }
335
-
336
- async function uploadSiteManifest ({ dTag, channel, fileMetadata, uploadService, signer, pause = 0, shouldReupload = false, log = () => {} }) {
337
- const kind = {
338
- main: 35128, // stable
339
- next: 35129, // insider
340
- draft: 35130 // vibe coded preview
341
- }[channel] ?? 35128
342
-
343
- const pathTags = fileMetadata.map(v => ['path', v.filename, v.rootHash])
344
- const tags = [
345
- ['d', dTag],
346
- ...pathTags,
347
- ['service', uploadService]
348
- ]
349
-
350
- const writeRelays = [...new Set([...(await signer.getRelays()).write, ...nappRelays].map(r => r.trim().replace(/\/$/, '')))]
351
-
352
- let mostRecentEvent
353
- const events = (await nostrRelays.getEvents({
354
- kinds: [kind],
355
- authors: [await signer.getPublicKey()],
356
- '#d': [dTag],
357
- limit: 1
358
- }, writeRelays)).result
359
-
360
- if (events.length > 0) {
361
- events.sort((a, b) => {
362
- if (b.created_at !== a.created_at) return b.created_at - a.created_at
363
- if (a.id < b.id) return -1
364
- if (a.id > b.id) return 1
365
- return 0
366
- })
367
- mostRecentEvent = events[0]
368
- }
369
-
370
- if (!shouldReupload && mostRecentEvent) {
371
- const recentPathTags = mostRecentEvent.tags
372
- .filter(t => t[0] === 'path' && t[1] !== '.well-known/napp.json')
373
- .sort((a, b) => (a[1] < b[1] ? -1 : a[1] > b[1] ? 1 : 0))
374
-
375
- const currentPathTags = pathTags
376
- .filter(t => t[1] !== '.well-known/napp.json')
377
- .sort((a, b) => (a[1] < b[1] ? -1 : a[1] > b[1] ? 1 : 0))
378
-
379
- const recentServiceTag = mostRecentEvent.tags.find(t => t[0] === 'service')
380
- const serviceChanged = recentServiceTag?.[1] !== uploadService
381
-
382
- const isSame = !serviceChanged && currentPathTags.length === recentPathTags.length && currentPathTags.every((t, i) => {
383
- const rt = recentPathTags[i]
384
- return rt.length >= 3 && rt[1] === t[1] && rt[2] === t[2]
385
- })
386
-
387
- if (isSame) {
388
- log(`Site manifest based on ${pathTags.length} files is up-to-date (id: ${mostRecentEvent.id} - created_at: ${new Date(mostRecentEvent.created_at * 1000).toISOString()})`)
389
-
390
- const matchingEvents = events.filter(e => e.id === mostRecentEvent.id)
391
- const coveredRelays = new Set(matchingEvents.map(e => e.meta?.relay).filter(Boolean))
392
- const missingRelays = writeRelays.filter(r => !coveredRelays.has(r))
393
-
394
- if (missingRelays.length === 0) return mostRecentEvent
395
-
396
- log(`Re-uploading existing site manifest event to ${missingRelays.length} missing relays (out of ${writeRelays.length})`)
397
- await throttledSendEvent(mostRecentEvent, missingRelays, { pause, trailingPause: true, log, minSuccessfulRelays: 0 })
398
- return mostRecentEvent
399
- }
400
- }
401
-
402
- const createdAt = Math.floor(Date.now() / 1000)
403
- let effectiveCreatedAt = (mostRecentEvent && mostRecentEvent.created_at >= createdAt) ? mostRecentEvent.created_at + 1 : createdAt
404
- const maxCreatedAt = createdAt + 172800 // 2 days ahead
405
- if (effectiveCreatedAt > maxCreatedAt) effectiveCreatedAt = maxCreatedAt
406
-
407
- const siteManifest = {
408
- kind,
409
- tags,
410
- content: '',
411
- created_at: effectiveCreatedAt
412
- }
413
- const event = await signer.signEvent(siteManifest)
414
- await throttledSendEvent(event, writeRelays, { pause, trailingPause: true, log })
415
- return event
416
- }
417
-
418
- async function maybeUploadAppListing ({
419
- dTag,
420
- channel,
421
- name,
422
- nameLang,
423
- isNameAuto,
424
- summary,
425
- summaryLang,
426
- isSummaryAuto,
427
- icon,
428
- isIconAuto,
429
- descriptions,
430
- keyArt,
431
- screenshots,
432
- uploadService,
433
- signer,
434
- writeRelays,
435
- log,
436
- pause,
437
- shouldReupload,
438
- self,
439
- countries,
440
- categories,
441
- hashtags
442
- }) {
443
- const trimmedName = typeof name === 'string' ? name.trim() : ''
444
- const trimmedSummary = typeof summary === 'string' ? summary.trim() : ''
445
- const iconRootHash = icon?.rootHash
446
- const iconMimeType = icon?.mimeType
447
- const hasMetadata = Boolean(trimmedName) || Boolean(trimmedSummary) || Boolean(iconRootHash) ||
448
- Boolean(self) || (countries && countries.length > 0) || (categories && categories.length > 0) || (hashtags && hashtags.length > 0) ||
449
- (descriptions && descriptions.length > 0) || (keyArt && keyArt.length > 0) || (screenshots && screenshots.length > 0)
450
-
451
- const relays = [...new Set([...writeRelays, ...nappRelays].map(r => r.trim().replace(/\/$/, '')))]
452
-
453
- const previousResult = await getPreviousAppListing(dTag, relays, signer, channel)
454
- const previous = previousResult?.previous
455
- if (!previous && !hasMetadata) {
456
- if (shouldReupload) log('Skipping app listing event upload: No previous event found and no metadata provided.')
457
- return { pause }
458
- }
459
-
460
- const publishListing = async (event) => {
461
- const signedEvent = await signer.signEvent(event)
462
- return await throttledSendEvent(signedEvent, relays, { pause, log, trailingPause: true })
463
- }
464
-
465
- const createdAt = Math.floor(Date.now() / 1000)
466
- const kind = {
467
- main: 37348,
468
- next: 37349,
469
- draft: 37350
470
- }[channel] ?? 37348
471
-
472
- // Helper to push media-related tags (icon, key_art, screenshot, service)
473
- const pushMediaTags = (tags) => {
474
- let hasMedia = false
475
-
476
- if (iconRootHash && iconMimeType) {
477
- tags.push(['icon', iconRootHash, iconMimeType])
478
- if (isIconAuto) tags.push(['auto', 'icon'])
479
- hasMedia = true
480
- }
481
-
482
- if (keyArt && keyArt.length > 0) {
483
- for (const ka of keyArt) {
484
- const row = ['key_art', ka.rootHash, ka.mimeType]
485
- if (ka.country) row.push(ka.country)
486
- tags.push(row)
487
- }
488
- hasMedia = true
489
- }
490
-
491
- if (screenshots && screenshots.length > 0) {
492
- for (const ss of screenshots) {
493
- const row = ['screenshot', ss.rootHash, ss.mimeType]
494
- if (ss.country) row.push(ss.country)
495
- tags.push(row)
496
- }
497
- hasMedia = true
498
- }
499
-
500
- if (hasMedia) tags.push(['service', uploadService])
501
-
502
- return { hasIcon: Boolean(iconRootHash && iconMimeType) }
503
- }
504
-
505
- if (!previous) {
506
- const tags = [
507
- ['d', dTag]
508
- ]
509
-
510
- if (countries && countries.length > 0) {
511
- countries.forEach(c => tags.push(['c', c]))
512
- } else {
513
- tags.push(['c', '*'])
514
- }
515
-
516
- if (self) tags.push(['self', self])
517
-
518
- if (categories) {
519
- let count = 0
520
- for (const [cat, subcats] of categories) {
521
- if (count >= 3) break
522
- if (Array.isArray(subcats)) {
523
- for (const sub of subcats) {
524
- if (count >= 3) break
525
- if (NAPP_CATEGORIES[cat] && NAPP_CATEGORIES[cat].includes(sub)) {
526
- tags.push(['l', `napp.${cat}:${sub}`])
527
- count++
528
- }
529
- }
530
- }
531
- }
532
- }
533
-
534
- if (hashtags) {
535
- hashtags.slice(0, 3).forEach(([tag, label]) => {
536
- const t = tag.replace(/\s/g, '').toLowerCase()
537
- const row = ['t', t]
538
- if (label) row.push(label)
539
- tags.push(row)
540
- })
541
- }
542
-
543
- const { hasIcon } = pushMediaTags(tags)
544
-
545
- let hasName = false
546
- if (trimmedName) {
547
- hasName = true
548
- const row = ['name', trimmedName]
549
- if (nameLang) row.push(nameLang)
550
- tags.push(row)
551
- if (isNameAuto) tags.push(['auto', 'name'])
552
- }
553
-
554
- if (trimmedSummary) {
555
- const row = ['summary', trimmedSummary]
556
- if (summaryLang) row.push(summaryLang)
557
- tags.push(row)
558
- if (isSummaryAuto) tags.push(['auto', 'summary'])
559
- }
560
-
561
- if (descriptions) {
562
- for (const [text, lang] of descriptions) {
563
- if (text) {
564
- const row = ['description', text]
565
- if (lang) row.push(lang)
566
- tags.push(row)
567
- }
568
- }
569
- }
570
-
571
- if (!hasIcon || !hasName) {
572
- log(`Skipping app listing event creation: Missing required metadata.${!hasName ? ' Name is missing.' : ''}${!hasIcon ? ' Icon is missing.' : ''}`)
573
- return { pause }
574
- }
575
-
576
- return await publishListing({
577
- kind,
578
- tags,
579
- content: '',
580
- created_at: createdAt
581
- })
582
- }
583
-
584
- const tags = Array.isArray(previous.tags)
585
- ? previous.tags.map(tag => (Array.isArray(tag) ? [...tag] : tag))
586
- : []
587
- let changed = false
588
-
589
- // Helper to remove tags by key
590
- const removeTags = (key) => {
591
- let idx
592
- while ((idx = tags.findIndex(t => Array.isArray(t) && t[0] === key)) !== -1) {
593
- tags.splice(idx, 1)
594
- changed = true
595
- }
596
- }
597
-
598
- // Helper to remove 'l' tags with specific prefix
599
- const removeLTags = (prefix) => {
600
- let idx
601
- while ((idx = tags.findIndex(t => Array.isArray(t) && t[0] === 'l' && t[1].startsWith(prefix))) !== -1) {
602
- tags.splice(idx, 1)
603
- changed = true
604
- }
605
- }
606
-
607
- // Update self
608
- if (self) {
609
- removeTags('self')
610
- tags.push(['self', self])
611
- changed = true
612
- }
613
-
614
- // Update countries
615
- if (countries) {
616
- removeTags('c')
617
- if (countries.length === 0) {
618
- tags.push(['c', '*'])
619
- } else {
620
- countries.forEach(c => tags.push(['c', c]))
621
- }
622
- changed = true
623
- }
624
-
625
- // Update categories
626
- if (categories) {
627
- removeLTags('napp.')
628
- let count = 0
629
- for (const [cat, subcats] of categories) {
630
- if (count >= 3) break
631
- if (Array.isArray(subcats)) {
632
- for (const sub of subcats) {
633
- if (count >= 3) break
634
- if (NAPP_CATEGORIES[cat] && NAPP_CATEGORIES[cat].includes(sub)) {
635
- tags.push(['l', `napp.${cat}:${sub}`])
636
- count++
637
- }
638
- }
639
- }
640
- }
641
- changed = true
642
- }
643
-
644
- // Update hashtags
645
- if (hashtags) {
646
- removeTags('t')
647
- hashtags.slice(0, 3).forEach(([tag, label]) => {
648
- const t = tag.replace(/\s/g, '').toLowerCase()
649
- const row = ['t', t]
650
- if (label) row.push(label)
651
- tags.push(row)
652
- })
653
- changed = true
654
- }
655
-
656
- // Update descriptions
657
- if (descriptions) {
658
- removeTags('description')
659
- for (const [text, lang] of descriptions) {
660
- if (text) {
661
- const row = ['description', text]
662
- if (lang) row.push(lang)
663
- tags.push(row)
664
- }
665
- }
666
- changed = true
667
- }
668
-
669
- // Update key art
670
- if (keyArt && keyArt.length > 0) {
671
- removeTags('key_art')
672
- for (const ka of keyArt) {
673
- const row = ['key_art', ka.rootHash, ka.mimeType]
674
- if (ka.country) row.push(ka.country)
675
- tags.push(row)
676
- }
677
- changed = true
678
- }
679
-
680
- // Update screenshots
681
- if (screenshots && screenshots.length > 0) {
682
- removeTags('screenshot')
683
- for (const ss of screenshots) {
684
- const row = ['screenshot', ss.rootHash, ss.mimeType]
685
- if (ss.country) row.push(ss.country)
686
- tags.push(row)
687
- }
688
- changed = true
689
- }
690
-
691
- const ensureTagValue = (key, updater) => {
692
- const index = tags.findIndex(tag => Array.isArray(tag) && tag[0] === key)
693
- if (index === -1) {
694
- const next = updater(null)
695
- if (!next) return
696
- tags.push(next)
697
- changed = true
698
- return
699
- }
700
-
701
- const next = updater(tags[index])
702
- if (!next) return
703
- if (!tags[index] || tags[index].some((value, idx) => value !== next[idx])) {
704
- tags[index] = next
705
- changed = true
706
- }
707
- }
708
-
709
- ensureTagValue('d', (existing) => {
710
- if (existing && existing[1] === dTag) return existing
711
- return ['d', dTag]
712
- })
713
-
714
- if (!countries) {
715
- ensureTagValue('c', (existing) => {
716
- if (!existing) return ['c', '*']
717
- const currentValue = typeof existing[1] === 'string' ? existing[1].trim() : ''
718
- if (currentValue === '') return ['c', '*']
719
- return existing
720
- })
721
- }
722
-
723
- const hasAuto = (field) => tags.some(tag => Array.isArray(tag) && tag[0] === 'auto' && tag[1] === field)
724
- const removeAuto = (field) => {
725
- const idx = tags.findIndex(tag => Array.isArray(tag) && tag[0] === 'auto' && tag[1] === field)
726
- if (idx !== -1) {
727
- tags.splice(idx, 1)
728
- changed = true
729
- }
730
- }
731
-
732
- if (trimmedName) {
733
- if (!isNameAuto || hasAuto('name')) {
734
- ensureTagValue('name', (_) => {
735
- const row = ['name', trimmedName]
736
- if (nameLang) row.push(nameLang)
737
- return row
738
- })
739
- if (!isNameAuto) removeAuto('name')
740
- }
741
- }
742
-
743
- if (trimmedSummary) {
744
- if (!isSummaryAuto || hasAuto('summary')) {
745
- ensureTagValue('summary', (_) => {
746
- const row = ['summary', trimmedSummary]
747
- if (summaryLang) row.push(summaryLang)
748
- return row
749
- })
750
- if (!isSummaryAuto) removeAuto('summary')
751
- }
752
- }
753
-
754
- if (iconRootHash && iconMimeType) {
755
- if (!isIconAuto || hasAuto('icon')) {
756
- ensureTagValue('icon', (_) => {
757
- return ['icon', iconRootHash, iconMimeType]
758
- })
759
- if (!isIconAuto) removeAuto('icon')
760
- }
761
- }
762
-
763
- // Update service tag if any media exists
764
- const hasMedia = Boolean(iconRootHash) || (keyArt && keyArt.length > 0) || (screenshots && screenshots.length > 0)
765
- if (hasMedia) {
766
- ensureTagValue('service', () => ['service', uploadService])
767
- }
768
-
769
- if (!changed && !shouldReupload) {
770
- const { storedEvents } = previousResult
771
-
772
- const matchingEvents = storedEvents.filter(e => e.id === previous.id)
773
- const coveredRelays = new Set(matchingEvents.map(e => e.meta?.relay).filter(Boolean))
774
- const missingRelays = relays.filter(r => !coveredRelays.has(r))
775
-
776
- if (missingRelays.length === 0) return { pause }
777
-
778
- log(`Re-uploading existing app listing event to ${missingRelays.length} missing relays (out of ${relays.length})`)
779
- return await throttledSendEvent(previous, missingRelays, { pause, log, trailingPause: true, minSuccessfulRelays: 0 })
780
- }
781
-
782
- let effectiveCreatedAt = (previous && previous.created_at >= createdAt) ? previous.created_at + 1 : createdAt
783
- const maxCreatedAt = createdAt + 172800 // 2 days ahead
784
- if (effectiveCreatedAt > maxCreatedAt) effectiveCreatedAt = maxCreatedAt
785
-
786
- return await publishListing({
787
- kind,
788
- tags,
789
- content: typeof previous.content === 'string' ? previous.content : '',
790
- created_at: effectiveCreatedAt
791
- })
792
- }
793
-
794
- async function getPreviousAppListing (dTagValue, relays, signer, channel) {
795
- const kind = {
796
- main: 37348,
797
- next: 37349,
798
- draft: 37350
799
- }[channel] ?? 37348
800
-
801
- const storedEvents = (await nostrRelays.getEvents({
802
- kinds: [kind],
803
- authors: [await signer.getPublicKey()],
804
- '#d': [dTagValue],
805
- limit: 1
806
- }, relays)).result
807
-
808
- if (storedEvents.length === 0) return null
809
-
810
- storedEvents.sort((a, b) => b.created_at - a.created_at)
811
-
812
- return {
813
- previous: storedEvents[0],
814
- storedEvents,
815
- targetRelayCount: relays.length
816
- }
817
- }