From 4b2c157938a5d2c3b73003f4e91c341b13ae4b4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Lohrer?= Date: Tue, 28 Apr 2026 08:10:55 +0200 Subject: [PATCH] feat(snapshot): post-json-builder mit fallback-summary Co-Authored-By: Claude Opus 4.7 (1M context) --- snapshot/src/core/post-json.ts | 108 +++++++++++++++++++++++++++++++ snapshot/tests/post-json.test.ts | 83 ++++++++++++++++++++++++ 2 files changed, 191 insertions(+) create mode 100644 snapshot/src/core/post-json.ts create mode 100644 snapshot/tests/post-json.test.ts diff --git a/snapshot/src/core/post-json.ts b/snapshot/src/core/post-json.ts new file mode 100644 index 0000000..b28e510 --- /dev/null +++ b/snapshot/src/core/post-json.ts @@ -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, +): 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, + } +} diff --git a/snapshot/tests/post-json.test.ts b/snapshot/tests/post-json.test.ts new file mode 100644 index 0000000..1d2b89b --- /dev/null +++ b/snapshot/tests/post-json.test.ts @@ -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') +})