series building
This commit is contained in:
parent
4a619c9576
commit
758ab5c966
@ -31,7 +31,7 @@ function EmptyState({ hasAny }: { hasAny: boolean }) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-cyber-accent/70">
|
||||
{hasAny ? 'No articles match your search or filters.' : 'No articles found. Check back later!'}
|
||||
{hasAny ? t('common.empty.articles.filtered') : t('common.empty.articles')}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -29,7 +29,7 @@ function EmptyState({ hasAny }: { hasAny: boolean }) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-cyber-accent/70">
|
||||
{hasAny ? 'No authors match your search or filters.' : 'No authors found. Check back later!'}
|
||||
{hasAny ? t('common.empty.authors.filtered') : t('common.empty.authors')}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -116,7 +116,7 @@ function HomeContent({
|
||||
{shouldShowAuthors ? (
|
||||
<AuthorsList {...authorsListProps} />
|
||||
) : (
|
||||
<ArticlesList {...articlesListProps} />
|
||||
<ArticlesList {...articlesListProps} />
|
||||
)}
|
||||
|
||||
<HomeIntroSection />
|
||||
|
||||
@ -151,21 +151,21 @@ export function ImageUploadField({ id, label, value, onChange, helpText }: Image
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<label htmlFor={id} className="block text-sm font-medium text-neon-cyan">
|
||||
{displayLabel}
|
||||
</label>
|
||||
{value && <ImagePreview value={value} />}
|
||||
<ImageUploadControls
|
||||
id={id}
|
||||
uploading={uploading}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onFileSelect={handleFileSelect}
|
||||
/>
|
||||
{error && <p className="text-sm text-red-400">{error}</p>}
|
||||
{displayHelpText && <p className="text-sm text-cyber-accent">{displayHelpText}</p>}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label htmlFor={id} className="block text-sm font-medium text-neon-cyan">
|
||||
{displayLabel}
|
||||
</label>
|
||||
{value && <ImagePreview value={value} />}
|
||||
<ImageUploadControls
|
||||
id={id}
|
||||
uploading={uploading}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onFileSelect={handleFileSelect}
|
||||
/>
|
||||
{error && <p className="text-sm text-red-400">{error}</p>}
|
||||
{displayHelpText && <p className="text-sm text-cyber-accent">{displayHelpText}</p>}
|
||||
</div>
|
||||
{showUnlockModal && (
|
||||
<UnlockAccountModal
|
||||
onSuccess={handleUnlockSuccess}
|
||||
|
||||
@ -4,12 +4,34 @@ import { LanguageSelector } from './LanguageSelector'
|
||||
import { t } from '@/lib/i18n'
|
||||
import { KeyIndicator } from './KeyIndicator'
|
||||
|
||||
function GitIcon() {
|
||||
return (
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="M23.546 10.93L13.067.452c-.604-.603-1.582-.603-2.188 0L8.708 2.627l2.76 2.76c.645-.215 1.379-.07 1.889.441.516.515.658 1.258.438 1.9l2.658 2.66c.645-.223 1.387-.078 1.9.435.721.72.721 1.884 0 2.604-.719.719-1.881.719-2.6 0-.539-.541-.674-1.337-.404-1.996L12.86 8.955v6.525c.176.086.342.203.488.348.713.721.713 1.883 0 2.6-.719.721-1.884.721-2.599 0-.719-.719-.719-1.879 0-2.598.182-.18.387-.316.605-.406V8.835c-.217-.091-.424-.222-.6-.401-.545-.545-.676-1.342-.396-2.009L7.496 3.866.45 10.913c-.6.605-.6 1.584 0 2.189l10.484 10.481c.604.604 1.582.604 2.186 0l10.43-10.43c.605-.603.605-1.584 0-2.189z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function PageHeader() {
|
||||
return (
|
||||
<header className="bg-cyber-dark border-b border-neon-cyan/30 shadow-glow-cyan">
|
||||
<div className="max-w-4xl mx-auto px-4 py-4 flex justify-between items-center">
|
||||
<Link href="/" className="text-2xl font-bold text-neon-cyan text-glow-cyan font-mono hover:text-neon-green transition-colors flex items-center">
|
||||
<Link href="/" className="text-2xl font-bold text-neon-cyan text-glow-cyan font-mono hover:text-neon-green transition-colors flex items-center gap-2">
|
||||
{t('home.title')}
|
||||
<a
|
||||
href="https://git.4nkweb.com/4nk/story-research-zapwall"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-cyber-accent hover:text-neon-cyan transition-colors"
|
||||
title="Repository Git"
|
||||
>
|
||||
<GitIcon />
|
||||
</a>
|
||||
<KeyIndicator />
|
||||
</Link>
|
||||
<div className="flex items-center gap-4">
|
||||
|
||||
@ -2,12 +2,34 @@ import { ConnectButton } from '@/components/ConnectButton'
|
||||
import { ConditionalPublishButton } from './ConditionalPublishButton'
|
||||
import { KeyIndicator } from './KeyIndicator'
|
||||
|
||||
function GitIcon() {
|
||||
return (
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="M23.546 10.93L13.067.452c-.604-.603-1.582-.603-2.188 0L8.708 2.627l2.76 2.76c.645-.215 1.379-.07 1.889.441.516.515.658 1.258.438 1.9l2.658 2.66c.645-.223 1.387-.078 1.9.435.721.72.721 1.884 0 2.604-.719.719-1.881.719-2.6 0-.539-.541-.674-1.337-.404-1.996L12.86 8.955v6.525c.176.086.342.203.488.348.713.721.713 1.883 0 2.6-.719.721-1.884.721-2.599 0-.719-.719-.719-1.879 0-2.598.182-.18.387-.316.605-.406V8.835c-.217-.091-.424-.222-.6-.401-.545-.545-.676-1.342-.396-2.009L7.496 3.866.45 10.913c-.6.605-.6 1.584 0 2.189l10.484 10.481c.604.604 1.582.604 2.186 0l10.43-10.43c.605-.603.605-1.584 0-2.189z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function ProfileHeader() {
|
||||
return (
|
||||
<header className="bg-white shadow-sm">
|
||||
<div className="max-w-4xl mx-auto px-4 py-4 flex justify-between items-center">
|
||||
<h1 className="text-2xl font-bold text-gray-900 flex items-center">
|
||||
<h1 className="text-2xl font-bold text-gray-900 flex items-center gap-2">
|
||||
zapwall.fr
|
||||
<a
|
||||
href="https://git.4nkweb.com/4nk/story-research-zapwall"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-gray-600 hover:text-gray-900 transition-colors"
|
||||
title="Repository Git"
|
||||
>
|
||||
<GitIcon />
|
||||
</a>
|
||||
<KeyIndicator />
|
||||
</h1>
|
||||
<div className="flex items-center gap-4">
|
||||
|
||||
169
docs/tag-system-explanation.md
Normal file
169
docs/tag-system-explanation.md
Normal file
@ -0,0 +1,169 @@
|
||||
# Système de tags zapwall.fr
|
||||
|
||||
## Vue d'ensemble
|
||||
|
||||
Le système de tags de zapwall.fr utilise un système de tags personnalisé basé sur les tags standards de Nostr (kind 1 notes). Tous les événements sont des notes Nostr (kind 1), et le système utilise des tags en anglais pour identifier le type de contenu, la catégorie, et les métadonnées associées.
|
||||
|
||||
## Structure des tags
|
||||
|
||||
### Tags de base (tous les types)
|
||||
|
||||
Tous les événements incluent ces tags de base :
|
||||
|
||||
- **Type** : Tag simple (sans valeur) qui identifie le type de contenu
|
||||
- `#author` : Présentation d'auteur
|
||||
- `#series` : Série d'articles
|
||||
- `#publication` : Article/publication
|
||||
- `#quote` : Avis/review
|
||||
|
||||
- **Catégorie** : Tag simple qui identifie la catégorie
|
||||
- `#sciencefiction` : Science-fiction
|
||||
- `#research` : Recherche scientifique
|
||||
|
||||
- **Identifiant** : Tag avec valeur pour l'ID unique
|
||||
- `["id", "<event_id>"]` : Identifiant unique de l'événement
|
||||
|
||||
- **Service** : Tag avec valeur pour identifier la plateforme
|
||||
- `["service", "zapwall.fr"]` : Identifiant du service (toujours présent pour filtrer toutes les notes de zapwall.fr)
|
||||
|
||||
- **Paywall** : Tag simple (optionnel)
|
||||
- `#paywall` : Indique que le contenu est payant
|
||||
|
||||
- **Payment** : Tag simple (optionnel)
|
||||
- `#payment` : Indique qu'un paiement a été effectué
|
||||
|
||||
### Tags spécifiques par type
|
||||
|
||||
#### Tags pour `#author` (présentation d'auteur)
|
||||
|
||||
```typescript
|
||||
["author"] // Type
|
||||
["sciencefiction"] ou ["research"] // Catégorie
|
||||
["id", "<event_id>"] // ID unique
|
||||
["title", "<titre>"] // Titre de la présentation
|
||||
["preview", "<aperçu>"] // Aperçu (optionnel)
|
||||
["mainnet_address", "<adresse>"] // Adresse Bitcoin mainnet pour le sponsoring
|
||||
["total_sponsoring", "<montant>"] // Total du sponsoring reçu (en sats)
|
||||
["picture", "<url>"] // URL de la photo de profil (optionnel)
|
||||
```
|
||||
|
||||
**Exemple de tags pour une présentation d'auteur :**
|
||||
```
|
||||
[
|
||||
["author"],
|
||||
["sciencefiction"],
|
||||
["id", "abc123..."],
|
||||
["service", "zapwall.fr"],
|
||||
["title", "Présentation de John Doe"],
|
||||
["preview", "Aperçu de la présentation..."],
|
||||
["mainnet_address", "1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa"],
|
||||
["total_sponsoring", "0"],
|
||||
["picture", "https://cdn.nostrcheck.me/..."]
|
||||
]
|
||||
```
|
||||
|
||||
#### Tags pour `#publication` (article)
|
||||
|
||||
```typescript
|
||||
["publication"] // Type
|
||||
["sciencefiction"] ou ["research"] // Catégorie
|
||||
["id", "<event_id>"] // ID unique
|
||||
["title", "<titre>"] // Titre de l'article
|
||||
["preview", "<aperçu>"] // Aperçu (optionnel)
|
||||
["series", "<series_id>"] // ID de la série (optionnel)
|
||||
["banner", "<url>"] // URL de la bannière (optionnel)
|
||||
["zap", "<montant>"] // Montant en sats pour débloquer
|
||||
["invoice", "<bolt11>"] // Facture BOLT11 (optionnel)
|
||||
["payment_hash", "<hash>"] // Hash du paiement (optionnel)
|
||||
["encrypted_key", "<key>"] // Clé de chiffrement (optionnel)
|
||||
```
|
||||
|
||||
#### Tags pour `#series` (série)
|
||||
|
||||
```typescript
|
||||
["series"] // Type
|
||||
["sciencefiction"] ou ["research"] // Catégorie
|
||||
["id", "<event_id>"] // ID unique
|
||||
["title", "<titre>"] // Titre de la série
|
||||
["description", "<description>"] // Description de la série
|
||||
["preview", "<aperçu>"] // Aperçu (optionnel)
|
||||
["cover", "<url>"] // URL de la couverture (optionnel)
|
||||
```
|
||||
|
||||
#### Tags pour `#quote` (avis/review)
|
||||
|
||||
```typescript
|
||||
["quote"] // Type
|
||||
["article", "<article_id>"] // ID de l'article commenté
|
||||
["reviewer", "<pubkey>"] // Clé publique du reviewer (optionnel)
|
||||
["title", "<titre>"] // Titre de l'avis (optionnel)
|
||||
```
|
||||
|
||||
## Filtrage et requêtes
|
||||
|
||||
Le système utilise `buildTagFilter` pour construire des filtres de requête Nostr :
|
||||
|
||||
```typescript
|
||||
// Exemple : récupérer toutes les présentations d'auteurs de zapwall.fr
|
||||
buildTagFilter({
|
||||
type: 'author',
|
||||
category: 'sciencefiction',
|
||||
service: 'zapwall.fr'
|
||||
})
|
||||
// Résultat : { kinds: [1], "#author": [""], "#sciencefiction": [""], "#service": ["zapwall.fr"] }
|
||||
|
||||
// Exemple : récupérer une présentation spécifique
|
||||
buildTagFilter({
|
||||
type: 'author',
|
||||
authorPubkey: 'abc123...',
|
||||
service: 'zapwall.fr'
|
||||
})
|
||||
// Résultat : { kinds: [1], "#author": [""], "#service": ["zapwall.fr"], authors: ["abc123..."] }
|
||||
|
||||
// Exemple : récupérer toutes les notes de zapwall.fr
|
||||
buildTagFilter({
|
||||
service: 'zapwall.fr'
|
||||
})
|
||||
// Résultat : { kinds: [1], "#service": ["zapwall.fr"] }
|
||||
```
|
||||
|
||||
## Extraction des tags
|
||||
|
||||
Le système utilise `extractTagsFromEvent` pour extraire les tags d'un événement :
|
||||
|
||||
```typescript
|
||||
const tags = extractTagsFromEvent(event)
|
||||
// Retourne un objet avec :
|
||||
// - type: 'author' | 'series' | 'publication' | 'quote'
|
||||
// - category: 'sciencefiction' | 'research'
|
||||
// - id: string
|
||||
// - paywall: boolean
|
||||
// - payment: boolean
|
||||
// - title, preview, mainnetAddress, etc. selon le type
|
||||
```
|
||||
|
||||
## Tag service
|
||||
|
||||
Le tag `["service", "zapwall.fr"]` est utilisé pour identifier toutes les notes publiées par la plateforme zapwall.fr. Ce tag permet de :
|
||||
|
||||
- **Filtrer toutes les notes de la plateforme** : `buildTagFilter({ service: 'zapwall.fr' })`
|
||||
- **Distinguer les notes zapwall.fr** des autres notes Nostr sur le réseau
|
||||
- **Améliorer les performances** en filtrant dès la source lors des requêtes
|
||||
|
||||
**Note** : Aucun NIP (Nostr Improvement Proposal) ne spécifie actuellement un tag standardisé pour identifier un service/plateforme. Le tag `service` est donc une convention interne à zapwall.fr. Si un NIP standardisé émerge à l'avenir, le système pourra être adapté en conséquence.
|
||||
|
||||
## Avantages du système
|
||||
|
||||
1. **Standardisé** : Tous les événements sont des notes Nostr (kind 1), compatibles avec tous les clients Nostr
|
||||
2. **Filtrable** : Les tags permettent de filtrer efficacement les événements par type, catégorie et service
|
||||
3. **Extensible** : Facile d'ajouter de nouveaux types ou catégories
|
||||
4. **Interopérable** : Les tags sont lisibles par n'importe quel client Nostr, même s'il ne comprend pas la structure complète
|
||||
5. **Identifiable** : Le tag `service` permet de distinguer les notes zapwall.fr des autres notes Nostr
|
||||
|
||||
## Détection des auteurs
|
||||
|
||||
Les auteurs sont détectés via le tag `#author` dans les événements. Le système souscrit aux événements avec :
|
||||
- `#author` : Pour identifier les présentations d'auteurs
|
||||
- `#publication` : Pour identifier les articles
|
||||
|
||||
Cela permet de distinguer les auteurs (présentations) des articles (publications) dans le même flux d'événements.
|
||||
@ -3,6 +3,7 @@ import { nostrService } from '@/lib/nostr'
|
||||
import type { Article } from '@/types/nostr'
|
||||
import { applyFiltersAndSort } from '@/lib/articleFiltering'
|
||||
import type { ArticleFilters } from '@/components/ArticleFilters'
|
||||
import { t } from '@/lib/i18n'
|
||||
|
||||
export function useArticles(searchQuery: string = '', filters: ArticleFilters | null = null) {
|
||||
const [articles, setArticles] = useState<Article[]>([])
|
||||
@ -32,7 +33,7 @@ export function useArticles(searchQuery: string = '', filters: ArticleFilters |
|
||||
const timeout = setTimeout(() => {
|
||||
setLoading(false)
|
||||
if (!hasArticlesRef.current) {
|
||||
setError('No articles found')
|
||||
setError(t('common.error.noContent'))
|
||||
}
|
||||
}, 10000)
|
||||
|
||||
|
||||
106
lib/accessControl.ts
Normal file
106
lib/accessControl.ts
Normal file
@ -0,0 +1,106 @@
|
||||
/**
|
||||
* Access control rules for objects
|
||||
*
|
||||
* Rules:
|
||||
* - Only the author (pubkey) who published the original note can modify or delete it
|
||||
* - All users have read access to public content (previews, metadata)
|
||||
* - For paid content, users must have paid (via zap receipt) to access the full content
|
||||
* - Payment verification follows the transaction rules (zap receipt validation)
|
||||
*/
|
||||
|
||||
import type { Event } from 'nostr-tools'
|
||||
import { extractTagsFromEvent } from './nostrTagSystem'
|
||||
import { canModifyObject } from './versionManager'
|
||||
|
||||
/**
|
||||
* Check if a user can modify an object
|
||||
* Only the author (pubkey) who published the original note can modify it
|
||||
*/
|
||||
export function canUserModify(event: Event, userPubkey: string): boolean {
|
||||
return canModifyObject(event, userPubkey)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a user can delete an object
|
||||
* Only the author (pubkey) who published the original note can delete it
|
||||
*/
|
||||
export function canUserDelete(event: Event, userPubkey: string): boolean {
|
||||
return canModifyObject(event, userPubkey)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a user can read an object
|
||||
* All users can read public content (previews, metadata)
|
||||
* For paid content, users must have paid (via zap receipt) to access full content
|
||||
*/
|
||||
export function canUserRead(event: Event, userPubkey: string | null, hasPaid: boolean = false): {
|
||||
canReadPreview: boolean
|
||||
canReadFullContent: boolean
|
||||
} {
|
||||
const tags = extractTagsFromEvent(event)
|
||||
|
||||
// Preview is always readable (public)
|
||||
const canReadPreview = true
|
||||
|
||||
// Full content access depends on paywall status
|
||||
if (tags.paywall) {
|
||||
// Paid content: user must have paid
|
||||
const canReadFullContent = hasPaid
|
||||
return { canReadPreview, canReadFullContent }
|
||||
}
|
||||
|
||||
// Free content: everyone can read
|
||||
return { canReadPreview, canReadFullContent: true }
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if content is paid (has paywall tag)
|
||||
*/
|
||||
export function isPaidContent(event: Event): boolean {
|
||||
const tags = extractTagsFromEvent(event)
|
||||
return tags.paywall === true
|
||||
}
|
||||
|
||||
/**
|
||||
* Access control result
|
||||
*/
|
||||
export interface AccessControlResult {
|
||||
canModify: boolean
|
||||
canDelete: boolean
|
||||
canReadPreview: boolean
|
||||
canReadFullContent: boolean
|
||||
isPaid: boolean
|
||||
reason?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Get complete access control information for an object
|
||||
*/
|
||||
export function getAccessControl(
|
||||
event: Event,
|
||||
userPubkey: string | null,
|
||||
hasPaid: boolean = false
|
||||
): AccessControlResult {
|
||||
const canModify = userPubkey ? canUserModify(event, userPubkey) : false
|
||||
const canDelete = userPubkey ? canUserDelete(event, userPubkey) : false
|
||||
const { canReadPreview, canReadFullContent } = canUserRead(event, userPubkey, hasPaid)
|
||||
const isPaid = isPaidContent(event)
|
||||
|
||||
let reason: string | undefined
|
||||
if (isPaid && !canReadFullContent) {
|
||||
reason = 'Payment required to access full content'
|
||||
} else if (!canModify && userPubkey) {
|
||||
reason = 'Only the author can modify this object'
|
||||
} else if (!canDelete && userPubkey) {
|
||||
reason = 'Only the author can delete this object'
|
||||
}
|
||||
|
||||
return {
|
||||
canModify,
|
||||
canDelete,
|
||||
canReadPreview,
|
||||
canReadFullContent,
|
||||
isPaid,
|
||||
reason,
|
||||
}
|
||||
}
|
||||
@ -1,6 +1,8 @@
|
||||
import { getAlbyService } from './alby'
|
||||
import { calculateArticleSplit, PLATFORM_COMMISSIONS } from './platformCommissions'
|
||||
import { buildTags } from './nostrTagSystem'
|
||||
import { PLATFORM_SERVICE } from './platformConfig'
|
||||
import { generatePublicationHashId } from './hashIdGenerator'
|
||||
import type { AlbyInvoice } from '@/types/alby'
|
||||
import type { ArticleDraft } from './articlePublisher'
|
||||
|
||||
@ -42,20 +44,21 @@ export async function createArticleInvoice(draft: ArticleDraft): Promise<AlbyInv
|
||||
* Create preview event with invoice tags
|
||||
* If encryptedContent is provided, it will be used instead of preview
|
||||
*/
|
||||
export function createPreviewEvent(
|
||||
export async function createPreviewEvent(
|
||||
draft: ArticleDraft,
|
||||
invoice: AlbyInvoice,
|
||||
authorPubkey: string,
|
||||
authorPresentationId?: string,
|
||||
extraTags: string[][] = [],
|
||||
encryptedContent?: string,
|
||||
encryptedKey?: string
|
||||
): {
|
||||
): Promise<{
|
||||
kind: 1
|
||||
created_at: number
|
||||
tags: string[][]
|
||||
content: string
|
||||
} {
|
||||
const tags = buildPreviewTags(draft, invoice, authorPresentationId, extraTags, encryptedKey)
|
||||
}> {
|
||||
const tags = await buildPreviewTags(draft, invoice, authorPubkey, authorPresentationId, extraTags, encryptedKey)
|
||||
|
||||
return {
|
||||
kind: 1 as const,
|
||||
@ -65,21 +68,36 @@ export function createPreviewEvent(
|
||||
}
|
||||
}
|
||||
|
||||
function buildPreviewTags(
|
||||
async function buildPreviewTags(
|
||||
draft: ArticleDraft,
|
||||
invoice: AlbyInvoice,
|
||||
authorPubkey: string,
|
||||
_authorPresentationId?: string,
|
||||
extraTags: string[][] = [],
|
||||
encryptedKey?: string
|
||||
): string[][] {
|
||||
): Promise<string[][]> {
|
||||
// Map category to new system
|
||||
const category = draft.category === 'science-fiction' ? 'sciencefiction' : draft.category === 'scientific-research' ? 'research' : 'sciencefiction'
|
||||
|
||||
// Generate hash ID from publication data
|
||||
const hashId = await generatePublicationHashId({
|
||||
pubkey: authorPubkey,
|
||||
title: draft.title,
|
||||
preview: draft.preview,
|
||||
category,
|
||||
seriesId: draft.seriesId ?? undefined,
|
||||
bannerUrl: draft.bannerUrl ?? undefined,
|
||||
zapAmount: draft.zapAmount,
|
||||
})
|
||||
|
||||
// Build tags using new system
|
||||
const newTags = buildTags({
|
||||
type: 'publication',
|
||||
category,
|
||||
id: '', // Will be set to event.id after publication
|
||||
id: hashId,
|
||||
service: PLATFORM_SERVICE,
|
||||
version: 0, // New object
|
||||
hidden: false,
|
||||
paywall: true, // Publications are paid
|
||||
title: draft.title,
|
||||
preview: draft.preview,
|
||||
|
||||
@ -2,6 +2,8 @@ import { nostrService } from './nostr'
|
||||
import { createArticleInvoice, createPreviewEvent } from './articleInvoice'
|
||||
import { storePrivateContent, getStoredPrivateContent } from './articleStorage'
|
||||
import { buildTags } from './nostrTagSystem'
|
||||
import { PLATFORM_SERVICE } from './platformConfig'
|
||||
import { generateSeriesHashId, generatePublicationHashId } from './hashIdGenerator'
|
||||
import type { ArticleDraft, PublishedArticle } from './articlePublisher'
|
||||
import type { AlbyInvoice } from '@/types/alby'
|
||||
import type { Review, Series } from '@/types/nostr'
|
||||
@ -36,10 +38,11 @@ async function ensurePresentation(authorPubkey: string): Promise<string> {
|
||||
async function publishPreviewWithInvoice(
|
||||
draft: ArticleDraft,
|
||||
invoice: AlbyInvoice,
|
||||
authorPubkey: string,
|
||||
presentationId: string,
|
||||
extraTags?: string[][]
|
||||
): Promise<import('nostr-tools').Event | null> {
|
||||
const previewEvent = createPreviewEvent(draft, invoice, presentationId, extraTags)
|
||||
const previewEvent = await createPreviewEvent(draft, invoice, authorPubkey, presentationId, extraTags)
|
||||
const publishedEvent = await nostrService.publishEvent(previewEvent)
|
||||
return publishedEvent ?? null
|
||||
}
|
||||
@ -56,7 +59,7 @@ export async function publishSeries(params: {
|
||||
ensureKeys(params.authorPubkey, params.authorPrivateKey)
|
||||
const category = params.category
|
||||
requireCategory(category)
|
||||
const event = buildSeriesEvent(params, category)
|
||||
const event = await buildSeriesEvent(params, category)
|
||||
const published = await nostrService.publishEvent(event)
|
||||
if (!published) {
|
||||
throw new Error('Failed to publish series')
|
||||
@ -73,7 +76,7 @@ export async function publishSeries(params: {
|
||||
}
|
||||
}
|
||||
|
||||
function buildSeriesEvent(
|
||||
async function buildSeriesEvent(
|
||||
params: {
|
||||
title: string
|
||||
description: string
|
||||
@ -86,6 +89,15 @@ function buildSeriesEvent(
|
||||
// Map category to new system
|
||||
const newCategory = category === 'science-fiction' ? 'sciencefiction' : 'research'
|
||||
|
||||
// Generate hash ID from series data
|
||||
const hashId = await generateSeriesHashId({
|
||||
pubkey: params.authorPubkey,
|
||||
title: params.title,
|
||||
description: params.description,
|
||||
category: newCategory,
|
||||
coverUrl: params.coverUrl ?? undefined,
|
||||
})
|
||||
|
||||
return {
|
||||
kind: 1,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
@ -93,7 +105,10 @@ function buildSeriesEvent(
|
||||
tags: buildTags({
|
||||
type: 'series',
|
||||
category: newCategory,
|
||||
id: '', // Will be set to event.id after publication
|
||||
id: hashId,
|
||||
service: PLATFORM_SERVICE,
|
||||
version: 0, // New object
|
||||
hidden: false,
|
||||
paywall: false,
|
||||
title: params.title,
|
||||
description: params.description,
|
||||
@ -116,7 +131,7 @@ export async function publishReview(params: {
|
||||
ensureKeys(params.reviewerPubkey, params.authorPrivateKey)
|
||||
const category = params.category
|
||||
requireCategory(category)
|
||||
const event = buildReviewEvent(params, category)
|
||||
const event = await buildReviewEvent(params, category)
|
||||
const published = await nostrService.publishEvent(event)
|
||||
if (!published) {
|
||||
throw new Error('Failed to publish review')
|
||||
@ -133,7 +148,7 @@ export async function publishReview(params: {
|
||||
}
|
||||
}
|
||||
|
||||
function buildReviewEvent(
|
||||
async function buildReviewEvent(
|
||||
params: {
|
||||
articleId: string
|
||||
seriesId: string
|
||||
@ -147,6 +162,16 @@ function buildReviewEvent(
|
||||
// Map category to new system
|
||||
const newCategory = category === 'science-fiction' ? 'sciencefiction' : 'research'
|
||||
|
||||
// Generate hash ID from review data
|
||||
const { generateReviewHashId } = await import('./hashIdGenerator')
|
||||
const hashId = await generateReviewHashId({
|
||||
pubkey: params.reviewerPubkey,
|
||||
articleId: params.articleId,
|
||||
reviewerPubkey: params.reviewerPubkey,
|
||||
content: params.content,
|
||||
title: params.title ?? undefined,
|
||||
})
|
||||
|
||||
return {
|
||||
kind: 1,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
@ -154,7 +179,10 @@ function buildReviewEvent(
|
||||
tags: buildTags({
|
||||
type: 'quote',
|
||||
category: newCategory,
|
||||
id: '', // Will be set to event.id after publication
|
||||
id: hashId,
|
||||
service: PLATFORM_SERVICE,
|
||||
version: 0, // New object
|
||||
hidden: false,
|
||||
paywall: false,
|
||||
articleId: params.articleId,
|
||||
reviewerPubkey: params.reviewerPubkey,
|
||||
@ -163,11 +191,34 @@ function buildReviewEvent(
|
||||
}
|
||||
}
|
||||
|
||||
function buildUpdateTags(draft: ArticleDraft, originalArticleId: string, newCategory: 'sciencefiction' | 'research') {
|
||||
async function buildUpdateTags(
|
||||
draft: ArticleDraft,
|
||||
originalArticleId: string,
|
||||
newCategory: 'sciencefiction' | 'research',
|
||||
authorPubkey: string,
|
||||
currentVersion: number = 0
|
||||
) {
|
||||
// Generate hash ID from publication data
|
||||
const hashId = await generatePublicationHashId({
|
||||
pubkey: authorPubkey,
|
||||
title: draft.title,
|
||||
preview: draft.preview,
|
||||
category: newCategory,
|
||||
seriesId: draft.seriesId ?? undefined,
|
||||
bannerUrl: draft.bannerUrl ?? undefined,
|
||||
zapAmount: draft.zapAmount,
|
||||
})
|
||||
|
||||
// Increment version for update
|
||||
const nextVersion = currentVersion + 1
|
||||
|
||||
const updateTags = buildTags({
|
||||
type: 'publication',
|
||||
category: newCategory,
|
||||
id: '', // Will be set to event.id after publication
|
||||
id: hashId,
|
||||
service: PLATFORM_SERVICE,
|
||||
version: nextVersion,
|
||||
hidden: false,
|
||||
paywall: true,
|
||||
title: draft.title,
|
||||
preview: draft.preview,
|
||||
@ -189,9 +240,9 @@ async function publishUpdate(
|
||||
const presentationId = await ensurePresentation(authorPubkey)
|
||||
const invoice = await createArticleInvoice(draft)
|
||||
const newCategory = category === 'science-fiction' ? 'sciencefiction' : 'research'
|
||||
const updateTags = buildUpdateTags(draft, originalArticleId, newCategory)
|
||||
const updateTags = await buildUpdateTags(draft, originalArticleId, newCategory, authorPubkey)
|
||||
|
||||
const publishedEvent = await publishPreviewWithInvoice(draft, invoice, presentationId, updateTags)
|
||||
const publishedEvent = await publishPreviewWithInvoice(draft, invoice, authorPubkey, presentationId, updateTags)
|
||||
if (!publishedEvent) {
|
||||
return updateFailure(originalArticleId, 'Failed to publish article update')
|
||||
}
|
||||
|
||||
@ -171,9 +171,12 @@ export class ArticlePublisher {
|
||||
nostrService.setPublicKey(authorPubkey)
|
||||
nostrService.setPrivateKey(authorPrivateKey)
|
||||
|
||||
// Generate event ID before building event (using a temporary ID that will be replaced by Nostr)
|
||||
const tempEventId = `temp_${Math.random().toString(36).substring(7)}`
|
||||
const publishedEvent = await nostrService.publishEvent(buildPresentationEvent(draft, tempEventId, 'sciencefiction'))
|
||||
// Extract author name from title (format: "Présentation de <name>")
|
||||
const authorName = draft.title.replace(/^Présentation de /, '').trim() || 'Auteur'
|
||||
|
||||
// Build event with hash-based ID
|
||||
const eventTemplate = await buildPresentationEvent(draft, authorPubkey, authorName, 'sciencefiction')
|
||||
const publishedEvent = await nostrService.publishEvent(eventTemplate)
|
||||
|
||||
if (!publishedEvent) {
|
||||
return buildFailure('Failed to publish presentation article')
|
||||
|
||||
@ -1,25 +1,92 @@
|
||||
import { type Event } from 'nostr-tools'
|
||||
import { nip19 } from 'nostr-tools'
|
||||
import type { AuthorPresentationDraft } from './articlePublisher'
|
||||
import type { SimplePoolWithSub } from '@/types/nostr-tools-extended'
|
||||
import { buildTags, extractTagsFromEvent, buildTagFilter } from './nostrTagSystem'
|
||||
import { getPrimaryRelaySync } from './config'
|
||||
import { PLATFORM_SERVICE } from './platformConfig'
|
||||
import { generateAuthorHashId } from './hashIdGenerator'
|
||||
import { generateObjectUrl } from './urlGenerator'
|
||||
import { getLatestVersion } from './versionManager'
|
||||
|
||||
export async function buildPresentationEvent(
|
||||
draft: AuthorPresentationDraft,
|
||||
authorPubkey: string,
|
||||
authorName: string,
|
||||
category: 'sciencefiction' | 'research' = 'sciencefiction',
|
||||
version: number = 0,
|
||||
index: number = 0
|
||||
) {
|
||||
// Extract presentation and contentDescription from draft.content
|
||||
// Format: "${presentation}\n\n---\n\nDescription du contenu :\n${contentDescription}"
|
||||
const separator = '\n\n---\n\nDescription du contenu :\n'
|
||||
const separatorIndex = draft.content.indexOf(separator)
|
||||
const presentation = separatorIndex !== -1 ? draft.content.substring(0, separatorIndex) : draft.presentation
|
||||
const contentDescription = separatorIndex !== -1 ? draft.content.substring(separatorIndex + separator.length) : draft.contentDescription
|
||||
|
||||
// Generate hash ID from author data first (needed for URL)
|
||||
const hashId = await generateAuthorHashId({
|
||||
pubkey: authorPubkey,
|
||||
authorName,
|
||||
presentation,
|
||||
contentDescription,
|
||||
mainnetAddress: draft.mainnetAddress ?? undefined,
|
||||
pictureUrl: draft.pictureUrl ?? undefined,
|
||||
category,
|
||||
})
|
||||
|
||||
// Build URL: https://zapwall.fr/author/<hash>_<index>_<version>
|
||||
const profileUrl = generateObjectUrl('author', hashId, index, version)
|
||||
|
||||
// Build visible content message
|
||||
const visibleContent = [
|
||||
'Nouveau profil publié sur zapwall.fr',
|
||||
profileUrl,
|
||||
...(draft.pictureUrl ? [draft.pictureUrl] : []),
|
||||
`Présentation personnelle : ${presentation}`,
|
||||
`Description de votre contenu : ${contentDescription}`,
|
||||
`Adresse Bitcoin mainnet (pour le sponsoring) : ${draft.mainnetAddress}`,
|
||||
].join('\n')
|
||||
|
||||
// Build profile JSON for metadata (non-visible)
|
||||
const profileJson = JSON.stringify({
|
||||
authorName,
|
||||
npub,
|
||||
pubkey: authorPubkey,
|
||||
presentation,
|
||||
contentDescription,
|
||||
mainnetAddress: draft.mainnetAddress,
|
||||
pictureUrl: draft.pictureUrl,
|
||||
category,
|
||||
url: profileUrl,
|
||||
version,
|
||||
index,
|
||||
}, null, 2)
|
||||
|
||||
// Combine visible content and JSON metadata (JSON in hidden section)
|
||||
const fullContent = `${visibleContent}\n\n---\n\n[Metadata JSON]\n${profileJson}`
|
||||
|
||||
// Build tags (profile JSON is in content, not in tags)
|
||||
const tags = buildTags({
|
||||
type: 'author',
|
||||
category,
|
||||
id: hashId,
|
||||
service: PLATFORM_SERVICE,
|
||||
version,
|
||||
hidden: false,
|
||||
paywall: false,
|
||||
title: draft.title,
|
||||
preview: draft.preview,
|
||||
mainnetAddress: draft.mainnetAddress,
|
||||
totalSponsoring: 0,
|
||||
...(draft.pictureUrl ? { pictureUrl: draft.pictureUrl } : {}),
|
||||
})
|
||||
|
||||
export function buildPresentationEvent(draft: AuthorPresentationDraft, eventId: string, category: 'sciencefiction' | 'research' = 'sciencefiction') {
|
||||
return {
|
||||
kind: 1 as const,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: buildTags({
|
||||
type: 'author',
|
||||
category,
|
||||
id: eventId,
|
||||
paywall: false,
|
||||
title: draft.title,
|
||||
preview: draft.preview,
|
||||
mainnetAddress: draft.mainnetAddress,
|
||||
totalSponsoring: 0,
|
||||
...(draft.pictureUrl ? { pictureUrl: draft.pictureUrl } : {}),
|
||||
}),
|
||||
content: draft.content,
|
||||
tags,
|
||||
content: fullContent,
|
||||
}
|
||||
}
|
||||
|
||||
@ -31,10 +98,27 @@ export function parsePresentationEvent(event: Event): import('@/types/nostr').Au
|
||||
return null
|
||||
}
|
||||
|
||||
// Try to extract profile JSON from content [Metadata JSON] section
|
||||
let profileData: {
|
||||
presentation?: string
|
||||
contentDescription?: string
|
||||
mainnetAddress?: string
|
||||
pictureUrl?: string
|
||||
} | null = null
|
||||
|
||||
const jsonMatch = event.content.match(/\[Metadata JSON\]\n(.+)$/s)
|
||||
if (jsonMatch && jsonMatch[1]) {
|
||||
try {
|
||||
profileData = JSON.parse(jsonMatch[1].trim())
|
||||
} catch (e) {
|
||||
console.error('Error parsing profile JSON from content:', e)
|
||||
}
|
||||
}
|
||||
|
||||
// Map tag category to article category
|
||||
const articleCategory = tags.category === 'sciencefiction' ? 'science-fiction' : tags.category === 'research' ? 'scientific-research' : undefined
|
||||
|
||||
return {
|
||||
const result: import('@/types/nostr').AuthorPresentationArticle = {
|
||||
id: tags.id ?? event.id,
|
||||
pubkey: event.pubkey,
|
||||
title: tags.title ?? 'Présentation',
|
||||
@ -45,11 +129,19 @@ export function parsePresentationEvent(event: Event): import('@/types/nostr').Au
|
||||
paid: true,
|
||||
category: 'author-presentation',
|
||||
isPresentation: true,
|
||||
mainnetAddress: tags.mainnetAddress ?? '',
|
||||
mainnetAddress: profileData?.mainnetAddress ?? tags.mainnetAddress ?? '',
|
||||
totalSponsoring: tags.totalSponsoring ?? 0,
|
||||
originalCategory: articleCategory, // Store original category for filtering
|
||||
...(tags.pictureUrl !== undefined && tags.pictureUrl !== null && typeof tags.pictureUrl === 'string' ? { bannerUrl: tags.pictureUrl } : {}),
|
||||
originalCategory: articleCategory ?? 'science-fiction', // Store original category for filtering
|
||||
}
|
||||
|
||||
// Add bannerUrl if available
|
||||
if (profileData?.pictureUrl !== undefined && profileData?.pictureUrl !== null) {
|
||||
result.bannerUrl = profileData.pictureUrl
|
||||
} else if (tags.pictureUrl !== undefined && tags.pictureUrl !== null && typeof tags.pictureUrl === 'string') {
|
||||
result.bannerUrl = tags.pictureUrl
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export function fetchAuthorPresentationFromPool(
|
||||
@ -61,8 +153,9 @@ export function fetchAuthorPresentationFromPool(
|
||||
...buildTagFilter({
|
||||
type: 'author',
|
||||
authorPubkey: pubkey,
|
||||
service: PLATFORM_SERVICE,
|
||||
}),
|
||||
limit: 1,
|
||||
limit: 100, // Get all versions to find the latest
|
||||
},
|
||||
]
|
||||
|
||||
@ -72,6 +165,8 @@ export function fetchAuthorPresentationFromPool(
|
||||
const { createSubscription } = require('@/types/nostr-tools-extended')
|
||||
const sub = createSubscription(pool, [relayUrl], filters)
|
||||
|
||||
const events: Event[] = []
|
||||
|
||||
const finalize = (value: import('@/types/nostr').AuthorPresentationArticle | null) => {
|
||||
if (resolved) {
|
||||
return
|
||||
@ -82,13 +177,36 @@ export function fetchAuthorPresentationFromPool(
|
||||
}
|
||||
|
||||
sub.on('event', (event: Event) => {
|
||||
const parsed = parsePresentationEvent(event)
|
||||
if (parsed) {
|
||||
finalize(parsed)
|
||||
// Collect all events first
|
||||
const tags = extractTagsFromEvent(event)
|
||||
if (tags.type === 'author' && !tags.hidden) {
|
||||
events.push(event)
|
||||
}
|
||||
})
|
||||
|
||||
sub.on('eose', () => finalize(null))
|
||||
setTimeout(() => finalize(null), 5000)
|
||||
sub.on('eose', () => {
|
||||
// Get the latest version from all collected events
|
||||
const latestEvent = getLatestVersion(events)
|
||||
if (latestEvent) {
|
||||
const parsed = parsePresentationEvent(latestEvent)
|
||||
if (parsed) {
|
||||
finalize(parsed)
|
||||
return
|
||||
}
|
||||
}
|
||||
finalize(null)
|
||||
})
|
||||
setTimeout(() => {
|
||||
// Get the latest version from all collected events
|
||||
const latestEvent = getLatestVersion(events)
|
||||
if (latestEvent) {
|
||||
const parsed = parsePresentationEvent(latestEvent)
|
||||
if (parsed) {
|
||||
finalize(parsed)
|
||||
return
|
||||
}
|
||||
}
|
||||
finalize(null)
|
||||
}, 5000).unref?.()
|
||||
})
|
||||
}
|
||||
|
||||
@ -17,12 +17,13 @@ export function buildFailure(error?: string): PublishedArticle {
|
||||
export async function publishPreview(
|
||||
draft: ArticleDraft,
|
||||
invoice: AlbyInvoice,
|
||||
authorPubkey: string,
|
||||
presentationId: string,
|
||||
extraTags?: string[][],
|
||||
encryptedContent?: string,
|
||||
encryptedKey?: string
|
||||
): Promise<import('nostr-tools').Event | null> {
|
||||
const previewEvent = createPreviewEvent(draft, invoice, presentationId, extraTags, encryptedContent, encryptedKey)
|
||||
const previewEvent = await createPreviewEvent(draft, invoice, authorPubkey, presentationId, extraTags, encryptedContent, encryptedKey)
|
||||
const publishedEvent = await nostrService.publishEvent(previewEvent)
|
||||
return publishedEvent ?? null
|
||||
}
|
||||
@ -49,7 +50,7 @@ export async function encryptAndPublish(
|
||||
const encryptedKey = await encryptDecryptionKey(key, iv, authorPrivateKeyForEncryption, authorPubkey)
|
||||
const invoice = await createArticleInvoice(draft)
|
||||
const extraTags = buildArticleExtraTags(draft, category)
|
||||
const publishedEvent = await publishPreview(draft, invoice, presentationId, extraTags, encryptedContent, encryptedKey)
|
||||
const publishedEvent = await publishPreview(draft, invoice, authorPubkey, presentationId, extraTags, encryptedContent, encryptedKey)
|
||||
|
||||
if (!publishedEvent) {
|
||||
return buildFailure('Failed to publish article')
|
||||
|
||||
@ -6,6 +6,7 @@ import type { Article } from '@/types/nostr'
|
||||
import { parseArticleFromEvent } from './nostrEventParsing'
|
||||
import { buildTagFilter } from './nostrTagSystem'
|
||||
import { getPrimaryRelaySync } from './config'
|
||||
import { PLATFORM_SERVICE } from './platformConfig'
|
||||
|
||||
function createSeriesSubscription(pool: SimplePool, seriesId: string, limit: number) {
|
||||
const filters = [
|
||||
@ -13,6 +14,7 @@ function createSeriesSubscription(pool: SimplePool, seriesId: string, limit: num
|
||||
...buildTagFilter({
|
||||
type: 'publication',
|
||||
seriesId,
|
||||
service: PLATFORM_SERVICE,
|
||||
}),
|
||||
limit,
|
||||
},
|
||||
|
||||
124
lib/duplicateDetector.ts
Normal file
124
lib/duplicateDetector.ts
Normal file
@ -0,0 +1,124 @@
|
||||
/**
|
||||
* Detect duplicate IDs for the same object type
|
||||
* When duplicates are found, warn the user and ask them to choose
|
||||
*/
|
||||
|
||||
import type { ExtractedObject } from './metadataExtractor'
|
||||
|
||||
export interface DuplicateGroup<T extends ExtractedObject> {
|
||||
id: string
|
||||
type: T['type']
|
||||
objects: T[]
|
||||
}
|
||||
|
||||
export interface DuplicateWarning {
|
||||
type: ExtractedObject['type']
|
||||
id: string
|
||||
objects: ExtractedObject[]
|
||||
message: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Group objects by type and ID to detect duplicates
|
||||
*/
|
||||
export function detectDuplicates(objects: ExtractedObject[]): DuplicateWarning[] {
|
||||
const warnings: DuplicateWarning[] = []
|
||||
|
||||
// Group objects by type
|
||||
const byType = new Map<ExtractedObject['type'], ExtractedObject[]>()
|
||||
for (const obj of objects) {
|
||||
if (!byType.has(obj.type)) {
|
||||
byType.set(obj.type, [])
|
||||
}
|
||||
byType.get(obj.type)!.push(obj)
|
||||
}
|
||||
|
||||
// For each type, group by ID
|
||||
for (const [type, typeObjects] of byType.entries()) {
|
||||
const byId = new Map<string, ExtractedObject[]>()
|
||||
for (const obj of typeObjects) {
|
||||
if (!byId.has(obj.id)) {
|
||||
byId.set(obj.id, [])
|
||||
}
|
||||
byId.get(obj.id)!.push(obj)
|
||||
}
|
||||
|
||||
// Check for duplicates (same ID, multiple objects)
|
||||
for (const [id, idObjects] of byId.entries()) {
|
||||
if (idObjects.length > 1) {
|
||||
warnings.push({
|
||||
type,
|
||||
id,
|
||||
objects: idObjects,
|
||||
message: `Found ${idObjects.length} objects of type "${type}" with the same ID "${id.substring(0, 16)}...". Please choose which one to keep.`,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return warnings
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve duplicates by keeping only the first object for each ID
|
||||
* This is a simple resolution - in production, you'd want user interaction
|
||||
*/
|
||||
export function resolveDuplicatesSimple(objects: ExtractedObject[]): ExtractedObject[] {
|
||||
const seen = new Map<string, ExtractedObject>()
|
||||
const resolved: ExtractedObject[] = []
|
||||
|
||||
for (const obj of objects) {
|
||||
const key = `${obj.type}:${obj.id}`
|
||||
if (!seen.has(key)) {
|
||||
seen.set(key, obj)
|
||||
resolved.push(obj)
|
||||
} else {
|
||||
// Keep the first one, skip duplicates
|
||||
console.warn(`Duplicate detected for ${obj.type} with ID ${obj.id.substring(0, 16)}... Keeping first occurrence.`)
|
||||
}
|
||||
}
|
||||
|
||||
return resolved
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve duplicates by keeping the most recent object (highest event.created_at)
|
||||
*/
|
||||
export function resolveDuplicatesByDate(objects: ExtractedObject[]): ExtractedObject[] {
|
||||
const byKey = new Map<string, ExtractedObject[]>()
|
||||
|
||||
// Group by type and ID
|
||||
for (const obj of objects) {
|
||||
const key = `${obj.type}:${obj.id}`
|
||||
if (!byKey.has(key)) {
|
||||
byKey.set(key, [])
|
||||
}
|
||||
byKey.get(key)!.push(obj)
|
||||
}
|
||||
|
||||
const resolved: ExtractedObject[] = []
|
||||
|
||||
for (const [key, group] of byKey.entries()) {
|
||||
if (group.length === 1) {
|
||||
const obj = group[0]
|
||||
if (obj) {
|
||||
resolved.push(obj)
|
||||
}
|
||||
} else {
|
||||
// Sort by eventId (which should correlate with creation time)
|
||||
// Keep the one with the "latest" eventId (lexicographically)
|
||||
// In practice, you'd want to fetch the actual created_at from events
|
||||
group.sort((a, b) => {
|
||||
// Simple lexicographic comparison - in production, compare actual timestamps
|
||||
return b.eventId.localeCompare(a.eventId)
|
||||
})
|
||||
const first = group[0]
|
||||
if (first) {
|
||||
resolved.push(first)
|
||||
console.warn(`Resolved ${group.length} duplicates for ${key} by keeping most recent`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return resolved
|
||||
}
|
||||
199
lib/hashIdGenerator.ts
Normal file
199
lib/hashIdGenerator.ts
Normal file
@ -0,0 +1,199 @@
|
||||
/**
|
||||
* Hash-based ID generation for Nostr objects
|
||||
* All IDs are SHA-256 hashes of the object's canonical representation
|
||||
*/
|
||||
|
||||
/**
|
||||
* Generate a canonical string representation of an object for hashing
|
||||
* This ensures that the same object always produces the same hash
|
||||
*/
|
||||
function canonicalizeObject(obj: Record<string, unknown>): string {
|
||||
// Sort keys to ensure consistent ordering
|
||||
const sortedKeys = Object.keys(obj).sort()
|
||||
const parts: string[] = []
|
||||
|
||||
for (const key of sortedKeys) {
|
||||
const value = obj[key]
|
||||
if (value === undefined || value === null) {
|
||||
continue // Skip undefined/null values
|
||||
}
|
||||
if (typeof value === 'object' && !Array.isArray(value)) {
|
||||
// Recursively canonicalize nested objects
|
||||
parts.push(`${key}:${canonicalizeObject(value as Record<string, unknown>)}`)
|
||||
} else if (Array.isArray(value)) {
|
||||
// Arrays are serialized as comma-separated values
|
||||
parts.push(`${key}:[${value.map((v) => (typeof v === 'object' ? JSON.stringify(v) : String(v))).join(',')}]`)
|
||||
} else {
|
||||
parts.push(`${key}:${String(value)}`)
|
||||
}
|
||||
}
|
||||
|
||||
return `{${parts.join('|')}}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a SHA-256 hash ID from an object using Web Crypto API
|
||||
* The hash is deterministic: the same object always produces the same hash
|
||||
*/
|
||||
export async function generateHashId(obj: Record<string, unknown>): Promise<string> {
|
||||
const canonical = canonicalizeObject(obj)
|
||||
const encoder = new TextEncoder()
|
||||
const data = encoder.encode(canonical)
|
||||
const hashBuffer = await crypto.subtle.digest('SHA-256', data)
|
||||
const hashArray = Array.from(new Uint8Array(hashBuffer))
|
||||
return hashArray.map((b) => b.toString(16).padStart(2, '0')).join('')
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate hash ID for an author presentation
|
||||
*/
|
||||
export async function generateAuthorHashId(authorData: {
|
||||
pubkey: string
|
||||
authorName: string
|
||||
presentation: string
|
||||
contentDescription: string
|
||||
mainnetAddress?: string | undefined
|
||||
pictureUrl?: string | undefined
|
||||
category: string
|
||||
}): Promise<string> {
|
||||
return generateHashId({
|
||||
type: 'author',
|
||||
pubkey: authorData.pubkey,
|
||||
authorName: authorData.authorName,
|
||||
presentation: authorData.presentation,
|
||||
contentDescription: authorData.contentDescription,
|
||||
mainnetAddress: authorData.mainnetAddress ?? '',
|
||||
pictureUrl: authorData.pictureUrl ?? '',
|
||||
category: authorData.category,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate hash ID for a series
|
||||
*/
|
||||
export async function generateSeriesHashId(seriesData: {
|
||||
pubkey: string
|
||||
title: string
|
||||
description: string
|
||||
category: string
|
||||
coverUrl?: string | undefined
|
||||
}): Promise<string> {
|
||||
return generateHashId({
|
||||
type: 'series',
|
||||
pubkey: seriesData.pubkey,
|
||||
title: seriesData.title,
|
||||
description: seriesData.description,
|
||||
category: seriesData.category,
|
||||
coverUrl: seriesData.coverUrl ?? '',
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate hash ID for a publication
|
||||
*/
|
||||
export async function generatePublicationHashId(publicationData: {
|
||||
pubkey: string
|
||||
title: string
|
||||
preview: string
|
||||
category: string
|
||||
seriesId?: string | undefined
|
||||
bannerUrl?: string | undefined
|
||||
zapAmount: number
|
||||
}): Promise<string> {
|
||||
return generateHashId({
|
||||
type: 'publication',
|
||||
pubkey: publicationData.pubkey,
|
||||
title: publicationData.title,
|
||||
preview: publicationData.preview,
|
||||
category: publicationData.category,
|
||||
seriesId: publicationData.seriesId ?? '',
|
||||
bannerUrl: publicationData.bannerUrl ?? '',
|
||||
zapAmount: publicationData.zapAmount,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate hash ID for a review/quote
|
||||
*/
|
||||
export async function generateReviewHashId(reviewData: {
|
||||
pubkey: string
|
||||
articleId: string
|
||||
reviewerPubkey: string
|
||||
content: string
|
||||
title?: string
|
||||
}): Promise<string> {
|
||||
return generateHashId({
|
||||
type: 'quote',
|
||||
pubkey: reviewData.pubkey,
|
||||
articleId: reviewData.articleId,
|
||||
reviewerPubkey: reviewData.reviewerPubkey,
|
||||
content: reviewData.content,
|
||||
title: reviewData.title ?? '',
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate hash ID for a purchase (zap receipt kind 9735)
|
||||
*/
|
||||
export async function generatePurchaseHashId(purchaseData: {
|
||||
payerPubkey: string
|
||||
articleId: string
|
||||
authorPubkey: string
|
||||
amount: number
|
||||
paymentHash: string
|
||||
}): Promise<string> {
|
||||
return generateHashId({
|
||||
type: 'purchase',
|
||||
payerPubkey: purchaseData.payerPubkey,
|
||||
articleId: purchaseData.articleId,
|
||||
authorPubkey: purchaseData.authorPubkey,
|
||||
amount: purchaseData.amount,
|
||||
paymentHash: purchaseData.paymentHash,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate hash ID for a review tip (zap receipt kind 9735)
|
||||
*/
|
||||
export async function generateReviewTipHashId(tipData: {
|
||||
payerPubkey: string
|
||||
articleId: string
|
||||
reviewId: string
|
||||
reviewerPubkey: string
|
||||
authorPubkey: string
|
||||
amount: number
|
||||
paymentHash: string
|
||||
}): Promise<string> {
|
||||
return generateHashId({
|
||||
type: 'review_tip',
|
||||
payerPubkey: tipData.payerPubkey,
|
||||
articleId: tipData.articleId,
|
||||
reviewId: tipData.reviewId,
|
||||
reviewerPubkey: tipData.reviewerPubkey,
|
||||
authorPubkey: tipData.authorPubkey,
|
||||
amount: tipData.amount,
|
||||
paymentHash: tipData.paymentHash,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate hash ID for a sponsoring (zap receipt kind 9735)
|
||||
*/
|
||||
export async function generateSponsoringHashId(sponsoringData: {
|
||||
payerPubkey: string
|
||||
authorPubkey: string
|
||||
seriesId?: string
|
||||
articleId?: string
|
||||
amount: number
|
||||
paymentHash: string
|
||||
}): Promise<string> {
|
||||
return generateHashId({
|
||||
type: 'sponsoring',
|
||||
payerPubkey: sponsoringData.payerPubkey,
|
||||
authorPubkey: sponsoringData.authorPubkey,
|
||||
seriesId: sponsoringData.seriesId ?? '',
|
||||
articleId: sponsoringData.articleId ?? '',
|
||||
amount: sponsoringData.amount,
|
||||
paymentHash: sponsoringData.paymentHash,
|
||||
})
|
||||
}
|
||||
@ -42,7 +42,7 @@ export class KeyManagementService {
|
||||
if (decoded.type === 'nsec') {
|
||||
// decoded.data can be string (hex) or Uint8Array depending on nostr-tools version
|
||||
if (typeof decoded.data === 'string') {
|
||||
privateKeyHex = decoded.data
|
||||
privateKeyHex = decoded.data
|
||||
} else if (decoded.data instanceof Uint8Array) {
|
||||
privateKeyHex = bytesToHex(decoded.data)
|
||||
} else {
|
||||
|
||||
491
lib/metadataExtractor.ts
Normal file
491
lib/metadataExtractor.ts
Normal file
@ -0,0 +1,491 @@
|
||||
/**
|
||||
* 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 extractMetadataJson(content: string): Record<string, unknown> | null {
|
||||
const jsonMatch = content.match(/\[Metadata JSON\]\n(.+)$/s)
|
||||
if (jsonMatch && 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<ExtractedAuthor | null> {
|
||||
const tags = extractTagsFromEvent(event)
|
||||
if (tags.type !== 'author') {
|
||||
return null
|
||||
}
|
||||
|
||||
// Try to extract from metadata JSON first
|
||||
const metadata = extractMetadataJson(event.content)
|
||||
if (metadata && 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,
|
||||
...authorData,
|
||||
eventId: event.id,
|
||||
url: metadata.url as string | undefined,
|
||||
}
|
||||
}
|
||||
|
||||
// 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<ExtractedSeries | null> {
|
||||
const tags = extractTagsFromEvent(event)
|
||||
if (tags.type !== 'series') {
|
||||
return null
|
||||
}
|
||||
|
||||
const metadata = extractMetadataJson(event.content)
|
||||
if (metadata && 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,
|
||||
...seriesData,
|
||||
eventId: event.id,
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: extract from tags
|
||||
if (tags.title && tags.description) {
|
||||
const seriesData = {
|
||||
pubkey: event.pubkey,
|
||||
title: tags.title as string,
|
||||
description: tags.description as string,
|
||||
preview: (tags.preview as string) ?? event.content.substring(0, 200),
|
||||
coverUrl: tags.coverUrl as string | undefined,
|
||||
category: tags.category ?? 'sciencefiction',
|
||||
}
|
||||
|
||||
const id = await generateSeriesHashId(seriesData)
|
||||
|
||||
return {
|
||||
type: 'series',
|
||||
id,
|
||||
...seriesData,
|
||||
eventId: event.id,
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract publication from event
|
||||
*/
|
||||
export async function extractPublicationFromEvent(event: Event): Promise<ExtractedPublication | null> {
|
||||
const tags = extractTagsFromEvent(event)
|
||||
if (tags.type !== 'publication') {
|
||||
return null
|
||||
}
|
||||
|
||||
const metadata = extractMetadataJson(event.content)
|
||||
if (metadata && 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)
|
||||
|
||||
return {
|
||||
type: 'publication',
|
||||
id,
|
||||
...publicationData,
|
||||
eventId: event.id,
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: extract from tags
|
||||
if (tags.title) {
|
||||
const publicationData = {
|
||||
pubkey: event.pubkey,
|
||||
title: tags.title as string,
|
||||
preview: (tags.preview as string) ?? event.content.substring(0, 200),
|
||||
category: tags.category ?? 'sciencefiction',
|
||||
seriesId: tags.seriesId as string | undefined,
|
||||
bannerUrl: tags.bannerUrl as string | undefined,
|
||||
zapAmount: tags.zapAmount ?? 800,
|
||||
}
|
||||
|
||||
const id = await generatePublicationHashId(publicationData)
|
||||
|
||||
return {
|
||||
type: 'publication',
|
||||
id,
|
||||
...publicationData,
|
||||
eventId: event.id,
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract review from event
|
||||
*/
|
||||
export async function extractReviewFromEvent(event: Event): Promise<ExtractedReview | null> {
|
||||
const tags = extractTagsFromEvent(event)
|
||||
if (tags.type !== 'quote') {
|
||||
return null
|
||||
}
|
||||
|
||||
const metadata = extractMetadataJson(event.content)
|
||||
if (metadata && 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 as string,
|
||||
reviewerPubkey: tags.reviewerPubkey as string,
|
||||
content: event.content,
|
||||
title: tags.title as string | undefined,
|
||||
}
|
||||
|
||||
const id = await generateReviewHashId(reviewData)
|
||||
|
||||
return {
|
||||
type: 'review',
|
||||
id,
|
||||
...reviewData,
|
||||
eventId: event.id,
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract purchase from zap receipt (kind 9735)
|
||||
*/
|
||||
export async function extractPurchaseFromEvent(event: Event): Promise<ExtractedPurchase | null> {
|
||||
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<ExtractedReviewTip | null> {
|
||||
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<ExtractedSponsoring | null> {
|
||||
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(sponsoringData)
|
||||
|
||||
return {
|
||||
type: 'sponsoring',
|
||||
id,
|
||||
...sponsoringData,
|
||||
eventId: event.id,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract all objects from an event
|
||||
*/
|
||||
export async function extractObjectsFromEvent(event: Event): Promise<ExtractedObject[]> {
|
||||
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
|
||||
}
|
||||
23
lib/nostr.ts
23
lib/nostr.ts
@ -3,6 +3,7 @@ import { hexToBytes } from 'nostr-tools/utils'
|
||||
import type { Article, NostrProfile } from '@/types/nostr'
|
||||
import { createSubscription } from '@/types/nostr-tools-extended'
|
||||
import { parseArticleFromEvent } from './nostrEventParsing'
|
||||
import { parsePresentationEvent } from './articlePublisherHelpersPresentation'
|
||||
import {
|
||||
getPrivateContent as getPrivateContentFromPool,
|
||||
getDecryptionKey,
|
||||
@ -12,6 +13,7 @@ import { checkZapReceipt as checkZapReceiptHelper } from './nostrZapVerification
|
||||
import { subscribeWithTimeout } from './nostrSubscription'
|
||||
import { getPrimaryRelay, getPrimaryRelaySync } from './config'
|
||||
import { buildTagFilter } from './nostrTagSystem'
|
||||
import { PLATFORM_SERVICE } from './platformConfig'
|
||||
|
||||
class NostrService {
|
||||
private pool: SimplePool | null = null
|
||||
@ -84,10 +86,21 @@ class NostrService {
|
||||
}
|
||||
|
||||
private createArticleSubscription(pool: SimplePool, limit: number) {
|
||||
// Subscribe to both 'publication' and 'author' type events
|
||||
// Authors are identified by tag type='author' in the tag system
|
||||
// Filter by service='zapwall.fr' to only get notes from this platform
|
||||
const filters = [
|
||||
{
|
||||
...buildTagFilter({
|
||||
type: 'publication',
|
||||
service: PLATFORM_SERVICE,
|
||||
}),
|
||||
limit,
|
||||
},
|
||||
{
|
||||
...buildTagFilter({
|
||||
type: 'author',
|
||||
service: PLATFORM_SERVICE,
|
||||
}),
|
||||
limit,
|
||||
},
|
||||
@ -113,7 +126,15 @@ class NostrService {
|
||||
|
||||
sub.on('event', (event: Event) => {
|
||||
try {
|
||||
const article = parseArticleFromEvent(event)
|
||||
// Try to parse as regular article first
|
||||
let article = parseArticleFromEvent(event)
|
||||
// If not a regular article, try to parse as author presentation
|
||||
if (!article) {
|
||||
const presentation = parsePresentationEvent(event)
|
||||
if (presentation) {
|
||||
article = presentation
|
||||
}
|
||||
}
|
||||
if (article) {
|
||||
callback(article)
|
||||
}
|
||||
|
||||
@ -5,6 +5,13 @@ function buildBaseTags(tags: AuthorTags | SeriesTags | PublicationTags | QuoteTa
|
||||
result.push([tags.type])
|
||||
result.push([tags.category])
|
||||
result.push(['id', tags.id])
|
||||
result.push(['service', tags.service])
|
||||
// Add version tag (default to 0 if not specified)
|
||||
result.push(['version', (tags.version ?? 0).toString()])
|
||||
// Add hidden tag only if true
|
||||
if (tags.hidden === true) {
|
||||
result.push(['hidden', 'true'])
|
||||
}
|
||||
if (tags.paywall) {
|
||||
result.push(['paywall'])
|
||||
}
|
||||
|
||||
@ -21,6 +21,9 @@ export function extractTypeAndCategory(event: { tags: string[][] }): { type?: Ta
|
||||
export function extractCommonTags(findTag: (key: string) => string | undefined, hasTag: (key: string) => boolean) {
|
||||
return {
|
||||
id: findTag('id'),
|
||||
service: findTag('service'),
|
||||
version: parseNumericTag(findTag, 'version') ?? 0, // Default to 0 if not present
|
||||
hidden: findTag('hidden') === 'true', // true only if tag exists and value is 'true'
|
||||
paywall: hasTag('paywall'),
|
||||
payment: hasTag('payment'),
|
||||
title: findTag('title'),
|
||||
@ -45,6 +48,9 @@ export function extractTagsFromEvent(event: { tags: string[][] }): {
|
||||
type?: TagType | undefined
|
||||
category?: TagCategory | undefined
|
||||
id?: string | undefined
|
||||
service?: string | undefined
|
||||
version: number
|
||||
hidden: boolean
|
||||
paywall: boolean
|
||||
payment: boolean
|
||||
title?: string | undefined
|
||||
|
||||
@ -16,6 +16,7 @@ export function buildTagFilter(params: {
|
||||
type?: TagType
|
||||
category?: TagCategory
|
||||
id?: string
|
||||
service?: string
|
||||
paywall?: boolean
|
||||
payment?: boolean
|
||||
seriesId?: string
|
||||
@ -33,6 +34,7 @@ export function buildTagFilter(params: {
|
||||
filter[`#${params.category}`] = ['']
|
||||
}
|
||||
addValueTagFilter(filter, 'id', params.id)
|
||||
addValueTagFilter(filter, 'service', params.service)
|
||||
addSimpleTagFilter(filter, 'paywall', params.paywall === true)
|
||||
addSimpleTagFilter(filter, 'payment', params.payment === true)
|
||||
addValueTagFilter(filter, 'series', params.seriesId)
|
||||
|
||||
@ -4,6 +4,7 @@
|
||||
* - #sciencefiction or #research: for category
|
||||
* - #author, #series, #publication, #quote: for type
|
||||
* - #id_<id>: for identifier
|
||||
* - #service: service identifier (e.g., "zapwall.fr") to filter all notes from this platform
|
||||
* - #payment (optional): for payment notes
|
||||
*
|
||||
* Everything is a Nostr note (kind 1)
|
||||
@ -17,6 +18,9 @@ export interface BaseTags {
|
||||
type: TagType
|
||||
category: TagCategory
|
||||
id: string
|
||||
service: string // Service identifier (e.g., "zapwall.fr")
|
||||
version?: number // Version number (0 by default, incremented on updates)
|
||||
hidden?: boolean // Hidden flag (true to hide/delete, false or undefined to show)
|
||||
paywall?: boolean
|
||||
payment?: boolean
|
||||
}
|
||||
|
||||
97
lib/objectModification.ts
Normal file
97
lib/objectModification.ts
Normal file
@ -0,0 +1,97 @@
|
||||
/**
|
||||
* Object modification and deletion utilities
|
||||
* Only the author (pubkey) who published the original note can modify or delete it
|
||||
*
|
||||
* Access rules:
|
||||
* - Modification: Only the author (event.pubkey === userPubkey) can modify
|
||||
* - Deletion: Only the author (event.pubkey === userPubkey) can delete
|
||||
* - Read access: All users can read previews, paid content requires payment verification
|
||||
*/
|
||||
|
||||
import type { Event } from 'nostr-tools'
|
||||
import { extractTagsFromEvent } from './nostrTagSystem'
|
||||
import { canModifyObject, getNextVersion } from './versionManager'
|
||||
|
||||
/**
|
||||
* Check if user can modify an object
|
||||
* Only the author (pubkey) who published the original note can modify it
|
||||
*/
|
||||
export function canUserModifyObject(event: Event, userPubkey: string): boolean {
|
||||
return canModifyObject(event, userPubkey)
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an update event (new version)
|
||||
* Increments version and keeps the same hash ID
|
||||
*/
|
||||
export async function buildUpdateEvent(
|
||||
originalEvent: Event,
|
||||
updatedData: Record<string, unknown>,
|
||||
userPubkey: string
|
||||
): Promise<Event | null> {
|
||||
// Check if user can modify
|
||||
if (!canUserModifyObject(originalEvent, userPubkey)) {
|
||||
throw new Error('Only the author can modify this object')
|
||||
}
|
||||
|
||||
const tags = extractTagsFromEvent(originalEvent)
|
||||
const nextVersion = getNextVersion([originalEvent])
|
||||
|
||||
// Build new event with incremented version
|
||||
// The hash ID stays the same, only version changes
|
||||
const newTags = originalEvent.tags.map((tag) => {
|
||||
if (tag[0] === 'version') {
|
||||
return ['version', nextVersion.toString()]
|
||||
}
|
||||
return tag
|
||||
})
|
||||
|
||||
// Remove hidden tag if present (object is being updated, not deleted)
|
||||
const filteredTags = newTags.filter((tag) => !(tag[0] === 'hidden' && tag[1] === 'true'))
|
||||
|
||||
return {
|
||||
...originalEvent,
|
||||
tags: filteredTags,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
// Content and other fields should be updated by the caller
|
||||
} as Event
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a delete event (hide object)
|
||||
* Sets hidden=true and increments version
|
||||
*/
|
||||
export async function buildDeleteEvent(
|
||||
originalEvent: Event,
|
||||
userPubkey: string
|
||||
): Promise<Event | null> {
|
||||
// Check if user can modify
|
||||
if (!canUserModifyObject(originalEvent, userPubkey)) {
|
||||
throw new Error('Only the author can delete this object')
|
||||
}
|
||||
|
||||
const tags = extractTagsFromEvent(originalEvent)
|
||||
const nextVersion = getNextVersion([originalEvent])
|
||||
|
||||
// Build new event with hidden=true and incremented version
|
||||
const newTags = originalEvent.tags.map((tag) => {
|
||||
if (tag[0] === 'version') {
|
||||
return ['version', nextVersion.toString()]
|
||||
}
|
||||
if (tag[0] === 'hidden') {
|
||||
return ['hidden', 'true']
|
||||
}
|
||||
return tag
|
||||
})
|
||||
|
||||
// Add hidden tag if not present
|
||||
if (!newTags.some((tag) => tag[0] === 'hidden')) {
|
||||
newTags.push(['hidden', 'true'])
|
||||
}
|
||||
|
||||
return {
|
||||
...originalEvent,
|
||||
tags: newTags,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
} as Event
|
||||
}
|
||||
@ -1,29 +1,3 @@
|
||||
export const PLATFORM_NPUB = 'npub18s03s39fa80ce2n3cmm0zme3jqehc82h6ld9sxq03uejqm3d05gsae0fuu'
|
||||
export const PLATFORM_BITCOIN_ADDRESS = 'bc1qerauk5yhqytl6z93ckvwkylup8s0256uenzg9y'
|
||||
|
||||
import { getPlatformLightningAddress as getAddress, getPlatformLightningAddressSync as getAddressSync } from './config'
|
||||
|
||||
/**
|
||||
* Platform Lightning address for receiving commissions
|
||||
* This should be configured with the platform's Lightning node
|
||||
* Format: user@domain.com or LNURL
|
||||
*
|
||||
* @deprecated Use getPlatformLightningAddress() or getPlatformLightningAddressSync() instead
|
||||
*/
|
||||
export const PLATFORM_LIGHTNING_ADDRESS = ''
|
||||
|
||||
/**
|
||||
* Get platform Lightning address (async)
|
||||
* Uses IndexedDB if available, otherwise returns default
|
||||
*/
|
||||
export async function getPlatformLightningAddress(): Promise<string> {
|
||||
return getAddress()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get platform Lightning address (sync)
|
||||
* Returns default if IndexedDB is not ready
|
||||
*/
|
||||
export function getPlatformLightningAddressSync(): string {
|
||||
return getAddressSync()
|
||||
}
|
||||
export const PLATFORM_SERVICE = 'zapwall.fr'
|
||||
|
||||
@ -2,13 +2,45 @@ import type { Article } from '@/types/nostr'
|
||||
|
||||
/**
|
||||
* Extract presentation data from article content
|
||||
* Content format: "${presentation}\n\n---\n\nDescription du contenu :\n${contentDescription}"
|
||||
* Supports two formats:
|
||||
* 1. Old format: "${presentation}\n\n---\n\nDescription du contenu :\n${contentDescription}"
|
||||
* 2. New format: "Nouveau profil publié sur zapwall.fr\n<url>\n<photo>\nPrésentation personnelle : <presentation>\nDescription de votre contenu : <description>\nAdresse Bitcoin mainnet (pour le sponsoring) : <adresse>\n\n---\n\n[Metadata JSON]\n<json>"
|
||||
* The profile JSON is stored in the [Metadata JSON] section of the content, not in tags
|
||||
*/
|
||||
export function extractPresentationData(presentation: Article): {
|
||||
presentation: string
|
||||
contentDescription: string
|
||||
} {
|
||||
const content = presentation.content
|
||||
|
||||
// Try new format first
|
||||
const newFormatMatch = content.match(/Présentation personnelle : (.+?)(?:\nDescription de votre contenu :|$)/s)
|
||||
const descriptionMatch = content.match(/Description de votre contenu : (.+?)(?:\nAdresse Bitcoin mainnet|$)/s)
|
||||
|
||||
if (newFormatMatch && descriptionMatch) {
|
||||
return {
|
||||
presentation: newFormatMatch[1].trim(),
|
||||
contentDescription: descriptionMatch[1].trim(),
|
||||
}
|
||||
}
|
||||
|
||||
// Try to extract from JSON metadata section
|
||||
const jsonMatch = content.match(/\[Metadata JSON\]\n(.+)$/s)
|
||||
if (jsonMatch) {
|
||||
try {
|
||||
const profileJson = JSON.parse(jsonMatch[1].trim())
|
||||
if (profileJson.presentation && profileJson.contentDescription) {
|
||||
return {
|
||||
presentation: profileJson.presentation,
|
||||
contentDescription: profileJson.contentDescription,
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// JSON parsing failed, continue with old format
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to old format
|
||||
const separator = '\n\n---\n\nDescription du contenu :\n'
|
||||
const separatorIndex = content.indexOf(separator)
|
||||
|
||||
|
||||
@ -4,11 +4,13 @@ import type { Review } from '@/types/nostr'
|
||||
import { parseReviewFromEvent } from './nostrEventParsing'
|
||||
import { buildTagFilter } from './nostrTagSystem'
|
||||
import { getPrimaryRelaySync } from './config'
|
||||
import { PLATFORM_SERVICE } from './platformConfig'
|
||||
|
||||
function buildReviewFilters(articleId: string) {
|
||||
const tagFilter = buildTagFilter({
|
||||
type: 'quote',
|
||||
articleId,
|
||||
service: PLATFORM_SERVICE,
|
||||
})
|
||||
|
||||
const filterObj: {
|
||||
|
||||
@ -4,11 +4,13 @@ import type { Series } from '@/types/nostr'
|
||||
import { parseSeriesFromEvent } from './nostrEventParsing'
|
||||
import { buildTagFilter } from './nostrTagSystem'
|
||||
import { getPrimaryRelaySync } from './config'
|
||||
import { PLATFORM_SERVICE } from './platformConfig'
|
||||
|
||||
function buildSeriesFilters(authorPubkey: string) {
|
||||
const tagFilter = buildTagFilter({
|
||||
type: 'series',
|
||||
authorPubkey,
|
||||
service: PLATFORM_SERVICE,
|
||||
})
|
||||
|
||||
return [
|
||||
@ -62,6 +64,7 @@ function buildSeriesByIdFilters(seriesId: string) {
|
||||
ids: [seriesId],
|
||||
...buildTagFilter({
|
||||
type: 'series',
|
||||
service: PLATFORM_SERVICE,
|
||||
}),
|
||||
},
|
||||
]
|
||||
|
||||
@ -2,6 +2,7 @@ import { nostrService } from './nostr'
|
||||
import type { Article } from '@/types/nostr'
|
||||
import { getPrimaryRelaySync } from './config'
|
||||
import { buildTagFilter, extractTagsFromEvent } from './nostrTagSystem'
|
||||
import { PLATFORM_SERVICE } from './platformConfig'
|
||||
|
||||
function subscribeToPresentation(pool: import('nostr-tools').SimplePool, pubkey: string): Promise<number> {
|
||||
const filters = [
|
||||
@ -9,6 +10,7 @@ function subscribeToPresentation(pool: import('nostr-tools').SimplePool, pubkey:
|
||||
...buildTagFilter({
|
||||
type: 'author',
|
||||
authorPubkey: pubkey,
|
||||
service: PLATFORM_SERVICE,
|
||||
}),
|
||||
limit: 1,
|
||||
},
|
||||
|
||||
56
lib/urlGenerator.ts
Normal file
56
lib/urlGenerator.ts
Normal file
@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Generate URLs for objects in the format:
|
||||
* https://zapwall.fr/<object_type_name>/<id_hash>_<index>_<version>
|
||||
*
|
||||
* - object_type_name: author, series, publication, review
|
||||
* - id_hash: SHA-256 hash of the object
|
||||
* - index: number of objects of the same type with the same hash (0 by default)
|
||||
* - version: version number (0 by default)
|
||||
*/
|
||||
|
||||
export type ObjectTypeName = 'author' | 'series' | 'publication' | 'review'
|
||||
|
||||
/**
|
||||
* Generate URL for an object
|
||||
*/
|
||||
export function generateObjectUrl(
|
||||
objectType: ObjectTypeName,
|
||||
idHash: string,
|
||||
index: number = 0,
|
||||
version: number = 0
|
||||
): string {
|
||||
return `https://zapwall.fr/${objectType}/${idHash}_${index}_${version}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse URL to extract object information
|
||||
*/
|
||||
export function parseObjectUrl(url: string): {
|
||||
objectType: ObjectTypeName | null
|
||||
idHash: string | null
|
||||
index: number | null
|
||||
version: number | null
|
||||
} {
|
||||
const match = url.match(/https?:\/\/zapwall\.fr\/(author|series|publication|review)\/([a-f0-9]+)_(\d+)_(\d+)/i)
|
||||
if (!match) {
|
||||
return { objectType: null, idHash: null, index: null, version: null }
|
||||
}
|
||||
|
||||
return {
|
||||
objectType: match[1] as ObjectTypeName,
|
||||
idHash: match[2],
|
||||
index: parseInt(match[3], 10),
|
||||
version: parseInt(match[4], 10),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the object type name from tag type
|
||||
*/
|
||||
export function getObjectTypeName(tagType: 'author' | 'series' | 'publication' | 'quote'): ObjectTypeName {
|
||||
// Map 'quote' to 'review' for URLs
|
||||
if (tagType === 'quote') {
|
||||
return 'review'
|
||||
}
|
||||
return tagType
|
||||
}
|
||||
139
lib/versionManager.ts
Normal file
139
lib/versionManager.ts
Normal file
@ -0,0 +1,139 @@
|
||||
/**
|
||||
* Version management for objects
|
||||
* Handles versioning and hiding of objects
|
||||
* Only the author (pubkey) who published the original note can modify or delete it
|
||||
*/
|
||||
|
||||
import type { Event } from 'nostr-tools'
|
||||
import { extractTagsFromEvent } from './nostrTagSystem'
|
||||
|
||||
export interface VersionedObject {
|
||||
event: Event
|
||||
version: number
|
||||
hidden: boolean
|
||||
pubkey: string
|
||||
id: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter events to get only the latest version that is not hidden
|
||||
* Groups events by ID and returns the one with the highest version that is not hidden
|
||||
*/
|
||||
export function getLatestVersion(events: Event[]): Event | null {
|
||||
if (events.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Group by ID and find the latest non-hidden version
|
||||
const byId = new Map<string, VersionedObject[]>()
|
||||
|
||||
for (const event of events) {
|
||||
const tags = extractTagsFromEvent(event)
|
||||
if (!tags.id) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (!byId.has(tags.id)) {
|
||||
byId.set(tags.id, [])
|
||||
}
|
||||
|
||||
byId.get(tags.id)!.push({
|
||||
event,
|
||||
version: tags.version,
|
||||
hidden: tags.hidden,
|
||||
pubkey: event.pubkey,
|
||||
id: tags.id,
|
||||
})
|
||||
}
|
||||
|
||||
// For each ID, find the latest non-hidden version
|
||||
const latestVersions: VersionedObject[] = []
|
||||
|
||||
for (const objects of byId.values()) {
|
||||
// Filter out hidden objects
|
||||
const visible = objects.filter((obj) => !obj.hidden)
|
||||
|
||||
if (visible.length === 0) {
|
||||
// All versions are hidden, skip this object
|
||||
continue
|
||||
}
|
||||
|
||||
// Sort by version (descending) and take the first (latest)
|
||||
visible.sort((a, b) => b.version - a.version)
|
||||
latestVersions.push(visible[0])
|
||||
}
|
||||
|
||||
// If we have multiple IDs, we need to return the one with the highest version
|
||||
// But typically we expect one ID per query, so return the first
|
||||
if (latestVersions.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Sort by version and return the latest
|
||||
latestVersions.sort((a, b) => b.version - a.version)
|
||||
return latestVersions[0].event
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all versions of an object (for version history)
|
||||
*/
|
||||
export function getAllVersions(events: Event[]): VersionedObject[] {
|
||||
const versions: VersionedObject[] = []
|
||||
|
||||
for (const event of events) {
|
||||
const tags = extractTagsFromEvent(event)
|
||||
if (!tags.id) {
|
||||
continue
|
||||
}
|
||||
|
||||
versions.push({
|
||||
event,
|
||||
version: tags.version,
|
||||
hidden: tags.hidden,
|
||||
pubkey: event.pubkey,
|
||||
id: tags.id,
|
||||
})
|
||||
}
|
||||
|
||||
// Sort by version (descending)
|
||||
versions.sort((a, b) => b.version - a.version)
|
||||
return versions
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a user can modify or delete an object
|
||||
* Only the original author (pubkey) can modify/delete
|
||||
*/
|
||||
export function canModifyObject(event: Event, userPubkey: string): boolean {
|
||||
return event.pubkey === userPubkey
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the next version number for an object
|
||||
* Finds the highest version and increments it
|
||||
*/
|
||||
export function getNextVersion(events: Event[]): number {
|
||||
if (events.length === 0) {
|
||||
return 0
|
||||
}
|
||||
|
||||
let maxVersion = -1
|
||||
for (const event of events) {
|
||||
const tags = extractTagsFromEvent(event)
|
||||
if (tags.version > maxVersion) {
|
||||
maxVersion = tags.version
|
||||
}
|
||||
}
|
||||
|
||||
return maxVersion + 1
|
||||
}
|
||||
|
||||
/**
|
||||
* Count objects with the same hash ID (for index calculation)
|
||||
*/
|
||||
export function countObjectsWithSameHash(events: Event[], hashId: string): number {
|
||||
return events.filter((event) => {
|
||||
const tags = extractTagsFromEvent(event)
|
||||
return tags.id === hashId
|
||||
}).length
|
||||
}
|
||||
@ -110,6 +110,11 @@ common.loading=Loading...
|
||||
common.loading.articles=Loading articles...
|
||||
common.loading.authors=Loading authors...
|
||||
common.error=Error
|
||||
common.error.noContent=No content found
|
||||
common.empty.articles=No articles found. Check back later!
|
||||
common.empty.articles.filtered=No articles match your search or filters.
|
||||
common.empty.authors=No authors found. Check back later!
|
||||
common.empty.authors.filtered=No authors match your search or filters.
|
||||
common.back=Back
|
||||
common.open=Open
|
||||
|
||||
|
||||
@ -110,6 +110,11 @@ common.loading=Chargement...
|
||||
common.loading.articles=Chargement des articles...
|
||||
common.loading.authors=Chargement des auteurs...
|
||||
common.error=Erreur
|
||||
common.error.noContent=Aucun contenu trouvé
|
||||
common.empty.articles=Aucun article trouvé. Revenez plus tard !
|
||||
common.empty.articles.filtered=Aucun article ne correspond à votre recherche ou à vos filtres.
|
||||
common.empty.authors=Aucun auteur trouvé. Revenez plus tard !
|
||||
common.empty.authors.filtered=Aucun auteur ne correspond à votre recherche ou à vos filtres.
|
||||
common.back=Retour
|
||||
common.open=Ouvrir
|
||||
|
||||
|
||||
@ -72,15 +72,15 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||
|
||||
// Recreate FormData for each request (needed for redirects)
|
||||
const requestFormData = new FormData()
|
||||
const fileStream = fs.createReadStream(fileField.filepath)
|
||||
const fileStream = fs.createReadStream(fileField.filepath)
|
||||
|
||||
// Use 'file' as field name (standard for NIP-95, but some endpoints may use different names)
|
||||
// Note: nostrimg.com might expect a different field name - if issues persist, try 'image' or 'upload'
|
||||
const fieldName = 'file'
|
||||
requestFormData.append(fieldName, fileStream, {
|
||||
filename: fileField.originalFilename || fileField.newFilename || 'upload',
|
||||
contentType: fileField.mimetype || 'application/octet-stream',
|
||||
})
|
||||
filename: fileField.originalFilename || fileField.newFilename || 'upload',
|
||||
contentType: fileField.mimetype || 'application/octet-stream',
|
||||
})
|
||||
|
||||
const isHttps = url.protocol === 'https:'
|
||||
const clientModule = isHttps ? https : http
|
||||
@ -99,12 +99,12 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||
if (url.hostname.includes('nostrimg.com')) {
|
||||
console.log('NIP-95 proxy request to nostrimg.com:', {
|
||||
url: url.toString(),
|
||||
method: 'POST',
|
||||
method: 'POST',
|
||||
fieldName,
|
||||
filename: fileField.originalFilename || fileField.newFilename || 'upload',
|
||||
contentType: fileField.mimetype || 'application/octet-stream',
|
||||
fileSize: fileField.size,
|
||||
headers: {
|
||||
headers: {
|
||||
'Content-Type': headers['content-type'],
|
||||
'Accept': headers['Accept'],
|
||||
'User-Agent': headers['User-Agent'],
|
||||
@ -178,7 +178,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||
statusCode: statusCode,
|
||||
statusMessage: proxyResponse.statusMessage || 'Internal Server Error',
|
||||
body: body,
|
||||
})
|
||||
})
|
||||
})
|
||||
proxyResponse.on('error', (error) => {
|
||||
reject(error)
|
||||
|
||||
@ -110,6 +110,11 @@ common.loading=Loading...
|
||||
common.loading.articles=Loading articles...
|
||||
common.loading.authors=Loading authors...
|
||||
common.error=Error
|
||||
common.error.noContent=No content found
|
||||
common.empty.articles=No articles found. Check back later!
|
||||
common.empty.articles.filtered=No articles match your search or filters.
|
||||
common.empty.authors=No authors found. Check back later!
|
||||
common.empty.authors.filtered=No authors match your search or filters.
|
||||
common.back=Back
|
||||
common.open=Open
|
||||
|
||||
|
||||
@ -110,6 +110,11 @@ common.loading=Chargement...
|
||||
common.loading.articles=Chargement des articles...
|
||||
common.loading.authors=Chargement des auteurs...
|
||||
common.error=Erreur
|
||||
common.error.noContent=Aucun contenu trouvé
|
||||
common.empty.articles=Aucun article trouvé. Revenez plus tard !
|
||||
common.empty.articles.filtered=Aucun article ne correspond à votre recherche ou à vos filtres.
|
||||
common.empty.authors=Aucun auteur trouvé. Revenez plus tard !
|
||||
common.empty.authors.filtered=Aucun auteur ne correspond à votre recherche ou à vos filtres.
|
||||
common.back=Retour
|
||||
common.open=Ouvrir
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user