mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-08 22:47:02 +02:00
feat: export to JSONL
This commit is contained in:
40
package-lock.json
generated
40
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
26
src/components/ui/progress.tsx
Normal file
26
src/components/ui/progress.tsx
Normal 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
18
src/lib/filename-utils.ts
Normal 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)
|
||||
);
|
||||
}
|
||||
@@ -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!");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user