feat: export to JSONL

This commit is contained in:
Alejandro Gómez
2025-12-14 12:21:09 +01:00
parent bef7369de9
commit b8d05e40c7
6 changed files with 293 additions and 13 deletions

40
package-lock.json generated
View File

@@ -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",

View File

@@ -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",

View File

@@ -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 (
<div className="h-full w-full flex flex-col bg-background text-foreground">
@@ -375,11 +460,30 @@ export default function ReqViewer({
{/* Right: Stats */}
<div className="flex items-center gap-3">
{/* Event Count */}
<div className="flex items-center gap-1 text-muted-foreground">
<FileText className="size-3" />
<span>{events.length}</span>
</div>
{/* Event Count (Dropdown) */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
className="flex items-center gap-1 text-muted-foreground hover:text-foreground transition-colors"
aria-label={`${events.length} event${events.length !== 1 ? "s" : ""}, click for export options`}
>
<FileText className="size-3" />
<span>{events.length}</span>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => {
setExportFilename(title);
setShowExportDialog(true);
}}
disabled={events.length === 0}
>
<Download className="size-3 mr-2" />
Export to JSONL
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{/* Relay Count (Dropdown) */}
<DropdownMenu>
@@ -503,6 +607,87 @@ export default function ReqViewer({
/>
)}
</div>
{/* Export Dialog */}
<Dialog open={showExportDialog} onOpenChange={setShowExportDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>Export Events to JSONL</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="text-sm text-muted-foreground">
{isExporting ? (
<>
Exporting{" "}
<span className="font-semibold">{events.length}</span> event
{events.length !== 1 ? "s" : ""}...
</>
) : (
<>
Export <span className="font-semibold">{events.length}</span>{" "}
event{events.length !== 1 ? "s" : ""} as JSONL
(newline-delimited JSON).
</>
)}
</div>
{isExporting && events.length > 1000 && (
<div className="space-y-2">
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>Processing events...</span>
<span>{exportProgress}%</span>
</div>
<Progress value={exportProgress} className="h-2" />
</div>
)}
<div className="space-y-2">
<label htmlFor="filename" className="text-sm font-medium">
Filename
</label>
<Input
id="filename"
autoFocus
value={exportFilename}
onChange={(e) => setExportFilename(e.target.value)}
placeholder="Enter filename"
disabled={isExporting}
onKeyDown={(e) => {
if (e.key === "Enter" && exportFilename.trim() && !isExporting) {
handleExport();
}
}}
/>
<div className="text-xs text-muted-foreground">
.jsonl extension will be added automatically
</div>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setShowExportDialog(false)}
disabled={isExporting}
>
Cancel
</Button>
<Button
onClick={handleExport}
disabled={!exportFilename.trim() || isExporting}
>
{isExporting ? (
<>
<Loader2 className="size-4 mr-2 animate-spin" />
Exporting...
</>
) : (
<>
<Download className="size-4 mr-2" />
Export
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -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<typeof ProgressPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
>(({ className, value, ...props }, ref) => (
<ProgressPrimitive.Root
ref={ref}
className={cn(
"relative h-2 w-full overflow-hidden rounded-full bg-primary/20",
className,
)}
{...props}
>
<ProgressPrimitive.Indicator
className="h-full w-full flex-1 bg-primary transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
));
Progress.displayName = ProgressPrimitive.Root.displayName;
export { Progress };

18
src/lib/filename-utils.ts Normal file
View File

@@ -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)
);
}

View File

@@ -63,7 +63,9 @@ class GrimoireDb extends Dexie {
console.log("[DB Migration v6] Normalizing relay URLs...");
// Migrate relayAuthPreferences
const authPrefs = await tx.table<RelayAuthPreference>("relayAuthPreferences").toArray();
const authPrefs = await tx
.table<RelayAuthPreference>("relayAuthPreferences")
.toArray();
const normalizedAuthPrefs = new Map<string, RelayAuthPreference>();
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!");
});