Browse Source

event import to cache db

imwald
Silberengel 4 weeks ago
parent
commit
f7bfff69c7
  1. 218
      src/components/CacheEventImportSettings/index.tsx
  2. 19
      src/i18n/locales/de.ts
  3. 19
      src/i18n/locales/en.ts
  4. 44
      src/lib/cache-event-import.test.ts
  5. 148
      src/lib/cache-event-import.ts
  6. 2
      src/pages/secondary/CacheSettingsPage/index.tsx

218
src/components/CacheEventImportSettings/index.tsx

@ -0,0 +1,218 @@ @@ -0,0 +1,218 @@
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { Label } from '@/components/ui/label'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Textarea } from '@/components/ui/textarea'
import {
CACHE_IMPORT_MAX_JSONL_FILE_BYTES,
formatCacheImportLimits,
parseJsonlCacheImportText,
parsePastedCacheImportJson
} from '@/lib/cache-event-import'
import { showPublishingError, showSimplePublishSuccess } from '@/lib/publishing-feedback'
import { useNostr } from '@/providers/NostrProvider'
import client from '@/services/client.service'
import type { Event } from 'nostr-tools'
import { Upload } from 'lucide-react'
import { useCallback, useId, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
async function ingestImportedEvents(
events: Event[],
broadcast: boolean,
accountPubkey: string | null,
t: (key: string) => string
): Promise<{ imported: number; broadcastOk: number; broadcastFailed: number }> {
const unique: Event[] = []
const seen = new Set<string>()
for (const ev of events) {
const id = ev.id.toLowerCase()
if (seen.has(id)) continue
seen.add(id)
unique.push(ev)
client.addEventToCache(ev, { explicitNoteLookupHexId: id })
}
let broadcastOk = 0
let broadcastFailed = 0
if (broadcast && accountPubkey && unique.length > 0) {
const urls = await client.getMailboxStackWriteUrlsForRepublish(accountPubkey)
if (!urls.length) {
throw new Error(t('No mailbox cache or HTTP write relays configured'))
}
for (const ev of unique) {
try {
const result = await client.publishEvent(urls, ev, { skipOutboxRetry: true })
if (result.successCount >= 1) broadcastOk++
else broadcastFailed++
} catch {
broadcastFailed++
}
}
}
return { imported: unique.length, broadcastOk, broadcastFailed }
}
export default function CacheEventImportSettings() {
const { t } = useTranslation()
const { pubkey: accountPubkey } = useNostr()
const broadcastId = useId()
const fileInputRef = useRef<HTMLInputElement>(null)
const [pasteText, setPasteText] = useState('')
const [broadcastAfterImport, setBroadcastAfterImport] = useState(false)
const [busy, setBusy] = useState(false)
const runImport = useCallback(
async (events: Event[], issueCount: number) => {
if (events.length === 0) {
toast.error(t('cacheImport.noValidEvents'))
return
}
if (broadcastAfterImport && !accountPubkey) {
toast.error(t('Log in to broadcast'))
return
}
setBusy(true)
const loadingId = toast.loading(t('cacheImport.importing'))
try {
const stats = await ingestImportedEvents(events, broadcastAfterImport, accountPubkey ?? null, t)
if (issueCount > 0) {
toast.warning(
t('cacheImport.partialWithIssues', {
imported: stats.imported,
skipped: issueCount
})
)
}
const successMessage =
broadcastAfterImport && accountPubkey
? t('cacheImport.doneWithBroadcast', {
imported: stats.imported,
broadcastOk: stats.broadcastOk,
broadcastFailed: stats.broadcastFailed
})
: t('cacheImport.done', { count: stats.imported })
toast.dismiss(loadingId)
showSimplePublishSuccess(successMessage)
setPasteText('')
} catch (err) {
toast.dismiss(loadingId)
showPublishingError(
t('cacheImport.failed', {
error: err instanceof Error ? err.message : String(err)
})
)
} finally {
setBusy(false)
}
},
[accountPubkey, broadcastAfterImport, t]
)
const handleImportPaste = () => {
const { events, issues } = parsePastedCacheImportJson(pasteText)
void runImport(events, issues.length)
}
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
e.target.value = ''
if (!file) return
if (!file.name.toLowerCase().endsWith('.jsonl')) {
toast.error(t('cacheImport.jsonlOnly'))
return
}
if (file.size > CACHE_IMPORT_MAX_JSONL_FILE_BYTES) {
toast.error(
t('cacheImport.fileTooLarge', {
mb: Math.round(CACHE_IMPORT_MAX_JSONL_FILE_BYTES / (1024 * 1024))
})
)
return
}
void (async () => {
setBusy(true)
try {
const text = await file.text()
const { events, issues } = parseJsonlCacheImportText(text)
await runImport(events, issues.length)
} catch (err) {
toast.error(
t('cacheImport.failed', {
error: err instanceof Error ? err.message : String(err)
})
)
} finally {
setBusy(false)
}
})()
}
return (
<div className="space-y-4 border-t border-border pt-6">
<h3 className="text-base font-medium">{t('cacheImport.sectionTitle')}</h3>
<p className="text-sm text-muted-foreground">{t('cacheImport.sectionBlurb')}</p>
<p className="text-xs text-muted-foreground">{formatCacheImportLimits()}</p>
<div className="flex items-start gap-2">
<Checkbox
id={broadcastId}
checked={broadcastAfterImport}
onCheckedChange={(v) => setBroadcastAfterImport(v === true)}
disabled={!accountPubkey || busy}
/>
<Label htmlFor={broadcastId} className="text-sm font-normal leading-snug cursor-pointer">
{t('cacheImport.broadcastLabel')}
{!accountPubkey ? (
<span className="block text-xs text-muted-foreground">{t('Log in to broadcast')}</span>
) : null}
</Label>
</div>
<Tabs defaultValue="paste" className="w-full">
<TabsList className="w-full">
<TabsTrigger value="paste" className="flex-1">
{t('cacheImport.tabPaste')}
</TabsTrigger>
<TabsTrigger value="file" className="flex-1">
{t('cacheImport.tabFile')}
</TabsTrigger>
</TabsList>
<TabsContent value="paste" className="space-y-3 mt-3">
<Textarea
className="min-h-[140px] font-mono text-xs"
placeholder={t('cacheImport.pastePlaceholder')}
value={pasteText}
onChange={(e) => setPasteText(e.target.value)}
disabled={busy}
/>
<Button type="button" disabled={busy || !pasteText.trim()} onClick={handleImportPaste}>
{t('cacheImport.importButton')}
</Button>
</TabsContent>
<TabsContent value="file" className="space-y-3 mt-3">
<input
ref={fileInputRef}
type="file"
accept=".jsonl,application/jsonl,text/jsonl"
className="hidden"
onChange={handleFileChange}
disabled={busy}
/>
<Button
type="button"
variant="secondary"
disabled={busy}
onClick={() => fileInputRef.current?.click()}
>
<Upload className="mr-2 h-4 w-4" />
{t('cacheImport.chooseJsonl')}
</Button>
<p className="text-xs text-muted-foreground">{t('cacheImport.fileHint')}</p>
</TabsContent>
</Tabs>
</div>
)
}

19
src/i18n/locales/de.ts

@ -658,6 +658,25 @@ export default { @@ -658,6 +658,25 @@ export default {
"eventArchive.off": "off",
"eventArchive.apply": "Apply cache settings",
"eventArchive.appliedToast": "Cache settings saved. Session memory updated.",
"cacheImport.sectionTitle": "Events importieren",
"cacheImport.sectionBlurb":
"Signierte Nostr-Events per JSON (einfügen) oder .jsonl-Export in den lokalen Cache laden. Events müssen die Signaturprüfung bestehen.",
"cacheImport.broadcastLabel": "Nach dem Import an meine Schreib-Relays senden",
"cacheImport.tabPaste": "JSON einfügen",
"cacheImport.tabFile": ".jsonl hochladen",
"cacheImport.pastePlaceholder": "Ein Event-Objekt oder ein JSON-Array von Events einfügen…",
"cacheImport.importButton": "In Cache importieren",
"cacheImport.chooseJsonl": ".jsonl-Datei wählen",
"cacheImport.fileHint": "Ein JSON-Event pro Zeile (NDJSON). Doppelte Event-IDs im selben Import werden übersprungen.",
"cacheImport.jsonlOnly": "Bitte eine .jsonl-Datei wählen",
"cacheImport.fileTooLarge": "Datei ist größer als {{mb}} MB",
"cacheImport.noValidEvents": "Keine gültigen signierten Events zum Importieren",
"cacheImport.importing": "Events werden importiert…",
"cacheImport.done": "{{count}} Event(s) in den Cache importiert",
"cacheImport.doneWithBroadcast":
"{{imported}} Event(s) importiert; auf {{broadcastOk}} Relay(s) gesendet ({{broadcastFailed}} fehlgeschlagen)",
"cacheImport.partialWithIssues": "{{imported}} Event(s) importiert; {{skipped}} Zeile(n) übersprungen",
"cacheImport.failed": "Import fehlgeschlagen: {{error}}",
"Paste or drop media files to upload": "Füge Medien-Dateien ein oder ziehe sie hierher, um sie hochzuladen",
Preview: "Vorschau",
"You are about to publish an event signed by [{{eventAuthorName}}]. You are currently logged in as [{{currentUsername}}]. Are you sure?": "Du bist dabei, ein Ereignis zu veröffentlichen, das von [{{eventAuthorName}}] signiert wurde. Du bist derzeit als [{{currentUsername}}] angemeldet. Bist du sicher?",

19
src/i18n/locales/en.ts

@ -677,6 +677,25 @@ export default { @@ -677,6 +677,25 @@ export default {
"eventArchive.off": "off",
"eventArchive.apply": "Apply cache settings",
"eventArchive.appliedToast": "Cache settings saved. Session memory updated.",
"cacheImport.sectionTitle": "Import events",
"cacheImport.sectionBlurb":
"Add signed Nostr events to your local cache from JSON (paste) or a .jsonl export. Events must pass signature verification.",
"cacheImport.broadcastLabel": "Broadcast to my write relays after import",
"cacheImport.tabPaste": "Paste JSON",
"cacheImport.tabFile": "Upload .jsonl",
"cacheImport.pastePlaceholder": "Paste one event object, or a JSON array of events…",
"cacheImport.importButton": "Import to cache",
"cacheImport.chooseJsonl": "Choose .jsonl file",
"cacheImport.fileHint": "One JSON event per line (NDJSON). Duplicate event ids in the same import are skipped.",
"cacheImport.jsonlOnly": "Please choose a .jsonl file",
"cacheImport.fileTooLarge": "File is larger than {{mb}} MB",
"cacheImport.noValidEvents": "No valid signed events to import",
"cacheImport.importing": "Importing events…",
"cacheImport.done": "Imported {{count}} event(s) into cache",
"cacheImport.doneWithBroadcast":
"Imported {{imported}} event(s); broadcast accepted on {{broadcastOk}} relay(s) ({{broadcastFailed}} failed)",
"cacheImport.partialWithIssues": "Imported {{imported}} event(s); {{skipped}} line(s) skipped",
"cacheImport.failed": "Import failed: {{error}}",
"Paste or drop media files to upload": "Paste or drop media files to upload",
Preview: "Preview",
"You are about to publish an event signed by [{{eventAuthorName}}]. You are currently logged in as [{{currentUsername}}]. Are you sure?": "You are about to publish an event signed by [{{eventAuthorName}}]. You are currently logged in as [{{currentUsername}}]. Are you sure?",

44
src/lib/cache-event-import.test.ts

@ -0,0 +1,44 @@ @@ -0,0 +1,44 @@
import { describe, expect, it } from 'vitest'
import { kinds, finalizeEvent, generateSecretKey, getPublicKey } from 'nostr-tools'
import { parseJsonlCacheImportText, parsePastedCacheImportJson } from './cache-event-import'
function signedNote(content: string) {
const sk = generateSecretKey()
const pubkey = getPublicKey(sk)
return finalizeEvent(
{
kind: kinds.ShortTextNote,
created_at: Math.floor(Date.now() / 1000),
tags: [],
content
},
sk
)
}
describe('parsePastedCacheImportJson', () => {
it('accepts a single signed event object', () => {
const ev = signedNote('hello')
const { events, issues } = parsePastedCacheImportJson(JSON.stringify(ev))
expect(issues).toHaveLength(0)
expect(events).toHaveLength(1)
expect(events[0].id).toBe(ev.id)
})
it('rejects invalid JSON', () => {
const { events, issues } = parsePastedCacheImportJson('{not json')
expect(events).toHaveLength(0)
expect(issues[0].message).toMatch(/Invalid JSON/i)
})
})
describe('parseJsonlCacheImportText', () => {
it('parses one event per line', () => {
const a = signedNote('a')
const b = signedNote('b')
const text = `${JSON.stringify(a)}\n${JSON.stringify(b)}\n`
const { events, issues } = parseJsonlCacheImportText(text)
expect(issues).toHaveLength(0)
expect(events).toHaveLength(2)
})
})

148
src/lib/cache-event-import.ts

@ -0,0 +1,148 @@ @@ -0,0 +1,148 @@
import { isLikelyCachedNostrEvent } from '@/services/indexed-db.service'
import type { Event } from 'nostr-tools'
import { validateEvent, verifyEvent } from 'nostr-tools'
/** Max size for a pasted JSON blob (single event or small array). */
export const CACHE_IMPORT_MAX_PASTE_CHARS = 400_000
/** Max `.jsonl` upload size. */
export const CACHE_IMPORT_MAX_JSONL_FILE_BYTES = 2 * 1024 * 1024
/** Max lines processed from a `.jsonl` file. */
export const CACHE_IMPORT_MAX_JSONL_LINES = 500
/** Skip oversized single lines in JSONL. */
export const CACHE_IMPORT_MAX_JSONL_LINE_CHARS = 256_000
export type CacheImportParseIssue = { line?: number; message: string }
export type CacheImportParseResult = {
events: Event[]
issues: CacheImportParseIssue[]
}
function normalizeImportedEvent(raw: unknown): Event | null {
if (!isLikelyCachedNostrEvent(raw)) return null
const ev = raw as Event
if (typeof ev.created_at !== 'number' || !Number.isFinite(ev.created_at)) return null
if (typeof ev.sig !== 'string' || !ev.sig.trim()) return null
if (!validateEvent(ev)) return null
if (!verifyEvent(ev)) return null
return ev
}
function collectFromUnknown(
value: unknown,
line: number | undefined,
events: Event[],
issues: CacheImportParseIssue[]
): void {
if (Array.isArray(value)) {
if (value.length + events.length > CACHE_IMPORT_MAX_JSONL_LINES) {
issues.push({
line,
message: `Too many events (max ${CACHE_IMPORT_MAX_JSONL_LINES})`
})
return
}
for (let i = 0; i < value.length; i++) {
collectFromUnknown(value[i], line, events, issues)
if (events.length >= CACHE_IMPORT_MAX_JSONL_LINES) break
}
return
}
const ev = normalizeImportedEvent(value)
if (ev) {
events.push(ev)
return
}
issues.push({
line,
message: line != null ? 'Invalid or unsigned event on this line' : 'Invalid or unsigned Nostr event JSON'
})
}
/** Parse one pasted JSON value (object or array of events). */
export function parsePastedCacheImportJson(text: string): CacheImportParseResult {
const trimmed = text.trim()
if (!trimmed) {
return { events: [], issues: [{ message: 'Paste is empty' }] }
}
if (trimmed.length > CACHE_IMPORT_MAX_PASTE_CHARS) {
return {
events: [],
issues: [
{
message: `Paste exceeds ${Math.round(CACHE_IMPORT_MAX_PASTE_CHARS / 1024)} KB limit`
}
]
}
}
let parsed: unknown
try {
parsed = JSON.parse(trimmed) as unknown
} catch {
return { events: [], issues: [{ message: 'Invalid JSON' }] }
}
const events: Event[] = []
const issues: CacheImportParseIssue[] = []
collectFromUnknown(parsed, undefined, events, issues)
return { events, issues }
}
/** Parse newline-delimited JSON (one event per line). */
export function parseJsonlCacheImportText(text: string): CacheImportParseResult {
if (text.length > CACHE_IMPORT_MAX_JSONL_FILE_BYTES) {
return {
events: [],
issues: [
{
message: `File content exceeds ${Math.round(CACHE_IMPORT_MAX_JSONL_FILE_BYTES / (1024 * 1024))} MB limit`
}
]
}
}
const lines = text.split(/\r?\n/)
const events: Event[] = []
const issues: CacheImportParseIssue[] = []
let nonEmptyLines = 0
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim()
if (!line) continue
nonEmptyLines++
if (nonEmptyLines > CACHE_IMPORT_MAX_JSONL_LINES) {
issues.push({
line: i + 1,
message: `Stopped at ${CACHE_IMPORT_MAX_JSONL_LINES} events (file has more lines)`
})
break
}
if (line.length > CACHE_IMPORT_MAX_JSONL_LINE_CHARS) {
issues.push({ line: i + 1, message: 'Line too large' })
continue
}
let parsed: unknown
try {
parsed = JSON.parse(line) as unknown
} catch {
issues.push({ line: i + 1, message: 'Invalid JSON on this line' })
continue
}
const before = events.length
collectFromUnknown(parsed, i + 1, events, issues)
if (events.length === before && issues[issues.length - 1]?.line !== i + 1) {
issues.push({ line: i + 1, message: 'Invalid or unsigned event on this line' })
}
if (events.length >= CACHE_IMPORT_MAX_JSONL_LINES) break
}
if (nonEmptyLines === 0) {
issues.push({ message: 'File has no event lines' })
}
return { events, issues }
}
export function formatCacheImportLimits(): string {
const mb = Math.round(CACHE_IMPORT_MAX_JSONL_FILE_BYTES / (1024 * 1024))
const pasteKb = Math.round(CACHE_IMPORT_MAX_PASTE_CHARS / 1024)
return `${CACHE_IMPORT_MAX_JSONL_LINES} events max · ${mb} MB file · ${pasteKb} KB paste`
}

2
src/pages/secondary/CacheSettingsPage/index.tsx

@ -1,3 +1,4 @@ @@ -1,3 +1,4 @@
import CacheEventImportSettings from '@/components/CacheEventImportSettings'
import InBrowserCacheSetting from '@/components/InBrowserCacheSetting'
import EventArchiveCacheSettings from '@/components/EventArchiveCacheSettings'
import { RefreshButton } from '@/components/RefreshButton'
@ -31,6 +32,7 @@ const CacheSettingsPage = forwardRef( @@ -31,6 +32,7 @@ const CacheSettingsPage = forwardRef(
>
<div key={contentKey} className="px-4 py-3 space-y-6">
<InBrowserCacheSetting />
<CacheEventImportSettings />
<EventArchiveCacheSettings />
</div>
</SecondaryPageLayout>

Loading…
Cancel
Save