feat(snapshot): post-json-builder mit fallback-summary

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jörg Lohrer 2026-04-28 08:10:55 +02:00
parent 7e38b73785
commit 4b2c157938
2 changed files with 191 additions and 0 deletions

View File

@ -0,0 +1,108 @@
import { nip19 } from 'nostr-tools'
import type { SignedEvent } from './types.ts'
export interface CoverImage {
url: string
width?: number
height?: number
alt?: string
mime?: string
}
export interface TranslationRef {
lang: string
slug: string
title: string
}
export interface PostJson {
slug: string
event_id: string
created_at: number
published_at: number
title: string
summary: string
lang: string
cover_image: CoverImage | null
content_markdown: string
tags: string[]
naddr: string
habla_url: string
translations: TranslationRef[]
}
const SUMMARY_MAX = 200
function tagValue(ev: SignedEvent, name: string): string | undefined {
return ev.tags.find((t) => t[0] === name)?.[1]
}
function tagsAll(ev: SignedEvent, name: string): string[] {
return ev.tags.filter((t) => t[0] === name).map((t) => t[1])
}
function deriveSummary(content: string): string {
const flat = content.replace(/\s+/g, ' ').trim()
if (flat.length <= SUMMARY_MAX) return flat
const cut = flat.slice(0, SUMMARY_MAX)
const lastSpace = cut.lastIndexOf(' ')
const trimmed = lastSpace > SUMMARY_MAX * 0.5 ? cut.slice(0, lastSpace) : cut
return trimmed + '…'
}
export function buildPostJson(
ev: SignedEvent,
titleByDtag: Map<string, string>,
): PostJson {
const slug = tagValue(ev, 'd') ?? ''
const title = tagValue(ev, 'title') ?? ''
const summaryTag = tagValue(ev, 'summary')
const summary = summaryTag && summaryTag.length > 0 ? summaryTag : deriveSummary(ev.content)
const image = tagValue(ev, 'image')
const publishedAtRaw = tagValue(ev, 'published_at')
const publishedAt = publishedAtRaw ? parseInt(publishedAtRaw, 10) : ev.created_at
const lang = ev.tags.find((t) => t[0] === 'l' && t[2] === 'ISO-639-1')?.[1] ?? 'de'
const cover_image: CoverImage | null = image
? { url: image, alt: title || undefined }
: null
const naddr = nip19.naddrEncode({
kind: ev.kind,
pubkey: ev.pubkey,
identifier: slug,
})
const translations: TranslationRef[] = []
for (const tag of ev.tags) {
if (tag[0] !== 'a') continue
if (tag[3] !== 'translation') continue
const coord = tag[1]
if (!coord) continue
const parts = coord.split(':')
if (parts.length !== 3) continue
const otherSlug = parts[2]
const otherTitle = titleByDtag.get(otherSlug) ?? otherSlug
translations.push({
lang: lang === 'de' ? 'en' : 'de',
slug: otherSlug,
title: otherTitle,
})
}
return {
slug,
event_id: ev.id,
created_at: ev.created_at,
published_at: publishedAt,
title,
summary,
lang,
cover_image,
content_markdown: ev.content,
tags: tagsAll(ev, 't'),
naddr,
habla_url: `https://habla.news/a/${naddr}`,
translations,
}
}

View File

@ -0,0 +1,83 @@
import { assertEquals } from '@std/assert'
import { buildPostJson } from '../src/core/post-json.ts'
import type { SignedEvent } from '../src/core/types.ts'
const PUBKEY = '4fa5d1c413e2b45e10d40bf3562ab701a5331206e359c90baae0e99bfd6c6e41'
function buildEvent(opts: {
d: string
title: string
summary?: string
image?: string
publishedAt?: number
lang?: string
tags?: string[]
translationCoords?: string[]
content: string
}): SignedEvent {
const tags: string[][] = [['d', opts.d], ['title', opts.title]]
if (opts.summary) tags.push(['summary', opts.summary])
if (opts.image) tags.push(['image', opts.image])
if (opts.publishedAt) tags.push(['published_at', String(opts.publishedAt)])
if (opts.lang) {
tags.push(['L', 'ISO-639-1'])
tags.push(['l', opts.lang, 'ISO-639-1'])
}
for (const t of opts.tags ?? []) tags.push(['t', t])
for (const c of opts.translationCoords ?? []) tags.push(['a', c, '', 'translation'])
return {
id: 'event-' + opts.d, pubkey: PUBKEY, created_at: 1700000000, kind: 30023,
sig: 'sig', content: opts.content, tags,
}
}
Deno.test('buildPostJson: vollstaendiges event', () => {
const ev = buildEvent({
d: 'bibel-selfies', title: 'Bibel-Selfies', summary: 'Kurz',
image: 'https://blossom.edufeed.org/abc.jpg',
publishedAt: 1699000000, lang: 'de', tags: ['Bibel'],
translationCoords: [`30023:${PUBKEY}:bible-selfies`],
content: '# body',
})
const titleByDtag = new Map([['bible-selfies', 'Bible-Selfies']])
const json = buildPostJson(ev, titleByDtag)
assertEquals(json.slug, 'bibel-selfies')
assertEquals(json.title, 'Bibel-Selfies')
assertEquals(json.summary, 'Kurz')
assertEquals(json.lang, 'de')
assertEquals(json.tags, ['Bibel'])
assertEquals(json.published_at, 1699000000)
assertEquals(json.cover_image?.url, 'https://blossom.edufeed.org/abc.jpg')
assertEquals(json.translations, [
{ lang: 'en', slug: 'bible-selfies', title: 'Bible-Selfies' },
])
assertEquals(json.content_markdown, '# body')
})
Deno.test('buildPostJson: fallback summary aus content', () => {
const ev = buildEvent({
d: 'no-summary', title: 'X', content: 'Lorem ipsum dolor sit amet.'.repeat(20),
})
const json = buildPostJson(ev, new Map())
if (!json.summary) throw new Error('summary fehlt')
if (json.summary.length > 220) throw new Error('summary zu lang')
if (!json.summary.endsWith('…')) throw new Error('summary ohne ellipsis')
})
Deno.test('buildPostJson: fehlt published_at -> created_at', () => {
const ev = buildEvent({ d: 'no-pub', title: 'X', content: 'x' })
const json = buildPostJson(ev, new Map())
assertEquals(json.published_at, 1700000000)
})
Deno.test('buildPostJson: fehlt image -> cover_image null', () => {
const ev = buildEvent({ d: 'no-img', title: 'X', content: 'x' })
const json = buildPostJson(ev, new Map())
assertEquals(json.cover_image, null)
})
Deno.test('buildPostJson: lang default de wenn keine l-tags', () => {
const ev = buildEvent({ d: 'no-lang', title: 'X', content: 'x' })
const json = buildPostJson(ev, new Map())
assertEquals(json.lang, 'de')
})