/** * Extract objects from invisible metadata in Nostr notes * Objects are stored in [Metadata JSON] sections in the note content */ import type { Event } from 'nostr-tools' import { extractTagsFromEvent } from './nostrTagSystem' import { generateAuthorHashId, generateSeriesHashId, generatePublicationHashId, generateReviewHashId, generatePurchaseHashId, generateReviewTipHashId, generateSponsoringHashId } from './hashIdGenerator' export interface ExtractedAuthor { type: 'author' id: string pubkey: string authorName: string presentation: string contentDescription: string mainnetAddress?: string pictureUrl?: string category: string url?: string eventId: string } export interface ExtractedSeries { type: 'series' id: string pubkey: string title: string description: string preview?: string coverUrl?: string category: string eventId: string } export interface ExtractedPublication { type: 'publication' id: string pubkey: string title: string preview: string category: string seriesId?: string bannerUrl?: string zapAmount: number eventId: string } export interface ExtractedReview { type: 'review' id: string pubkey: string articleId: string reviewerPubkey: string content: string title?: string eventId: string } export interface ExtractedPurchase { type: 'purchase' id: string payerPubkey: string articleId: string authorPubkey: string amount: number paymentHash: string eventId: string } export interface ExtractedReviewTip { type: 'review_tip' id: string payerPubkey: string articleId: string reviewId: string reviewerPubkey: string authorPubkey: string amount: number paymentHash: string eventId: string } export interface ExtractedSponsoring { type: 'sponsoring' id: string payerPubkey: string authorPubkey: string seriesId?: string articleId?: string amount: number paymentHash: string eventId: string } export type ExtractedObject = | ExtractedAuthor | ExtractedSeries | ExtractedPublication | ExtractedReview | ExtractedPurchase | ExtractedReviewTip | ExtractedSponsoring /** * Extract JSON metadata from note content */ function extractMetadataJsonFromTag(event: { tags: string[][] }): Record | null { const jsonTag = event.tags.find((tag) => tag[0] === 'json') if (jsonTag?.[1]) { try { return JSON.parse(jsonTag[1]) } catch (e) { console.error('Error parsing JSON metadata from tag:', e) return null } } return null } function extractMetadataJson(content: string): Record | null { // Try invisible format first (with zero-width characters) - for backward compatibility const invisibleJsonMatch = content.match(/[\u200B\u200C]\[Metadata JSON\][\u200B\u200C]\n[\u200B\u200C](.+)[\u200B\u200C]$/s) if (invisibleJsonMatch?.[1]) { try { // Remove zero-width characters from JSON const cleanedJson = invisibleJsonMatch[1].replace(/[\u200B\u200C\u200D\u200E\u200F]/g, '').trim() return JSON.parse(cleanedJson) } catch (e) { console.error('Error parsing metadata JSON from invisible content:', e) } } // Fallback to visible format (for backward compatibility) const jsonMatch = content.match(/\[Metadata JSON\]\n(.+)$/s) if (jsonMatch?.[1]) { try { return JSON.parse(jsonMatch[1].trim()) } catch (e) { console.error('Error parsing metadata JSON from content:', e) return null } } return null } /** * Extract author from event */ export async function extractAuthorFromEvent(event: Event): Promise { const tags = extractTagsFromEvent(event) if (tags.type !== 'author') { return null } // Try to extract from tag first (new format) let metadata = extractMetadataJsonFromTag(event) // Fallback to content format (for backward compatibility) if (!metadata) { metadata = extractMetadataJson(event.content) } if (metadata?.type === 'author') { const authorData = { pubkey: (metadata.pubkey as string) ?? event.pubkey, authorName: (metadata.authorName as string) ?? '', presentation: (metadata.presentation as string) ?? '', contentDescription: (metadata.contentDescription as string) ?? '', mainnetAddress: metadata.mainnetAddress as string | undefined, pictureUrl: metadata.pictureUrl as string | undefined, category: (metadata.category as string) ?? tags.category ?? 'sciencefiction', } const id = await generateAuthorHashId(authorData) return { type: 'author', id, pubkey: authorData.pubkey, authorName: authorData.authorName, presentation: authorData.presentation, contentDescription: authorData.contentDescription, category: authorData.category, eventId: event.id, ...(authorData.mainnetAddress ? { mainnetAddress: authorData.mainnetAddress } : {}), ...(authorData.pictureUrl ? { pictureUrl: authorData.pictureUrl } : {}), ...(metadata.url ? { url: metadata.url as string } : {}), } } // Fallback: extract from tags and visible content // This is a simplified extraction - full data should be in metadata JSON return null } /** * Extract series from event */ export async function extractSeriesFromEvent(event: Event): Promise { const tags = extractTagsFromEvent(event) if (tags.type !== 'series') { return null } // Try to extract from tag first (new format) let metadata = extractMetadataJsonFromTag(event) // Fallback to content format (for backward compatibility) if (!metadata) { metadata = extractMetadataJson(event.content) } if (metadata?.type === 'series') { const seriesData = { pubkey: (metadata.pubkey as string) ?? event.pubkey, title: (metadata.title as string) ?? (tags.title as string) ?? '', description: (metadata.description as string) ?? '', preview: (metadata.preview as string) ?? (tags.preview as string) ?? event.content.substring(0, 200), coverUrl: (metadata.coverUrl as string) ?? (tags.coverUrl as string) ?? undefined, category: (metadata.category as string) ?? tags.category ?? 'sciencefiction', } const id = await generateSeriesHashId(seriesData) return { type: 'series', id, pubkey: seriesData.pubkey, title: seriesData.title, description: seriesData.description, category: seriesData.category, eventId: event.id, ...(seriesData.coverUrl ? { coverUrl: seriesData.coverUrl } : {}), ...(seriesData.preview ? { preview: seriesData.preview } : {}), } } // Fallback: extract from tags if (tags.title && tags.description) { const seriesData = { pubkey: event.pubkey, title: tags.title, description: tags.description, preview: (tags.preview as string) ?? event.content.substring(0, 200), coverUrl: tags.coverUrl, category: tags.category ?? 'sciencefiction', } const id = await generateSeriesHashId(seriesData) return { type: 'series', id, pubkey: seriesData.pubkey, title: seriesData.title, description: seriesData.description, category: seriesData.category, eventId: event.id, ...(seriesData.coverUrl ? { coverUrl: seriesData.coverUrl } : {}), ...(seriesData.preview ? { preview: seriesData.preview } : {}), } } return null } /** * Extract publication from event */ export async function extractPublicationFromEvent(event: Event): Promise { const tags = extractTagsFromEvent(event) if (tags.type !== 'publication') { return null } // Try to extract from tag first (new format) let metadata = extractMetadataJsonFromTag(event) // Fallback to content format (for backward compatibility) if (!metadata) { metadata = extractMetadataJson(event.content) } if (metadata?.type === 'publication') { const publicationData = { pubkey: (metadata.pubkey as string) ?? event.pubkey, title: (metadata.title as string) ?? (tags.title as string) ?? '', preview: (metadata.preview as string) ?? (tags.preview as string) ?? event.content.substring(0, 200), category: (metadata.category as string) ?? tags.category ?? 'sciencefiction', seriesId: (metadata.seriesId as string) ?? tags.seriesId ?? undefined, bannerUrl: (metadata.bannerUrl as string) ?? tags.bannerUrl ?? undefined, zapAmount: (metadata.zapAmount as number) ?? tags.zapAmount ?? 800, } const id = await generatePublicationHashId(publicationData) // Extract pages from metadata if present const pages = metadata.pages as Array<{ number: number; type: 'markdown' | 'image'; content: string }> | undefined return { type: 'publication', id, pubkey: publicationData.pubkey, title: publicationData.title, preview: publicationData.preview, category: publicationData.category, zapAmount: publicationData.zapAmount, eventId: event.id, ...(publicationData.seriesId ? { seriesId: publicationData.seriesId } : {}), ...(publicationData.bannerUrl ? { bannerUrl: publicationData.bannerUrl } : {}), ...(pages && pages.length > 0 ? { pages } : {}), } } // Fallback: extract from tags if (tags.title) { const publicationData = { pubkey: event.pubkey, title: tags.title, preview: (tags.preview as string) ?? event.content.substring(0, 200), category: tags.category ?? 'sciencefiction', seriesId: tags.seriesId, bannerUrl: tags.bannerUrl, zapAmount: tags.zapAmount ?? 800, } const id = await generatePublicationHashId(publicationData) return { type: 'publication', id, pubkey: publicationData.pubkey, title: publicationData.title, preview: publicationData.preview, category: publicationData.category, zapAmount: publicationData.zapAmount, eventId: event.id, ...(publicationData.seriesId ? { seriesId: publicationData.seriesId } : {}), ...(publicationData.bannerUrl ? { bannerUrl: publicationData.bannerUrl } : {}), } } return null } /** * Extract review from event */ export async function extractReviewFromEvent(event: Event): Promise { const tags = extractTagsFromEvent(event) if (tags.type !== 'quote') { return null } // Try to extract from tag first (new format) let metadata = extractMetadataJsonFromTag(event) // Fallback to content format (for backward compatibility) if (!metadata) { metadata = extractMetadataJson(event.content) } if (metadata?.type === 'review') { const reviewData = { pubkey: (metadata.pubkey as string) ?? event.pubkey, articleId: (metadata.articleId as string) ?? (tags.articleId as string) ?? '', reviewerPubkey: (metadata.reviewerPubkey as string) ?? (tags.reviewerPubkey as string) ?? event.pubkey, content: (metadata.content as string) ?? event.content, title: (metadata.title as string) ?? (tags.title as string) ?? undefined, } if (!reviewData.articleId || !reviewData.reviewerPubkey) { return null } const id = await generateReviewHashId(reviewData) return { type: 'review', id, ...reviewData, eventId: event.id, } } // Fallback: extract from tags if (tags.articleId && tags.reviewerPubkey) { const reviewData = { pubkey: event.pubkey, articleId: tags.articleId, reviewerPubkey: tags.reviewerPubkey, content: event.content, title: tags.title, } const id = await generateReviewHashId({ pubkey: reviewData.pubkey, articleId: reviewData.articleId, reviewerPubkey: reviewData.reviewerPubkey, content: reviewData.content, ...(reviewData.title ? { title: reviewData.title } : {}), }) return { type: 'review', id, pubkey: reviewData.pubkey, articleId: reviewData.articleId, reviewerPubkey: reviewData.reviewerPubkey, content: reviewData.content, eventId: event.id, ...(reviewData.title ? { title: reviewData.title } : {}), } } return null } /** * Extract purchase from zap receipt (kind 9735) */ export async function extractPurchaseFromEvent(event: Event): Promise { if (event.kind !== 9735) { return null } // Check for purchase kind_type tag const kindTypeTag = event.tags.find((tag) => tag[0] === 'kind_type' && tag[1] === 'purchase') if (!kindTypeTag) { return null } const pTag = event.tags.find((tag) => tag[0] === 'p')?.[1] const eTag = event.tags.find((tag) => tag[0] === 'e')?.[1] const amountTag = event.tags.find((tag) => tag[0] === 'amount')?.[1] const paymentHashTag = event.tags.find((tag) => tag[0] === 'payment_hash')?.[1] if (!pTag || !eTag || !amountTag) { return null } const amount = parseInt(amountTag, 10) / 1000 // Convert millisats to sats const paymentHash = paymentHashTag ?? event.id // Use event.id as fallback const purchaseData = { payerPubkey: event.pubkey, articleId: eTag, authorPubkey: pTag, amount, paymentHash, } const id = await generatePurchaseHashId(purchaseData) return { type: 'purchase', id, ...purchaseData, eventId: event.id, } } /** * Extract review tip from zap receipt (kind 9735) */ export async function extractReviewTipFromEvent(event: Event): Promise { if (event.kind !== 9735) { return null } const kindTypeTag = event.tags.find((tag) => tag[0] === 'kind_type' && tag[1] === 'review_tip') if (!kindTypeTag) { return null } const pTag = event.tags.find((tag) => tag[0] === 'p')?.[1] const eTag = event.tags.find((tag) => tag[0] === 'e')?.[1] const amountTag = event.tags.find((tag) => tag[0] === 'amount')?.[1] const reviewerTag = event.tags.find((tag) => tag[0] === 'reviewer')?.[1] const reviewIdTag = event.tags.find((tag) => tag[0] === 'review_id')?.[1] const paymentHashTag = event.tags.find((tag) => tag[0] === 'payment_hash')?.[1] if (!pTag || !eTag || !amountTag || !reviewerTag || !reviewIdTag) { return null } const amount = parseInt(amountTag, 10) / 1000 const paymentHash = paymentHashTag ?? event.id const tipData = { payerPubkey: event.pubkey, articleId: eTag, reviewId: reviewIdTag, reviewerPubkey: reviewerTag, authorPubkey: pTag, amount, paymentHash, } const id = await generateReviewTipHashId(tipData) return { type: 'review_tip', id, ...tipData, eventId: event.id, } } /** * Extract sponsoring from zap receipt (kind 9735) */ export async function extractSponsoringFromEvent(event: Event): Promise { if (event.kind !== 9735) { return null } const kindTypeTag = event.tags.find((tag) => tag[0] === 'kind_type' && tag[1] === 'sponsoring') if (!kindTypeTag) { return null } const pTag = event.tags.find((tag) => tag[0] === 'p')?.[1] const eTag = event.tags.find((tag) => tag[0] === 'e')?.[1] const amountTag = event.tags.find((tag) => tag[0] === 'amount')?.[1] const seriesTag = event.tags.find((tag) => tag[0] === 'series')?.[1] const articleTag = event.tags.find((tag) => tag[0] === 'article')?.[1] const paymentHashTag = event.tags.find((tag) => tag[0] === 'payment_hash')?.[1] if (!pTag || !amountTag) { return null } const amount = parseInt(amountTag, 10) / 1000 const paymentHash = paymentHashTag ?? event.id const sponsoringData = { payerPubkey: event.pubkey, authorPubkey: pTag, seriesId: seriesTag, articleId: articleTag ?? eTag, // Use eTag as fallback for articleId amount, paymentHash, } const id = await generateSponsoringHashId({ payerPubkey: sponsoringData.payerPubkey, authorPubkey: sponsoringData.authorPubkey, amount: sponsoringData.amount, paymentHash: sponsoringData.paymentHash, ...(sponsoringData.seriesId ? { seriesId: sponsoringData.seriesId } : {}), ...(sponsoringData.articleId ? { articleId: sponsoringData.articleId } : {}), }) return { type: 'sponsoring', id, payerPubkey: sponsoringData.payerPubkey, authorPubkey: sponsoringData.authorPubkey, amount: sponsoringData.amount, paymentHash: sponsoringData.paymentHash, eventId: event.id, ...(sponsoringData.seriesId ? { seriesId: sponsoringData.seriesId } : {}), ...(sponsoringData.articleId ? { articleId: sponsoringData.articleId } : {}), } } /** * Extract all objects from an event */ export async function extractObjectsFromEvent(event: Event): Promise { const results: ExtractedObject[] = [] // Try to extract each type const author = await extractAuthorFromEvent(event) if (author) {results.push(author)} const series = await extractSeriesFromEvent(event) if (series) {results.push(series)} const publication = await extractPublicationFromEvent(event) if (publication) {results.push(publication)} const review = await extractReviewFromEvent(event) if (review) {results.push(review)} const purchase = await extractPurchaseFromEvent(event) if (purchase) {results.push(purchase)} const reviewTip = await extractReviewTipFromEvent(event) if (reviewTip) {results.push(reviewTip)} const sponsoring = await extractSponsoringFromEvent(event) if (sponsoring) {results.push(sponsoring)} return results }