series building
This commit is contained in:
parent
4a619c9576
commit
758ab5c966
@ -31,7 +31,7 @@ function EmptyState({ hasAny }: { hasAny: boolean }) {
|
|||||||
return (
|
return (
|
||||||
<div className="text-center py-12">
|
<div className="text-center py-12">
|
||||||
<p className="text-cyber-accent/70">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -29,7 +29,7 @@ function EmptyState({ hasAny }: { hasAny: boolean }) {
|
|||||||
return (
|
return (
|
||||||
<div className="text-center py-12">
|
<div className="text-center py-12">
|
||||||
<p className="text-cyber-accent/70">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -4,12 +4,34 @@ import { LanguageSelector } from './LanguageSelector'
|
|||||||
import { t } from '@/lib/i18n'
|
import { t } from '@/lib/i18n'
|
||||||
import { KeyIndicator } from './KeyIndicator'
|
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() {
|
export function PageHeader() {
|
||||||
return (
|
return (
|
||||||
<header className="bg-cyber-dark border-b border-neon-cyan/30 shadow-glow-cyan">
|
<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">
|
<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')}
|
{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 />
|
<KeyIndicator />
|
||||||
</Link>
|
</Link>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
|
|||||||
@ -2,12 +2,34 @@ import { ConnectButton } from '@/components/ConnectButton'
|
|||||||
import { ConditionalPublishButton } from './ConditionalPublishButton'
|
import { ConditionalPublishButton } from './ConditionalPublishButton'
|
||||||
import { KeyIndicator } from './KeyIndicator'
|
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() {
|
export function ProfileHeader() {
|
||||||
return (
|
return (
|
||||||
<header className="bg-white shadow-sm">
|
<header className="bg-white shadow-sm">
|
||||||
<div className="max-w-4xl mx-auto px-4 py-4 flex justify-between items-center">
|
<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
|
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 />
|
<KeyIndicator />
|
||||||
</h1>
|
</h1>
|
||||||
<div className="flex items-center gap-4">
|
<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 type { Article } from '@/types/nostr'
|
||||||
import { applyFiltersAndSort } from '@/lib/articleFiltering'
|
import { applyFiltersAndSort } from '@/lib/articleFiltering'
|
||||||
import type { ArticleFilters } from '@/components/ArticleFilters'
|
import type { ArticleFilters } from '@/components/ArticleFilters'
|
||||||
|
import { t } from '@/lib/i18n'
|
||||||
|
|
||||||
export function useArticles(searchQuery: string = '', filters: ArticleFilters | null = null) {
|
export function useArticles(searchQuery: string = '', filters: ArticleFilters | null = null) {
|
||||||
const [articles, setArticles] = useState<Article[]>([])
|
const [articles, setArticles] = useState<Article[]>([])
|
||||||
@ -32,7 +33,7 @@ export function useArticles(searchQuery: string = '', filters: ArticleFilters |
|
|||||||
const timeout = setTimeout(() => {
|
const timeout = setTimeout(() => {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
if (!hasArticlesRef.current) {
|
if (!hasArticlesRef.current) {
|
||||||
setError('No articles found')
|
setError(t('common.error.noContent'))
|
||||||
}
|
}
|
||||||
}, 10000)
|
}, 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 { getAlbyService } from './alby'
|
||||||
import { calculateArticleSplit, PLATFORM_COMMISSIONS } from './platformCommissions'
|
import { calculateArticleSplit, PLATFORM_COMMISSIONS } from './platformCommissions'
|
||||||
import { buildTags } from './nostrTagSystem'
|
import { buildTags } from './nostrTagSystem'
|
||||||
|
import { PLATFORM_SERVICE } from './platformConfig'
|
||||||
|
import { generatePublicationHashId } from './hashIdGenerator'
|
||||||
import type { AlbyInvoice } from '@/types/alby'
|
import type { AlbyInvoice } from '@/types/alby'
|
||||||
import type { ArticleDraft } from './articlePublisher'
|
import type { ArticleDraft } from './articlePublisher'
|
||||||
|
|
||||||
@ -42,20 +44,21 @@ export async function createArticleInvoice(draft: ArticleDraft): Promise<AlbyInv
|
|||||||
* Create preview event with invoice tags
|
* Create preview event with invoice tags
|
||||||
* If encryptedContent is provided, it will be used instead of preview
|
* If encryptedContent is provided, it will be used instead of preview
|
||||||
*/
|
*/
|
||||||
export function createPreviewEvent(
|
export async function createPreviewEvent(
|
||||||
draft: ArticleDraft,
|
draft: ArticleDraft,
|
||||||
invoice: AlbyInvoice,
|
invoice: AlbyInvoice,
|
||||||
|
authorPubkey: string,
|
||||||
authorPresentationId?: string,
|
authorPresentationId?: string,
|
||||||
extraTags: string[][] = [],
|
extraTags: string[][] = [],
|
||||||
encryptedContent?: string,
|
encryptedContent?: string,
|
||||||
encryptedKey?: string
|
encryptedKey?: string
|
||||||
): {
|
): Promise<{
|
||||||
kind: 1
|
kind: 1
|
||||||
created_at: number
|
created_at: number
|
||||||
tags: string[][]
|
tags: string[][]
|
||||||
content: string
|
content: string
|
||||||
} {
|
}> {
|
||||||
const tags = buildPreviewTags(draft, invoice, authorPresentationId, extraTags, encryptedKey)
|
const tags = await buildPreviewTags(draft, invoice, authorPubkey, authorPresentationId, extraTags, encryptedKey)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
kind: 1 as const,
|
kind: 1 as const,
|
||||||
@ -65,21 +68,36 @@ export function createPreviewEvent(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildPreviewTags(
|
async function buildPreviewTags(
|
||||||
draft: ArticleDraft,
|
draft: ArticleDraft,
|
||||||
invoice: AlbyInvoice,
|
invoice: AlbyInvoice,
|
||||||
|
authorPubkey: string,
|
||||||
_authorPresentationId?: string,
|
_authorPresentationId?: string,
|
||||||
extraTags: string[][] = [],
|
extraTags: string[][] = [],
|
||||||
encryptedKey?: string
|
encryptedKey?: string
|
||||||
): string[][] {
|
): Promise<string[][]> {
|
||||||
// Map category to new system
|
// Map category to new system
|
||||||
const category = draft.category === 'science-fiction' ? 'sciencefiction' : draft.category === 'scientific-research' ? 'research' : 'sciencefiction'
|
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
|
// Build tags using new system
|
||||||
const newTags = buildTags({
|
const newTags = buildTags({
|
||||||
type: 'publication',
|
type: 'publication',
|
||||||
category,
|
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
|
paywall: true, // Publications are paid
|
||||||
title: draft.title,
|
title: draft.title,
|
||||||
preview: draft.preview,
|
preview: draft.preview,
|
||||||
|
|||||||
@ -2,6 +2,8 @@ import { nostrService } from './nostr'
|
|||||||
import { createArticleInvoice, createPreviewEvent } from './articleInvoice'
|
import { createArticleInvoice, createPreviewEvent } from './articleInvoice'
|
||||||
import { storePrivateContent, getStoredPrivateContent } from './articleStorage'
|
import { storePrivateContent, getStoredPrivateContent } from './articleStorage'
|
||||||
import { buildTags } from './nostrTagSystem'
|
import { buildTags } from './nostrTagSystem'
|
||||||
|
import { PLATFORM_SERVICE } from './platformConfig'
|
||||||
|
import { generateSeriesHashId, generatePublicationHashId } from './hashIdGenerator'
|
||||||
import type { ArticleDraft, PublishedArticle } from './articlePublisher'
|
import type { ArticleDraft, PublishedArticle } from './articlePublisher'
|
||||||
import type { AlbyInvoice } from '@/types/alby'
|
import type { AlbyInvoice } from '@/types/alby'
|
||||||
import type { Review, Series } from '@/types/nostr'
|
import type { Review, Series } from '@/types/nostr'
|
||||||
@ -36,10 +38,11 @@ async function ensurePresentation(authorPubkey: string): Promise<string> {
|
|||||||
async function publishPreviewWithInvoice(
|
async function publishPreviewWithInvoice(
|
||||||
draft: ArticleDraft,
|
draft: ArticleDraft,
|
||||||
invoice: AlbyInvoice,
|
invoice: AlbyInvoice,
|
||||||
|
authorPubkey: string,
|
||||||
presentationId: string,
|
presentationId: string,
|
||||||
extraTags?: string[][]
|
extraTags?: string[][]
|
||||||
): Promise<import('nostr-tools').Event | null> {
|
): 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)
|
const publishedEvent = await nostrService.publishEvent(previewEvent)
|
||||||
return publishedEvent ?? null
|
return publishedEvent ?? null
|
||||||
}
|
}
|
||||||
@ -56,7 +59,7 @@ export async function publishSeries(params: {
|
|||||||
ensureKeys(params.authorPubkey, params.authorPrivateKey)
|
ensureKeys(params.authorPubkey, params.authorPrivateKey)
|
||||||
const category = params.category
|
const category = params.category
|
||||||
requireCategory(category)
|
requireCategory(category)
|
||||||
const event = buildSeriesEvent(params, category)
|
const event = await buildSeriesEvent(params, category)
|
||||||
const published = await nostrService.publishEvent(event)
|
const published = await nostrService.publishEvent(event)
|
||||||
if (!published) {
|
if (!published) {
|
||||||
throw new Error('Failed to publish series')
|
throw new Error('Failed to publish series')
|
||||||
@ -73,7 +76,7 @@ export async function publishSeries(params: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildSeriesEvent(
|
async function buildSeriesEvent(
|
||||||
params: {
|
params: {
|
||||||
title: string
|
title: string
|
||||||
description: string
|
description: string
|
||||||
@ -86,6 +89,15 @@ function buildSeriesEvent(
|
|||||||
// Map category to new system
|
// Map category to new system
|
||||||
const newCategory = category === 'science-fiction' ? 'sciencefiction' : 'research'
|
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 {
|
return {
|
||||||
kind: 1,
|
kind: 1,
|
||||||
created_at: Math.floor(Date.now() / 1000),
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
@ -93,7 +105,10 @@ function buildSeriesEvent(
|
|||||||
tags: buildTags({
|
tags: buildTags({
|
||||||
type: 'series',
|
type: 'series',
|
||||||
category: newCategory,
|
category: newCategory,
|
||||||
id: '', // Will be set to event.id after publication
|
id: hashId,
|
||||||
|
service: PLATFORM_SERVICE,
|
||||||
|
version: 0, // New object
|
||||||
|
hidden: false,
|
||||||
paywall: false,
|
paywall: false,
|
||||||
title: params.title,
|
title: params.title,
|
||||||
description: params.description,
|
description: params.description,
|
||||||
@ -116,7 +131,7 @@ export async function publishReview(params: {
|
|||||||
ensureKeys(params.reviewerPubkey, params.authorPrivateKey)
|
ensureKeys(params.reviewerPubkey, params.authorPrivateKey)
|
||||||
const category = params.category
|
const category = params.category
|
||||||
requireCategory(category)
|
requireCategory(category)
|
||||||
const event = buildReviewEvent(params, category)
|
const event = await buildReviewEvent(params, category)
|
||||||
const published = await nostrService.publishEvent(event)
|
const published = await nostrService.publishEvent(event)
|
||||||
if (!published) {
|
if (!published) {
|
||||||
throw new Error('Failed to publish review')
|
throw new Error('Failed to publish review')
|
||||||
@ -133,7 +148,7 @@ export async function publishReview(params: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildReviewEvent(
|
async function buildReviewEvent(
|
||||||
params: {
|
params: {
|
||||||
articleId: string
|
articleId: string
|
||||||
seriesId: string
|
seriesId: string
|
||||||
@ -147,6 +162,16 @@ function buildReviewEvent(
|
|||||||
// Map category to new system
|
// Map category to new system
|
||||||
const newCategory = category === 'science-fiction' ? 'sciencefiction' : 'research'
|
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 {
|
return {
|
||||||
kind: 1,
|
kind: 1,
|
||||||
created_at: Math.floor(Date.now() / 1000),
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
@ -154,7 +179,10 @@ function buildReviewEvent(
|
|||||||
tags: buildTags({
|
tags: buildTags({
|
||||||
type: 'quote',
|
type: 'quote',
|
||||||
category: newCategory,
|
category: newCategory,
|
||||||
id: '', // Will be set to event.id after publication
|
id: hashId,
|
||||||
|
service: PLATFORM_SERVICE,
|
||||||
|
version: 0, // New object
|
||||||
|
hidden: false,
|
||||||
paywall: false,
|
paywall: false,
|
||||||
articleId: params.articleId,
|
articleId: params.articleId,
|
||||||
reviewerPubkey: params.reviewerPubkey,
|
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({
|
const updateTags = buildTags({
|
||||||
type: 'publication',
|
type: 'publication',
|
||||||
category: newCategory,
|
category: newCategory,
|
||||||
id: '', // Will be set to event.id after publication
|
id: hashId,
|
||||||
|
service: PLATFORM_SERVICE,
|
||||||
|
version: nextVersion,
|
||||||
|
hidden: false,
|
||||||
paywall: true,
|
paywall: true,
|
||||||
title: draft.title,
|
title: draft.title,
|
||||||
preview: draft.preview,
|
preview: draft.preview,
|
||||||
@ -189,9 +240,9 @@ async function publishUpdate(
|
|||||||
const presentationId = await ensurePresentation(authorPubkey)
|
const presentationId = await ensurePresentation(authorPubkey)
|
||||||
const invoice = await createArticleInvoice(draft)
|
const invoice = await createArticleInvoice(draft)
|
||||||
const newCategory = category === 'science-fiction' ? 'sciencefiction' : 'research'
|
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) {
|
if (!publishedEvent) {
|
||||||
return updateFailure(originalArticleId, 'Failed to publish article update')
|
return updateFailure(originalArticleId, 'Failed to publish article update')
|
||||||
}
|
}
|
||||||
|
|||||||
@ -171,9 +171,12 @@ export class ArticlePublisher {
|
|||||||
nostrService.setPublicKey(authorPubkey)
|
nostrService.setPublicKey(authorPubkey)
|
||||||
nostrService.setPrivateKey(authorPrivateKey)
|
nostrService.setPrivateKey(authorPrivateKey)
|
||||||
|
|
||||||
// Generate event ID before building event (using a temporary ID that will be replaced by Nostr)
|
// Extract author name from title (format: "Présentation de <name>")
|
||||||
const tempEventId = `temp_${Math.random().toString(36).substring(7)}`
|
const authorName = draft.title.replace(/^Présentation de /, '').trim() || 'Auteur'
|
||||||
const publishedEvent = await nostrService.publishEvent(buildPresentationEvent(draft, tempEventId, 'sciencefiction'))
|
|
||||||
|
// Build event with hash-based ID
|
||||||
|
const eventTemplate = await buildPresentationEvent(draft, authorPubkey, authorName, 'sciencefiction')
|
||||||
|
const publishedEvent = await nostrService.publishEvent(eventTemplate)
|
||||||
|
|
||||||
if (!publishedEvent) {
|
if (!publishedEvent) {
|
||||||
return buildFailure('Failed to publish presentation article')
|
return buildFailure('Failed to publish presentation article')
|
||||||
|
|||||||
@ -1,25 +1,92 @@
|
|||||||
import { type Event } from 'nostr-tools'
|
import { type Event } from 'nostr-tools'
|
||||||
|
import { nip19 } from 'nostr-tools'
|
||||||
import type { AuthorPresentationDraft } from './articlePublisher'
|
import type { AuthorPresentationDraft } from './articlePublisher'
|
||||||
import type { SimplePoolWithSub } from '@/types/nostr-tools-extended'
|
import type { SimplePoolWithSub } from '@/types/nostr-tools-extended'
|
||||||
import { buildTags, extractTagsFromEvent, buildTagFilter } from './nostrTagSystem'
|
import { buildTags, extractTagsFromEvent, buildTagFilter } from './nostrTagSystem'
|
||||||
import { getPrimaryRelaySync } from './config'
|
import { getPrimaryRelaySync } from './config'
|
||||||
|
import { PLATFORM_SERVICE } from './platformConfig'
|
||||||
|
import { generateAuthorHashId } from './hashIdGenerator'
|
||||||
|
import { generateObjectUrl } from './urlGenerator'
|
||||||
|
import { getLatestVersion } from './versionManager'
|
||||||
|
|
||||||
export function buildPresentationEvent(draft: AuthorPresentationDraft, eventId: string, category: 'sciencefiction' | 'research' = 'sciencefiction') {
|
export async function buildPresentationEvent(
|
||||||
return {
|
draft: AuthorPresentationDraft,
|
||||||
kind: 1 as const,
|
authorPubkey: string,
|
||||||
created_at: Math.floor(Date.now() / 1000),
|
authorName: string,
|
||||||
tags: buildTags({
|
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',
|
type: 'author',
|
||||||
category,
|
category,
|
||||||
id: eventId,
|
id: hashId,
|
||||||
|
service: PLATFORM_SERVICE,
|
||||||
|
version,
|
||||||
|
hidden: false,
|
||||||
paywall: false,
|
paywall: false,
|
||||||
title: draft.title,
|
title: draft.title,
|
||||||
preview: draft.preview,
|
preview: draft.preview,
|
||||||
mainnetAddress: draft.mainnetAddress,
|
mainnetAddress: draft.mainnetAddress,
|
||||||
totalSponsoring: 0,
|
totalSponsoring: 0,
|
||||||
...(draft.pictureUrl ? { pictureUrl: draft.pictureUrl } : {}),
|
...(draft.pictureUrl ? { pictureUrl: draft.pictureUrl } : {}),
|
||||||
}),
|
})
|
||||||
content: draft.content,
|
|
||||||
|
return {
|
||||||
|
kind: 1 as const,
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
tags,
|
||||||
|
content: fullContent,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -31,10 +98,27 @@ export function parsePresentationEvent(event: Event): import('@/types/nostr').Au
|
|||||||
return null
|
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
|
// Map tag category to article category
|
||||||
const articleCategory = tags.category === 'sciencefiction' ? 'science-fiction' : tags.category === 'research' ? 'scientific-research' : undefined
|
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,
|
id: tags.id ?? event.id,
|
||||||
pubkey: event.pubkey,
|
pubkey: event.pubkey,
|
||||||
title: tags.title ?? 'Présentation',
|
title: tags.title ?? 'Présentation',
|
||||||
@ -45,11 +129,19 @@ export function parsePresentationEvent(event: Event): import('@/types/nostr').Au
|
|||||||
paid: true,
|
paid: true,
|
||||||
category: 'author-presentation',
|
category: 'author-presentation',
|
||||||
isPresentation: true,
|
isPresentation: true,
|
||||||
mainnetAddress: tags.mainnetAddress ?? '',
|
mainnetAddress: profileData?.mainnetAddress ?? tags.mainnetAddress ?? '',
|
||||||
totalSponsoring: tags.totalSponsoring ?? 0,
|
totalSponsoring: tags.totalSponsoring ?? 0,
|
||||||
originalCategory: articleCategory, // Store original category for filtering
|
originalCategory: articleCategory ?? 'science-fiction', // Store original category for filtering
|
||||||
...(tags.pictureUrl !== undefined && tags.pictureUrl !== null && typeof tags.pictureUrl === 'string' ? { bannerUrl: tags.pictureUrl } : {}),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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(
|
export function fetchAuthorPresentationFromPool(
|
||||||
@ -61,8 +153,9 @@ export function fetchAuthorPresentationFromPool(
|
|||||||
...buildTagFilter({
|
...buildTagFilter({
|
||||||
type: 'author',
|
type: 'author',
|
||||||
authorPubkey: pubkey,
|
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 { createSubscription } = require('@/types/nostr-tools-extended')
|
||||||
const sub = createSubscription(pool, [relayUrl], filters)
|
const sub = createSubscription(pool, [relayUrl], filters)
|
||||||
|
|
||||||
|
const events: Event[] = []
|
||||||
|
|
||||||
const finalize = (value: import('@/types/nostr').AuthorPresentationArticle | null) => {
|
const finalize = (value: import('@/types/nostr').AuthorPresentationArticle | null) => {
|
||||||
if (resolved) {
|
if (resolved) {
|
||||||
return
|
return
|
||||||
@ -82,13 +177,36 @@ export function fetchAuthorPresentationFromPool(
|
|||||||
}
|
}
|
||||||
|
|
||||||
sub.on('event', (event: Event) => {
|
sub.on('event', (event: Event) => {
|
||||||
const parsed = parsePresentationEvent(event)
|
// Collect all events first
|
||||||
if (parsed) {
|
const tags = extractTagsFromEvent(event)
|
||||||
finalize(parsed)
|
if (tags.type === 'author' && !tags.hidden) {
|
||||||
|
events.push(event)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
sub.on('eose', () => finalize(null))
|
sub.on('eose', () => {
|
||||||
setTimeout(() => finalize(null), 5000)
|
// 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(
|
export async function publishPreview(
|
||||||
draft: ArticleDraft,
|
draft: ArticleDraft,
|
||||||
invoice: AlbyInvoice,
|
invoice: AlbyInvoice,
|
||||||
|
authorPubkey: string,
|
||||||
presentationId: string,
|
presentationId: string,
|
||||||
extraTags?: string[][],
|
extraTags?: string[][],
|
||||||
encryptedContent?: string,
|
encryptedContent?: string,
|
||||||
encryptedKey?: string
|
encryptedKey?: string
|
||||||
): Promise<import('nostr-tools').Event | null> {
|
): 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)
|
const publishedEvent = await nostrService.publishEvent(previewEvent)
|
||||||
return publishedEvent ?? null
|
return publishedEvent ?? null
|
||||||
}
|
}
|
||||||
@ -49,7 +50,7 @@ export async function encryptAndPublish(
|
|||||||
const encryptedKey = await encryptDecryptionKey(key, iv, authorPrivateKeyForEncryption, authorPubkey)
|
const encryptedKey = await encryptDecryptionKey(key, iv, authorPrivateKeyForEncryption, authorPubkey)
|
||||||
const invoice = await createArticleInvoice(draft)
|
const invoice = await createArticleInvoice(draft)
|
||||||
const extraTags = buildArticleExtraTags(draft, category)
|
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) {
|
if (!publishedEvent) {
|
||||||
return buildFailure('Failed to publish article')
|
return buildFailure('Failed to publish article')
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import type { Article } from '@/types/nostr'
|
|||||||
import { parseArticleFromEvent } from './nostrEventParsing'
|
import { parseArticleFromEvent } from './nostrEventParsing'
|
||||||
import { buildTagFilter } from './nostrTagSystem'
|
import { buildTagFilter } from './nostrTagSystem'
|
||||||
import { getPrimaryRelaySync } from './config'
|
import { getPrimaryRelaySync } from './config'
|
||||||
|
import { PLATFORM_SERVICE } from './platformConfig'
|
||||||
|
|
||||||
function createSeriesSubscription(pool: SimplePool, seriesId: string, limit: number) {
|
function createSeriesSubscription(pool: SimplePool, seriesId: string, limit: number) {
|
||||||
const filters = [
|
const filters = [
|
||||||
@ -13,6 +14,7 @@ function createSeriesSubscription(pool: SimplePool, seriesId: string, limit: num
|
|||||||
...buildTagFilter({
|
...buildTagFilter({
|
||||||
type: 'publication',
|
type: 'publication',
|
||||||
seriesId,
|
seriesId,
|
||||||
|
service: PLATFORM_SERVICE,
|
||||||
}),
|
}),
|
||||||
limit,
|
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,
|
||||||
|
})
|
||||||
|
}
|
||||||
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 type { Article, NostrProfile } from '@/types/nostr'
|
||||||
import { createSubscription } from '@/types/nostr-tools-extended'
|
import { createSubscription } from '@/types/nostr-tools-extended'
|
||||||
import { parseArticleFromEvent } from './nostrEventParsing'
|
import { parseArticleFromEvent } from './nostrEventParsing'
|
||||||
|
import { parsePresentationEvent } from './articlePublisherHelpersPresentation'
|
||||||
import {
|
import {
|
||||||
getPrivateContent as getPrivateContentFromPool,
|
getPrivateContent as getPrivateContentFromPool,
|
||||||
getDecryptionKey,
|
getDecryptionKey,
|
||||||
@ -12,6 +13,7 @@ import { checkZapReceipt as checkZapReceiptHelper } from './nostrZapVerification
|
|||||||
import { subscribeWithTimeout } from './nostrSubscription'
|
import { subscribeWithTimeout } from './nostrSubscription'
|
||||||
import { getPrimaryRelay, getPrimaryRelaySync } from './config'
|
import { getPrimaryRelay, getPrimaryRelaySync } from './config'
|
||||||
import { buildTagFilter } from './nostrTagSystem'
|
import { buildTagFilter } from './nostrTagSystem'
|
||||||
|
import { PLATFORM_SERVICE } from './platformConfig'
|
||||||
|
|
||||||
class NostrService {
|
class NostrService {
|
||||||
private pool: SimplePool | null = null
|
private pool: SimplePool | null = null
|
||||||
@ -84,10 +86,21 @@ class NostrService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private createArticleSubscription(pool: SimplePool, limit: number) {
|
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 = [
|
const filters = [
|
||||||
{
|
{
|
||||||
...buildTagFilter({
|
...buildTagFilter({
|
||||||
type: 'publication',
|
type: 'publication',
|
||||||
|
service: PLATFORM_SERVICE,
|
||||||
|
}),
|
||||||
|
limit,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...buildTagFilter({
|
||||||
|
type: 'author',
|
||||||
|
service: PLATFORM_SERVICE,
|
||||||
}),
|
}),
|
||||||
limit,
|
limit,
|
||||||
},
|
},
|
||||||
@ -113,7 +126,15 @@ class NostrService {
|
|||||||
|
|
||||||
sub.on('event', (event: Event) => {
|
sub.on('event', (event: Event) => {
|
||||||
try {
|
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) {
|
if (article) {
|
||||||
callback(article)
|
callback(article)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,6 +5,13 @@ function buildBaseTags(tags: AuthorTags | SeriesTags | PublicationTags | QuoteTa
|
|||||||
result.push([tags.type])
|
result.push([tags.type])
|
||||||
result.push([tags.category])
|
result.push([tags.category])
|
||||||
result.push(['id', tags.id])
|
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) {
|
if (tags.paywall) {
|
||||||
result.push(['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) {
|
export function extractCommonTags(findTag: (key: string) => string | undefined, hasTag: (key: string) => boolean) {
|
||||||
return {
|
return {
|
||||||
id: findTag('id'),
|
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'),
|
paywall: hasTag('paywall'),
|
||||||
payment: hasTag('payment'),
|
payment: hasTag('payment'),
|
||||||
title: findTag('title'),
|
title: findTag('title'),
|
||||||
@ -45,6 +48,9 @@ export function extractTagsFromEvent(event: { tags: string[][] }): {
|
|||||||
type?: TagType | undefined
|
type?: TagType | undefined
|
||||||
category?: TagCategory | undefined
|
category?: TagCategory | undefined
|
||||||
id?: string | undefined
|
id?: string | undefined
|
||||||
|
service?: string | undefined
|
||||||
|
version: number
|
||||||
|
hidden: boolean
|
||||||
paywall: boolean
|
paywall: boolean
|
||||||
payment: boolean
|
payment: boolean
|
||||||
title?: string | undefined
|
title?: string | undefined
|
||||||
|
|||||||
@ -16,6 +16,7 @@ export function buildTagFilter(params: {
|
|||||||
type?: TagType
|
type?: TagType
|
||||||
category?: TagCategory
|
category?: TagCategory
|
||||||
id?: string
|
id?: string
|
||||||
|
service?: string
|
||||||
paywall?: boolean
|
paywall?: boolean
|
||||||
payment?: boolean
|
payment?: boolean
|
||||||
seriesId?: string
|
seriesId?: string
|
||||||
@ -33,6 +34,7 @@ export function buildTagFilter(params: {
|
|||||||
filter[`#${params.category}`] = ['']
|
filter[`#${params.category}`] = ['']
|
||||||
}
|
}
|
||||||
addValueTagFilter(filter, 'id', params.id)
|
addValueTagFilter(filter, 'id', params.id)
|
||||||
|
addValueTagFilter(filter, 'service', params.service)
|
||||||
addSimpleTagFilter(filter, 'paywall', params.paywall === true)
|
addSimpleTagFilter(filter, 'paywall', params.paywall === true)
|
||||||
addSimpleTagFilter(filter, 'payment', params.payment === true)
|
addSimpleTagFilter(filter, 'payment', params.payment === true)
|
||||||
addValueTagFilter(filter, 'series', params.seriesId)
|
addValueTagFilter(filter, 'series', params.seriesId)
|
||||||
|
|||||||
@ -4,6 +4,7 @@
|
|||||||
* - #sciencefiction or #research: for category
|
* - #sciencefiction or #research: for category
|
||||||
* - #author, #series, #publication, #quote: for type
|
* - #author, #series, #publication, #quote: for type
|
||||||
* - #id_<id>: for identifier
|
* - #id_<id>: for identifier
|
||||||
|
* - #service: service identifier (e.g., "zapwall.fr") to filter all notes from this platform
|
||||||
* - #payment (optional): for payment notes
|
* - #payment (optional): for payment notes
|
||||||
*
|
*
|
||||||
* Everything is a Nostr note (kind 1)
|
* Everything is a Nostr note (kind 1)
|
||||||
@ -17,6 +18,9 @@ export interface BaseTags {
|
|||||||
type: TagType
|
type: TagType
|
||||||
category: TagCategory
|
category: TagCategory
|
||||||
id: string
|
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
|
paywall?: boolean
|
||||||
payment?: 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_NPUB = 'npub18s03s39fa80ce2n3cmm0zme3jqehc82h6ld9sxq03uejqm3d05gsae0fuu'
|
||||||
export const PLATFORM_BITCOIN_ADDRESS = 'bc1qerauk5yhqytl6z93ckvwkylup8s0256uenzg9y'
|
export const PLATFORM_BITCOIN_ADDRESS = 'bc1qerauk5yhqytl6z93ckvwkylup8s0256uenzg9y'
|
||||||
|
export const PLATFORM_SERVICE = 'zapwall.fr'
|
||||||
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()
|
|
||||||
}
|
|
||||||
|
|||||||
@ -2,13 +2,45 @@ import type { Article } from '@/types/nostr'
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Extract presentation data from article content
|
* 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): {
|
export function extractPresentationData(presentation: Article): {
|
||||||
presentation: string
|
presentation: string
|
||||||
contentDescription: string
|
contentDescription: string
|
||||||
} {
|
} {
|
||||||
const content = presentation.content
|
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 separator = '\n\n---\n\nDescription du contenu :\n'
|
||||||
const separatorIndex = content.indexOf(separator)
|
const separatorIndex = content.indexOf(separator)
|
||||||
|
|
||||||
|
|||||||
@ -4,11 +4,13 @@ import type { Review } from '@/types/nostr'
|
|||||||
import { parseReviewFromEvent } from './nostrEventParsing'
|
import { parseReviewFromEvent } from './nostrEventParsing'
|
||||||
import { buildTagFilter } from './nostrTagSystem'
|
import { buildTagFilter } from './nostrTagSystem'
|
||||||
import { getPrimaryRelaySync } from './config'
|
import { getPrimaryRelaySync } from './config'
|
||||||
|
import { PLATFORM_SERVICE } from './platformConfig'
|
||||||
|
|
||||||
function buildReviewFilters(articleId: string) {
|
function buildReviewFilters(articleId: string) {
|
||||||
const tagFilter = buildTagFilter({
|
const tagFilter = buildTagFilter({
|
||||||
type: 'quote',
|
type: 'quote',
|
||||||
articleId,
|
articleId,
|
||||||
|
service: PLATFORM_SERVICE,
|
||||||
})
|
})
|
||||||
|
|
||||||
const filterObj: {
|
const filterObj: {
|
||||||
|
|||||||
@ -4,11 +4,13 @@ import type { Series } from '@/types/nostr'
|
|||||||
import { parseSeriesFromEvent } from './nostrEventParsing'
|
import { parseSeriesFromEvent } from './nostrEventParsing'
|
||||||
import { buildTagFilter } from './nostrTagSystem'
|
import { buildTagFilter } from './nostrTagSystem'
|
||||||
import { getPrimaryRelaySync } from './config'
|
import { getPrimaryRelaySync } from './config'
|
||||||
|
import { PLATFORM_SERVICE } from './platformConfig'
|
||||||
|
|
||||||
function buildSeriesFilters(authorPubkey: string) {
|
function buildSeriesFilters(authorPubkey: string) {
|
||||||
const tagFilter = buildTagFilter({
|
const tagFilter = buildTagFilter({
|
||||||
type: 'series',
|
type: 'series',
|
||||||
authorPubkey,
|
authorPubkey,
|
||||||
|
service: PLATFORM_SERVICE,
|
||||||
})
|
})
|
||||||
|
|
||||||
return [
|
return [
|
||||||
@ -62,6 +64,7 @@ function buildSeriesByIdFilters(seriesId: string) {
|
|||||||
ids: [seriesId],
|
ids: [seriesId],
|
||||||
...buildTagFilter({
|
...buildTagFilter({
|
||||||
type: 'series',
|
type: 'series',
|
||||||
|
service: PLATFORM_SERVICE,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { nostrService } from './nostr'
|
|||||||
import type { Article } from '@/types/nostr'
|
import type { Article } from '@/types/nostr'
|
||||||
import { getPrimaryRelaySync } from './config'
|
import { getPrimaryRelaySync } from './config'
|
||||||
import { buildTagFilter, extractTagsFromEvent } from './nostrTagSystem'
|
import { buildTagFilter, extractTagsFromEvent } from './nostrTagSystem'
|
||||||
|
import { PLATFORM_SERVICE } from './platformConfig'
|
||||||
|
|
||||||
function subscribeToPresentation(pool: import('nostr-tools').SimplePool, pubkey: string): Promise<number> {
|
function subscribeToPresentation(pool: import('nostr-tools').SimplePool, pubkey: string): Promise<number> {
|
||||||
const filters = [
|
const filters = [
|
||||||
@ -9,6 +10,7 @@ function subscribeToPresentation(pool: import('nostr-tools').SimplePool, pubkey:
|
|||||||
...buildTagFilter({
|
...buildTagFilter({
|
||||||
type: 'author',
|
type: 'author',
|
||||||
authorPubkey: pubkey,
|
authorPubkey: pubkey,
|
||||||
|
service: PLATFORM_SERVICE,
|
||||||
}),
|
}),
|
||||||
limit: 1,
|
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.articles=Loading articles...
|
||||||
common.loading.authors=Loading authors...
|
common.loading.authors=Loading authors...
|
||||||
common.error=Error
|
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.back=Back
|
||||||
common.open=Open
|
common.open=Open
|
||||||
|
|
||||||
|
|||||||
@ -110,6 +110,11 @@ common.loading=Chargement...
|
|||||||
common.loading.articles=Chargement des articles...
|
common.loading.articles=Chargement des articles...
|
||||||
common.loading.authors=Chargement des auteurs...
|
common.loading.authors=Chargement des auteurs...
|
||||||
common.error=Erreur
|
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.back=Retour
|
||||||
common.open=Ouvrir
|
common.open=Ouvrir
|
||||||
|
|
||||||
|
|||||||
@ -110,6 +110,11 @@ common.loading=Loading...
|
|||||||
common.loading.articles=Loading articles...
|
common.loading.articles=Loading articles...
|
||||||
common.loading.authors=Loading authors...
|
common.loading.authors=Loading authors...
|
||||||
common.error=Error
|
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.back=Back
|
||||||
common.open=Open
|
common.open=Open
|
||||||
|
|
||||||
|
|||||||
@ -110,6 +110,11 @@ common.loading=Chargement...
|
|||||||
common.loading.articles=Chargement des articles...
|
common.loading.articles=Chargement des articles...
|
||||||
common.loading.authors=Chargement des auteurs...
|
common.loading.authors=Chargement des auteurs...
|
||||||
common.error=Erreur
|
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.back=Retour
|
||||||
common.open=Ouvrir
|
common.open=Ouvrir
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user