From b9960036c3469d7f77539089b70cc3f1d2ba5149 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Thu, 19 Mar 2026 13:21:50 +0100 Subject: [PATCH] populate a spell from a list --- src/i18n/locales/de.ts | 24 ++ src/i18n/locales/en.ts | 23 ++ src/lib/event.ts | 25 ++- src/lib/spell-list-import.ts | 198 +++++++++++++++++ .../primary/SpellsPage/CreateSpellDialog.tsx | 209 +++++++++++++++++- src/services/indexed-db.service.ts | 8 +- 6 files changed, 478 insertions(+), 9 deletions(-) create mode 100644 src/lib/spell-list-import.ts diff --git a/src/i18n/locales/de.ts b/src/i18n/locales/de.ts index ba8ebaa3..8dedb3ef 100644 --- a/src/i18n/locales/de.ts +++ b/src/i18n/locales/de.ts @@ -712,6 +712,30 @@ export default { 'Saving…': 'Speichern…', Clear: 'Leeren', + listImportManualLabel: 'Aus Event vorausfüllen', + listImportFromEventHint: + 'Unterstützte Tags werden ins Formular übernommen (e, p, t, relay, r, a wo möglich). Nicht leerer Inhalt wird übersprungen; verschlüsselte private Einträge werden nicht gelesen. Es können Hinweise zu nicht abgebildeten Tags erscheinen.', + listImportManualPlaceholder: '64 Zeichen Hex, nevent1… oder naddr1…', + listImportLoadManual: 'Anwenden', + listImportContentSkipped: + 'Dieses Event hat nicht leeren Inhalt (evtl. verschlüsselte private Einträge). Es wurden nur öffentliche Tags übernommen.', + listImportUnsupportedEmoji: + 'Diese Liste enthält Emoji-Tags (NIP-30); die werden nicht in den Zauber-Filter übernommen.', + listImportUnsupportedTag: + 'Tags vom Typ „{{tag}}“ ({{count}}) werden noch nicht auf Zauber-Filter abgebildet.', + listImportBadATag: 'Adress-Tag nicht lesbar: {{preview}}…', + listImportATagNotFound: 'Adress-Tag nicht gefunden: {{preview}}…', + listImportATagFailed: 'Adress-Tag konnte nicht aufgelöst werden: {{preview}}…', + listImportEventNotFound: 'Kein Event zu dieser Referenz gefunden.', + 'REQ tag filters': 'REQ-Tag-Filter', + spellTagFiltersHint: + 'Optionale Filter auf abonnierte Events (NIP-01 Ein-Buchstaben-Tags). Beispiel: Buchstabe „t“, Werte „bitcoin“.', + spellTagFiltersEmpty: + 'Noch keine Tag-Filter. Zeilen unten hinzufügen oder oben eine Event-Referenz anwenden.', + 'Tag filter letter': 'Tag-Buchstabe', + 'Filter value': 'Wert', + 'Add tag filter': 'Tag-Filter hinzufügen', + 'doublePane.secondaryEmpty': 'Öffne eine Notiz, ein Profil oder Einstellungen, um sie hier anzuzeigen.', 'doublePane.secondaryEmptyHint': 'Feed und Hauptseiten bleiben links.' diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 9caf9b64..37e87ae2 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -747,6 +747,29 @@ export default { 'REQ returns a feed; COUNT returns a number.': 'REQ: scrollable feed (choose live Feed or one-shot Fetch below). COUNT: a single number, no feed.', + listImportManualLabel: 'Pre-fill from event', + listImportFromEventHint: + 'Supported tags are merged into the form (e, p, t, relay, r, a where possible). Non-empty content is skipped; encrypted private items are not read. You may see notices for unmapped tags.', + listImportManualPlaceholder: '64-char hex, nevent1…, or naddr1…', + listImportLoadManual: 'Apply', + listImportContentSkipped: + 'This event has non-empty content (may include encrypted private items). Only public tags were merged.', + listImportUnsupportedEmoji: + 'This list includes emoji tags (NIP-30); those are not added to the spell filter.', + listImportUnsupportedTag: + 'Tags of type “{{tag}}” ({{count}}) are not mapped to spell filters yet.', + listImportBadATag: 'Could not parse address tag: {{preview}}…', + listImportATagNotFound: 'Could not resolve address tag: {{preview}}…', + listImportATagFailed: 'Failed to resolve address tag: {{preview}}…', + listImportEventNotFound: 'No event found for that reference.', + 'REQ tag filters': 'REQ tag filters', + spellTagFiltersHint: + 'Optional filters on subscribed events (NIP-01 single-letter tags). Example: letter “t”, values “bitcoin”.', + spellTagFiltersEmpty: 'No tag filters yet. Add rows below or apply an event reference above.', + 'Tag filter letter': 'Tag letter', + 'Filter value': 'Value', + 'Add tag filter': 'Add tag filter', + Spells: 'Spells', 'doublePane.secondaryEmpty': 'Open a note, profile, or settings item to show it here.', diff --git a/src/lib/event.ts b/src/lib/event.ts index cc6417f6..6e3cde19 100644 --- a/src/lib/event.ts +++ b/src/lib/event.ts @@ -185,7 +185,7 @@ export function getReplaceableCoordinate(kind: number, pubkey: string, d: string } export function getReplaceableCoordinateFromEvent(event: Event) { - const d = event.tags.find(tagNameEquals('d'))?.[1] + const d = event.tags.find(tagNameEquals('d'))?.[1] ?? '' return getReplaceableCoordinate(event.kind, event.pubkey, d) } @@ -346,3 +346,26 @@ export function getRetainedEvent(a: Event, b: Event): Event { } return b } + +/** + * Collapse replaceable/addressable events to one per NIP-01 coordinate (`kind:pubkey` or `kind:pubkey:d`), + * keeping the newest (`created_at`, then lexicographically smallest `id` on ties). + * Non-replaceable events are keyed by `id` only. + */ +export function dedupeToLatestPerReplaceableCoordinate(events: Event[]): Event[] { + const byKey = new Map() + for (const e of events) { + if (!isReplaceableEvent(e.kind)) { + byKey.set(e.id, e) + continue + } + const coord = getReplaceableCoordinateFromEvent(e) + const existing = byKey.get(coord) + if (!existing) { + byKey.set(coord, e) + continue + } + byKey.set(coord, getRetainedEvent(e, existing)) + } + return [...byKey.values()] +} diff --git a/src/lib/spell-list-import.ts b/src/lib/spell-list-import.ts new file mode 100644 index 00000000..3840d256 --- /dev/null +++ b/src/lib/spell-list-import.ts @@ -0,0 +1,198 @@ +/** + * Merge tags from any fetched Nostr event into NIP-A7 spell draft fields (best-effort). + */ + +import type { TSpellDraftParams } from '@/lib/draft-event' +import { isValidPubkey } from '@/lib/pubkey' +import { isWebsocketUrl, normalizeUrl } from '@/lib/url' +import type { Event } from 'nostr-tools' +import type { Filter } from 'nostr-tools' +import client from '@/services/client.service' + +const HEX64 = /^[0-9a-f]{64}$/i + +/** Metadata tags on list events — not mapped to spell filters. */ +const LIST_METADATA_TAGS = new Set([ + 'd', + 'title', + 'image', + 'description', + 'client', + 'alt', + 'expiration', + 'relay' // handled separately below +]) + +/** Tags we explicitly report as unsupported for spell import. */ +const KNOWN_UNSUPPORTED = new Set(['emoji', 'word', 'group']) + +export function dedupeAppendIds(base: string[], add: string[]): string[] { + const seen = new Set( + base + .map((s) => s.trim().toLowerCase()) + .filter(Boolean) + ) + const out = base.map((s) => s.trim()).filter(Boolean) + for (const raw of add) { + const t = raw.trim() + if (!t) continue + const k = t.toLowerCase() + if (seen.has(k)) continue + seen.add(k) + out.push(t) + } + return out +} + +function mergeTagLetter( + rows: { letter: string; values: string[] }[], + letter: string, + values: string[] +): { letter: string; values: string[] }[] { + const vset = new Set(values.map((v) => v.trim()).filter(Boolean)) + if (vset.size === 0) return rows + const mergedVals = [...vset] + const idx = rows.findIndex((r) => r.letter === letter) + if (idx < 0) return [...rows, { letter, values: mergedVals }] + const prev = rows[idx]! + const u = new Set([...prev.values.map((x) => x.trim()).filter(Boolean), ...mergedVals]) + return rows.map((r, i) => (i === idx ? { letter, values: [...u] } : r)) +} + +export type TListToSpellResult = { + draft: TSpellDraftParams + notices: string[] + /** `a` coordinates to resolve to event ids in the background */ + pendingATags: string[] +} + +/** + * Merge public tags from a list/set event into spell draft fields. + * Does not resolve `a` tags — use {@link resolveSpellListATags} after. + */ +export function applyListEventToSpellDraft( + base: TSpellDraftParams, + listEvent: Event +): TListToSpellResult { + const notices: string[] = [] + const pendingATags: string[] = [] + const unsupportedCounts = new Map() + + let draft: TSpellDraftParams = { + ...base, + ids: [...base.ids], + authors: [...base.authors], + relays: [...base.relays], + topics: [...base.topics], + tagFilters: base.tagFilters.map((r) => ({ letter: r.letter, values: [...r.values] })), + kinds: [...base.kinds] + } + + if ((listEvent.content ?? '').trim().length > 0) { + notices.push('listImportContentSkipped') + } + + const title = listEvent.tags.find((t) => t[0] === 'title')?.[1]?.trim() + if (title && !(draft.name ?? '').trim()) { + draft = { ...draft, name: title } + } + + for (const tag of listEvent.tags) { + const name = tag[0] + if (!name) continue + if (LIST_METADATA_TAGS.has(name) && name !== 'relay') continue + + if (name === 't' && tag[1]) { + const v = tag[1].trim() + if (v) draft.tagFilters = mergeTagLetter(draft.tagFilters, 't', [v]) + continue + } + + if (name === 'e' && tag[1] && HEX64.test(tag[1])) { + draft.ids = dedupeAppendIds(draft.ids, [tag[1]]) + continue + } + + if (name === 'p' && tag[1] && isValidPubkey(tag[1])) { + draft.authors = dedupeAppendIds(draft.authors, [tag[1]]) + continue + } + + if (name === 'relay' && tag[1]) { + const u = normalizeUrl(tag[1]) || tag[1] + if (isWebsocketUrl(u)) draft.relays = dedupeAppendIds(draft.relays, [u]) + continue + } + + if (name === 'r' && tag[1]) { + const u = normalizeUrl(tag[1]) || tag[1] + if (isWebsocketUrl(u)) draft.relays = dedupeAppendIds(draft.relays, [u]) + continue + } + + if (name === 'a' && tag[1]) { + pendingATags.push(tag[1]) + continue + } + + if (KNOWN_UNSUPPORTED.has(name)) { + unsupportedCounts.set(name, (unsupportedCounts.get(name) ?? 0) + 1) + continue + } + + if (LIST_METADATA_TAGS.has(name)) continue + + unsupportedCounts.set(name, (unsupportedCounts.get(name) ?? 0) + 1) + } + + for (const [n, c] of unsupportedCounts) { + if (n === 'emoji') notices.push('listImportUnsupportedEmoji') + else notices.push(`listImportUnsupportedTag:${n}:${c}`) + } + + return { draft, notices, pendingATags: [...new Set(pendingATags)] } +} + +/** Resolve NIP-33 address strings (`kind:pubkey:d…`) to latest replaceable event ids. */ +export async function resolveSpellListATags( + aTags: string[], + relayUrls: string[] +): Promise<{ ids: string[]; notices: string[] }> { + const ids: string[] = [] + const notices: string[] = [] + const relays = relayUrls.length ? relayUrls : [] + + await Promise.all( + aTags.map(async (at) => { + const parts = at.split(':') + if (parts.length < 3) { + notices.push(`listImportBadATag:${at.slice(0, 32)}`) + return + } + const kind = parseInt(parts[0]!, 10) + const author = parts[1]! + const d = parts.slice(2).join(':') + if (Number.isNaN(kind) || !isValidPubkey(author) || !d) { + notices.push(`listImportBadATag:${at.slice(0, 32)}`) + return + } + const filter: Filter = { kinds: [kind], authors: [author], '#d': [d], limit: 5 } + try { + const events = + relays.length > 0 + ? await client.fetchEvents(relays, filter, { globalTimeout: 12_000 }) + : await client.fetchEvents([], filter, { globalTimeout: 12_000 }) + if (!events.length) { + notices.push(`listImportATagNotFound:${at.slice(0, 48)}`) + return + } + const latest = [...events].sort((a, b) => b.created_at - a.created_at)[0]! + ids.push(latest.id) + } catch { + notices.push(`listImportATagFailed:${at.slice(0, 48)}`) + } + }) + ) + + return { ids: [...new Set(ids)], notices } +} diff --git a/src/pages/primary/SpellsPage/CreateSpellDialog.tsx b/src/pages/primary/SpellsPage/CreateSpellDialog.tsx index f8f4fd06..179e3195 100644 --- a/src/pages/primary/SpellsPage/CreateSpellDialog.tsx +++ b/src/pages/primary/SpellsPage/CreateSpellDialog.tsx @@ -13,9 +13,16 @@ import { spellEventToDraftParams, type TSpellDraftParams } from '@/lib/draft-event' +import { + applyListEventToSpellDraft, + dedupeAppendIds, + resolveSpellListATags +} from '@/lib/spell-list-import' import { useNostr } from '@/providers/NostrProvider' import { showPublishingError, showSimplePublishSuccess } from '@/lib/publishing-feedback' +import client from '@/services/client.service' import indexedDb from '@/services/indexed-db.service' +import { getRelaysForSpellCatalogSync } from '@/services/spell.service' import { Minus, Plus, X } from 'lucide-react' import { useTranslation } from 'react-i18next' import type { Event as NostrEvent } from 'nostr-tools' @@ -61,7 +68,8 @@ function DynamicStringListField({ values, onChange, placeholder, - inputType = 'text' + inputType = 'text', + showLabel = true }: { label: string hint?: string @@ -69,6 +77,8 @@ function DynamicStringListField({ onChange: (next: string[]) => void placeholder?: string inputType?: 'text' | 'number' + /** When false, only the inputs and add/remove controls are rendered (for nested editors). */ + showLabel?: boolean }) { const { t } = useTranslation() const rows = values.length > 0 ? values : [''] @@ -96,7 +106,7 @@ function DynamicStringListField({ return (
- + {showLabel && label ? : null}
{rows.map((v, i) => (
@@ -130,6 +140,99 @@ function DynamicStringListField({ ) } +function TagFiltersEditor({ + tagFilters, + onChange +}: { + tagFilters: { letter: string; values: string[] }[] + onChange: (next: { letter: string; values: string[] }[]) => void +}) { + const { t } = useTranslation() + const addRow = () => onChange([...tagFilters, { letter: '', values: [''] }]) + const removeRow = (i: number) => { + const next = [...tagFilters] + next.splice(i, 1) + onChange(next) + } + return ( +
+ +

{t('spellTagFiltersHint')}

+ {tagFilters.length === 0 ? ( +

{t('spellTagFiltersEmpty')}

+ ) : null} + {tagFilters.map((row, i) => ( +
+
+ { + const next = [...tagFilters] + next[i] = { ...next[i]!, letter: e.target.value } + onChange(next) + }} + aria-label={t('Tag filter letter')} + /> + +
+ 0 ? row.values : ['']} + onChange={(values) => { + const next = [...tagFilters] + next[i] = { ...next[i]!, values } + onChange(next) + }} + placeholder={t('Filter value')} + /> +
+ ))} + +
+ ) +} + +function formatListImportNotice(raw: string, t: (k: string, o?: Record) => string) { + if (raw === 'listImportContentSkipped') return t('listImportContentSkipped') + if (raw === 'listImportUnsupportedEmoji') return t('listImportUnsupportedEmoji') + if (raw.startsWith('listImportUnsupportedTag:')) { + const parts = raw.split(':') + const tag = parts[1] ?? '?' + const count = parts[2] ?? '1' + return t('listImportUnsupportedTag', { tag, count }) + } + if (raw.startsWith('listImportBadATag:')) { + const preview = raw.slice('listImportBadATag:'.length) + return t('listImportBadATag', { preview }) + } + if (raw.startsWith('listImportATagNotFound:')) { + const preview = raw.slice('listImportATagNotFound:'.length) + return t('listImportATagNotFound', { preview }) + } + if (raw.startsWith('listImportATagFailed:')) { + const preview = raw.slice('listImportATagFailed:'.length) + return t('listImportATagFailed', { preview }) + } + return raw +} + export default function CreateSpellDialog({ open, onOpenChange, @@ -147,10 +250,18 @@ export default function CreateSpellDialog({ spellToClone?: NostrEvent | null }) { const { t } = useTranslation() - const { pubkey, publish, checkLogin } = useNostr() + const { pubkey, publish, checkLogin, relayList } = useNostr() const [form, setForm] = useState(DEFAULT_PARAMS) const [saving, setSaving] = useState(false) const scrollBodyRef = useRef(null) + const formRef = useRef(DEFAULT_PARAMS) + const [listImportNotices, setListImportNotices] = useState([]) + const [manualListRef, setManualListRef] = useState('') + const [manualListLoading, setManualListLoading] = useState(false) + + useEffect(() => { + formRef.current = form + }, [form]) useEffect(() => { if (!open) return @@ -160,8 +271,47 @@ export default function CreateSpellDialog({ } else { setForm({ ...DEFAULT_PARAMS }) } + setListImportNotices([]) + setManualListRef('') }, [open, spellToEdit, spellToClone]) + const applyListSource = useCallback( + (ev: NostrEvent) => { + const base = formRef.current + const { draft, notices, pendingATags } = applyListEventToSpellDraft(base, ev) + setForm(draft) + setListImportNotices(notices) + const urls = getRelaysForSpellCatalogSync(relayList ?? undefined) + if (pendingATags.length === 0) return + void resolveSpellListATags(pendingATags, urls).then(({ ids, notices: extra }) => { + if (ids.length) { + setForm((f) => ({ ...f, ids: dedupeAppendIds(f.ids, ids) })) + } + if (extra.length) setListImportNotices((n) => [...n, ...extra]) + }) + }, + [relayList] + ) + + const handleLoadManualList = useCallback(async () => { + const q = manualListRef.trim() + if (!q) return + setManualListLoading(true) + try { + const ev = await client.fetchEvent(q) + if (!ev) { + setListImportNotices([t('listImportEventNotFound')]) + return + } + applyListSource(ev) + } catch (e) { + logger.warn('[CreateSpellDialog] List import fetch failed', e) + setListImportNotices([t('listImportEventNotFound')]) + } finally { + setManualListLoading(false) + } + }, [manualListRef, applyListSource, t]) + const handleScrollBodyKeyDown = useCallback((e: React.KeyboardEvent) => { const el = scrollBodyRef.current if (!el) return @@ -186,7 +336,11 @@ export default function CreateSpellDialog({ } }, []) - const handleClear = () => setForm({ ...DEFAULT_PARAMS }) + const handleClear = () => { + setForm({ ...DEFAULT_PARAMS }) + setListImportNotices([]) + setManualListRef('') + } const handleCancel = () => { handleClear() onOpenChange(false) @@ -250,9 +404,7 @@ export default function CreateSpellDialog({

{spellToClone - ? t( - 'This spell is preloaded from someone else’s definition. Adjust anything you like, then save to publish a new spell signed by you.' - ) + ? t('Clone spell intro') : t( 'Spells are saved relay filters (NIP-A7). Fill in the filter fields below. Use $me for your pubkey and $contacts for your follow list when executing.' )} @@ -268,6 +420,44 @@ export default function CreateSpellDialog({ onKeyDown={handleScrollBodyKeyDown} >

+
+ +

{t('listImportFromEventHint')}

+
+ setManualListRef(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault() + void handleLoadManualList() + } + }} + /> + +
+ {listImportNotices.length > 0 ? ( +
    + {listImportNotices.map((n, i) => ( +
  • {formatListImportNotice(n, t)}
  • + ))} +
+ ) : null} +
+