From b8d05e40c7794e0abfecaa094100f749f4865364 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20G=C3=B3mez?= Date: Sun, 14 Dec 2025 12:21:09 +0100 Subject: [PATCH] feat: export to JSONL --- package-lock.json | 40 +++++++ package.json | 1 + src/components/ReqViewer.tsx | 197 ++++++++++++++++++++++++++++++++- src/components/ui/progress.tsx | 26 +++++ src/lib/filename-utils.ts | 18 +++ src/services/db.ts | 24 ++-- 6 files changed, 293 insertions(+), 13 deletions(-) create mode 100644 src/components/ui/progress.tsx create mode 100644 src/lib/filename-utils.ts diff --git a/package-lock.json b/package-lock.json index 537521a..809ef36 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-hover-card": "^1.1.15", + "@radix-ui/react-progress": "^1.1.8", "@radix-ui/react-scroll-area": "^1.2.10", "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-tooltip": "^1.2.8", @@ -2531,6 +2532,45 @@ } } }, + "node_modules/@radix-ui/react-progress": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.8.tgz", + "integrity": "sha512-+gISHcSPUJ7ktBy9RnTqbdKW78bcGke3t6taawyZ71pio1JewwGSJizycs7rLhGTvMJYCQB1DBK4KQsxs7U8dA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-context": "1.1.3", + "@radix-ui/react-primitive": "2.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-progress/node_modules/@radix-ui/react-context": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.3.tgz", + "integrity": "sha512-ieIFACdMpYfMEjF0rEf5KLvfVyIkOz6PDGyNnP+u+4xQ6jny3VCgA4OgXOwNx2aUkxn8zx9fiVcM8CfFYv9Lxw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-roving-focus": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz", diff --git a/package.json b/package.json index 17c80e9..1fc927f 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-hover-card": "^1.1.15", + "@radix-ui/react-progress": "^1.1.8", "@radix-ui/react-scroll-area": "^1.2.10", "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-tooltip": "^1.2.8", diff --git a/src/components/ReqViewer.tsx b/src/components/ReqViewer.tsx index 3e1b180..6e3e15a 100644 --- a/src/components/ReqViewer.tsx +++ b/src/components/ReqViewer.tsx @@ -1,4 +1,4 @@ -import { useState, memo } from "react"; +import { useState, memo, useCallback } from "react"; import { ChevronDown, ChevronRight, @@ -14,6 +14,7 @@ import { ShieldQuestion, Shield, Filter as FilterIcon, + Download, } from "lucide-react"; import { Virtuoso } from "react-virtuoso"; import { useReqTimeline } from "@/hooks/useReqTimeline"; @@ -29,6 +30,16 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from "./ui/dropdown-menu"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from "./ui/dialog"; +import { Input } from "./ui/input"; +import { Button } from "./ui/button"; +import { Progress } from "./ui/progress"; import { RelayLink } from "./nostr/RelayLink"; import type { NostrFilter } from "@/types/nostr"; import type { RelayState } from "@/types/relay-state"; @@ -40,6 +51,7 @@ import { formatPubkeysWithProfiles, formatHashtags, } from "@/lib/filter-formatters"; +import { sanitizeFilename } from "@/lib/filename-utils"; // Memoized FeedEvent to prevent unnecessary re-renders during scroll const MemoizedFeedEvent = memo( @@ -117,6 +129,7 @@ interface ReqViewerProps { closeOnEose?: boolean; nip05Authors?: string[]; nip05PTags?: string[]; + title?: string; } interface QueryDropdownProps { @@ -299,6 +312,7 @@ export default function ReqViewer({ closeOnEose = false, nip05Authors, nip05PTags, + title = "nostr-events", }: ReqViewerProps) { const { state } = useGrimoire(); const { relays: relayStates } = useRelayState(); @@ -334,6 +348,77 @@ export default function ReqViewer({ ); const [showQuery, setShowQuery] = useState(false); + const [showExportDialog, setShowExportDialog] = useState(false); + const [exportFilename, setExportFilename] = useState(""); + const [isExporting, setIsExporting] = useState(false); + const [exportProgress, setExportProgress] = useState(0); + + /** + * Export events to JSONL format with chunked processing for large datasets + * Handles tens of thousands of events without blocking the UI + */ + const handleExport = useCallback(async () => { + if (!exportFilename.trim()) return; + + setIsExporting(true); + setExportProgress(0); + + try { + const sanitized = sanitizeFilename(exportFilename); + const CHUNK_SIZE = 1000; // Process 1000 events at a time + const shouldChunk = events.length > CHUNK_SIZE; + + let blob: Blob; + + if (shouldChunk) { + // Chunked processing for large datasets + const chunks: string[] = []; + + for (let i = 0; i < events.length; i += CHUNK_SIZE) { + // Yield to browser to prevent UI blocking + await new Promise((resolve) => setTimeout(resolve, 0)); + + const chunk = events.slice(i, i + CHUNK_SIZE); + const jsonlChunk = chunk.map((e) => JSON.stringify(e)).join("\n"); + chunks.push(jsonlChunk); + + // Update progress + setExportProgress(Math.round(((i + chunk.length) / events.length) * 100)); + } + + // Join chunks with newlines between them + const jsonl = chunks.join("\n"); + blob = new Blob([jsonl], { type: "application/jsonl" }); + } else { + // Direct processing for small datasets + const jsonl = events.map((e) => JSON.stringify(e)).join("\n"); + blob = new Blob([jsonl], { type: "application/jsonl" }); + } + + // Create download + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `${sanitized}.jsonl`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + + setExportProgress(100); + } catch (error) { + console.error("Export failed:", error); + // Keep dialog open on error so user can retry + setIsExporting(false); + setExportProgress(0); + return; + } + + // Close dialog on success + setIsExporting(false); + setExportProgress(0); + setShowExportDialog(false); + }, [events, exportFilename]); return (
@@ -375,11 +460,30 @@ export default function ReqViewer({ {/* Right: Stats */}
- {/* Event Count */} -
- - {events.length} -
+ {/* Event Count (Dropdown) */} + + + + + + { + setExportFilename(title); + setShowExportDialog(true); + }} + disabled={events.length === 0} + > + + Export to JSONL + + + {/* Relay Count (Dropdown) */} @@ -503,6 +607,87 @@ export default function ReqViewer({ /> )}
+ + {/* Export Dialog */} + + + + Export Events to JSONL + +
+
+ {isExporting ? ( + <> + Exporting{" "} + {events.length} event + {events.length !== 1 ? "s" : ""}... + + ) : ( + <> + Export {events.length}{" "} + event{events.length !== 1 ? "s" : ""} as JSONL + (newline-delimited JSON). + + )} +
+ {isExporting && events.length > 1000 && ( +
+
+ Processing events... + {exportProgress}% +
+ +
+ )} +
+ + setExportFilename(e.target.value)} + placeholder="Enter filename" + disabled={isExporting} + onKeyDown={(e) => { + if (e.key === "Enter" && exportFilename.trim() && !isExporting) { + handleExport(); + } + }} + /> +
+ .jsonl extension will be added automatically +
+
+
+ + + + +
+
); } diff --git a/src/components/ui/progress.tsx b/src/components/ui/progress.tsx new file mode 100644 index 0000000..882affb --- /dev/null +++ b/src/components/ui/progress.tsx @@ -0,0 +1,26 @@ +import * as React from "react"; +import * as ProgressPrimitive from "@radix-ui/react-progress"; + +import { cn } from "@/lib/utils"; + +const Progress = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, value, ...props }, ref) => ( + + + +)); +Progress.displayName = ProgressPrimitive.Root.displayName; + +export { Progress }; diff --git a/src/lib/filename-utils.ts b/src/lib/filename-utils.ts new file mode 100644 index 0000000..120e1cf --- /dev/null +++ b/src/lib/filename-utils.ts @@ -0,0 +1,18 @@ +/** + * Sanitizes a filename by removing invalid characters and enforcing length limits + * Prevents path traversal and filesystem errors + */ +export function sanitizeFilename(filename: string): string { + return ( + filename + .trim() + // Remove invalid filesystem characters + .replace(/[/\\:*?"<>|]/g, "_") + // Remove leading dots (hidden files) + .replace(/^\.+/, "") + // Remove trailing dots + .replace(/\.+$/, "") + // Limit to safe filename length (255 is filesystem max) + .substring(0, 255) + ); +} diff --git a/src/services/db.ts b/src/services/db.ts index eb3f31b..3bad016 100644 --- a/src/services/db.ts +++ b/src/services/db.ts @@ -63,7 +63,9 @@ class GrimoireDb extends Dexie { console.log("[DB Migration v6] Normalizing relay URLs..."); // Migrate relayAuthPreferences - const authPrefs = await tx.table("relayAuthPreferences").toArray(); + const authPrefs = await tx + .table("relayAuthPreferences") + .toArray(); const normalizedAuthPrefs = new Map(); let skippedAuthPrefs = 0; @@ -83,16 +85,20 @@ class GrimoireDb extends Dexie { skippedAuthPrefs++; console.warn( `[DB Migration v6] Skipping invalid relay URL in auth preferences: ${pref.url}`, - error + error, ); } } await tx.table("relayAuthPreferences").clear(); - await tx.table("relayAuthPreferences").bulkAdd(Array.from(normalizedAuthPrefs.values())); + await tx + .table("relayAuthPreferences") + .bulkAdd(Array.from(normalizedAuthPrefs.values())); console.log( `[DB Migration v6] Normalized ${normalizedAuthPrefs.size} auth preferences` + - (skippedAuthPrefs > 0 ? ` (skipped ${skippedAuthPrefs} invalid)` : "") + (skippedAuthPrefs > 0 + ? ` (skipped ${skippedAuthPrefs} invalid)` + : ""), ); // Migrate relayInfo @@ -116,16 +122,20 @@ class GrimoireDb extends Dexie { skippedRelayInfos++; console.warn( `[DB Migration v6] Skipping invalid relay URL in relay info: ${info.url}`, - error + error, ); } } await tx.table("relayInfo").clear(); - await tx.table("relayInfo").bulkAdd(Array.from(normalizedRelayInfos.values())); + await tx + .table("relayInfo") + .bulkAdd(Array.from(normalizedRelayInfos.values())); console.log( `[DB Migration v6] Normalized ${normalizedRelayInfos.size} relay infos` + - (skippedRelayInfos > 0 ? ` (skipped ${skippedRelayInfos} invalid)` : "") + (skippedRelayInfos > 0 + ? ` (skipped ${skippedRelayInfos} invalid)` + : ""), ); console.log("[DB Migration v6] Complete!"); });