diff --git a/ui/src/lib/csv.test.ts b/ui/src/lib/csv.test.ts new file mode 100644 index 000000000..116f11cdc --- /dev/null +++ b/ui/src/lib/csv.test.ts @@ -0,0 +1,69 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { downloadCsv, toCsv, toExcelCsv } from "./csv"; + +afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + document.body.replaceChildren(); +}); + +describe("toCsv", () => { + it("sanitizes formula-leading cells", () => { + const csv = toCsv( + [{ header: "filename", value: (row: { filename: string }) => row.filename }], + [ + { filename: "=cmd|' /C calc'!A0" }, + { filename: "+sum(1,2)" }, + { filename: "-10" }, + { filename: "@admin" }, + { filename: "\t=cmd" }, + { filename: "\r=cmd" }, + { filename: "\n=cmd" }, + { filename: "safe.pdf" }, + ], + ); + + expect(csv).toBe([ + "filename", + "'=cmd|' /C calc'!A0", + "\"'+sum(1,2)\"", + "'-10", + "'@admin", + "'\t=cmd", + "\"'\r=cmd\"", + "\"'\n=cmd\"", + "safe.pdf", + ].join("\r\n")); + }); + + it("adds Excel encoding and delimiter hints to downloads", () => { + const csv = toExcelCsv( + [{ header: "filename", value: (row: { filename: string }) => row.filename }], + [{ filename: "Rapport_priv\u00e9.pdf" }], + ); + + expect(csv).toBe("\uFEFFsep=,\r\nfilename\r\nRapport_priv\u00e9.pdf"); + }); + + it("keeps the object URL alive until the browser handles the click", () => { + vi.useFakeTimers(); + const createObjectURL = vi.fn(() => "blob:csv"); + const revokeObjectURL = vi.fn(); + vi.stubGlobal("URL", { createObjectURL, revokeObjectURL }); + const click = vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {}); + + downloadCsv("report.csv", [{ header: "name", value: (row: { name: string }) => row.name }], [ + { name: "report" }, + ]); + + expect(document.querySelector('a[download="report.csv"]')).not.toBeNull(); + expect(click).toHaveBeenCalledOnce(); + expect(revokeObjectURL).not.toHaveBeenCalled(); + + vi.runAllTimers(); + + expect(document.querySelector('a[download="report.csv"]')).toBeNull(); + expect(revokeObjectURL).toHaveBeenCalledWith("blob:csv"); + }); +}); diff --git a/ui/src/lib/csv.ts b/ui/src/lib/csv.ts new file mode 100644 index 000000000..50cb0024a --- /dev/null +++ b/ui/src/lib/csv.ts @@ -0,0 +1,45 @@ +type CsvColumn = { + header: string; + value: (row: T) => unknown; +}; + +const FORMULA_PREFIX = /^[=+\-@\t\r\n]/; +const CSV_DELIMITER = ","; +const CSV_LINE_BREAK = "\r\n"; +const UTF8_BOM = "\uFEFF"; + +function csvCell(value: unknown): string { + const raw = value == null ? "" : String(value); + const text = FORMULA_PREFIX.test(raw) ? `'${raw}` : raw; + return /[",\n\r]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text; +} + +export function toCsv(columns: CsvColumn[], rows: T[]): string { + return [ + columns.map((column) => csvCell(column.header)).join(CSV_DELIMITER), + ...rows.map((row) => columns.map((column) => csvCell(column.value(row))).join(CSV_DELIMITER)), + ].join(CSV_LINE_BREAK); +} + +export function toExcelCsv(columns: CsvColumn[], rows: T[]): string { + return `${UTF8_BOM}sep=${CSV_DELIMITER}${CSV_LINE_BREAK}${toCsv(columns, rows)}`; +} + +export function downloadCsv(filename: string, columns: CsvColumn[], rows: T[]) { + const blob = new Blob([toExcelCsv(columns, rows)], { type: "text/csv;charset=utf-8" }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = filename; + link.style.display = "none"; + + try { + document.body.appendChild(link); + link.click(); + } finally { + window.setTimeout(() => { + link.remove(); + URL.revokeObjectURL(url); + }, 0); + } +} diff --git a/ui/src/pages/admin/documents/list.test.tsx b/ui/src/pages/admin/documents/list.test.tsx index f81fbdd8e..b82e0219d 100644 --- a/ui/src/pages/admin/documents/list.test.tsx +++ b/ui/src/pages/admin/documents/list.test.tsx @@ -7,6 +7,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { toast } from "sonner"; import type { Action } from "sonner"; import { deleteFile, uploadFile } from "@/lib/api/indexing"; +import { downloadCsv } from "@/lib/csv"; import DocumentListPage from "./list"; vi.mock("sonner", () => ({ @@ -54,7 +55,7 @@ vi.mock("@/lib/api/documents", () => ({ partition: "docs", filename: "b.pdf", mimetype: "application/pdf", - indexed_at: "2026-01-02T00:00:00Z", + indexed_at: new Date(2026, 0, 2, 0, 30).toISOString(), }, ], }), @@ -66,8 +67,13 @@ vi.mock("@/lib/api/indexing", () => ({ newFileId: vi.fn(() => "new-file-id"), })); +vi.mock("@/lib/csv", () => ({ + downloadCsv: vi.fn(), +})); + const deleteFileMock = vi.mocked(deleteFile); const uploadFileMock = vi.mocked(uploadFile); +const downloadCsvMock = vi.mocked(downloadCsv); const toastSuccessMock = vi.mocked(toast.success); function LocationProbe() { @@ -104,6 +110,7 @@ describe("DocumentListPage", () => { permissions.superAdminModeResolved = true; deleteFileMock.mockClear(); uploadFileMock.mockReset(); + downloadCsvMock.mockClear(); toastSuccessMock.mockClear(); }); @@ -122,6 +129,38 @@ describe("DocumentListPage", () => { expect(fileLink.className).toContain("truncate"); }); + it("filters documents by file name and indexed date before exporting", async () => { + renderDocuments(); + + expect(await screen.findByText("a.pdf")).not.toBeNull(); + await userEvent.type(screen.getByLabelText("Search files"), "b"); + await userEvent.type(screen.getByLabelText("Indexed since"), "2026-01-02"); + + expect(screen.queryByText("a.pdf")).toBeNull(); + expect(screen.getByText("b.pdf")).not.toBeNull(); + expect(screen.getByText("1 of 2 file(s)")).not.toBeNull(); + + await userEvent.click(screen.getByRole("button", { name: /export csv/i })); + + expect(downloadCsvMock).toHaveBeenCalledWith( + "openrag-documents-docs.csv", + expect.any(Array), + [expect.objectContaining({ file_id: "file-b" })], + ); + }); + + it("reports CSV download failures", async () => { + downloadCsvMock.mockImplementationOnce(() => { + throw new Error("downloads unavailable"); + }); + renderDocuments(); + + expect(await screen.findByText("a.pdf")).not.toBeNull(); + await userEvent.click(screen.getByRole("button", { name: /export csv/i })); + + expect(toast.error).toHaveBeenCalledWith("CSV export failed: downloads unavailable"); + }); + it("opens the upload dialog for a partition upload link", async () => { renderDocuments(["/documents?partition=docs&upload=1"]); diff --git a/ui/src/pages/admin/documents/list.tsx b/ui/src/pages/admin/documents/list.tsx index d62f13bf8..941f56f58 100644 --- a/ui/src/pages/admin/documents/list.tsx +++ b/ui/src/pages/admin/documents/list.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Link, useNavigate, useSearchParams } from "react-router-dom"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import type { ColumnDef, OnChangeFn, RowSelectionState } from "@tanstack/react-table"; -import { Plus, Eye, Trash2, RefreshCw } from "lucide-react"; +import { Download, Plus, Eye, Trash2, RefreshCw, Search } from "lucide-react"; import { toast } from "sonner"; import { PageHeader } from "@/components/shared/page-header"; @@ -32,11 +32,13 @@ import { listPartitionFiles, type PartitionFile } from "@/lib/api/documents"; import { uploadFile, deleteFile, newFileId } from "@/lib/api/indexing"; import { listPartitions } from "@/lib/api/partitions"; import { usePermissions } from "@/lib/permissions"; +import { downloadCsv } from "@/lib/csv"; import { resolveDocumentsPartition } from "./partition-selection"; const fileHref = (partition: string, fileId: string) => `/documents/${encodeURIComponent(partition)}/${encodeURIComponent(fileId)}`; const fileLabel = (f: PartitionFile) => (f.filename as string) || f.file_id; +const str = (v: unknown) => (v == null ? "" : String(v)); export default function DocumentListPage() { const queryClient = useQueryClient(); @@ -54,6 +56,8 @@ export default function DocumentListPage() { const [uploadOpen, setUploadOpen] = useState(false); const [files, setFiles] = useState([]); const [uploading, setUploading] = useState(false); + const [fileSearch, setFileSearch] = useState(""); + const [indexedSince, setIndexedSince] = useState(""); const [fileSelection, setFileSelection] = useState<{ partition: string; rows: RowSelectionState; @@ -143,6 +147,21 @@ export default function DocumentListPage() { refetchInterval: 5000, }); const fileRows = useMemo(() => filesQuery.data?.files ?? [], [filesQuery.data?.files]); + const filteredFileRows = useMemo(() => { + const q = fileSearch.trim().toLowerCase(); + const indexedSinceTime = indexedSince ? new Date(`${indexedSince}T00:00:00`).getTime() : null; + return fileRows.filter((file) => { + const filename = fileLabel(file); + const fileTime = Date.parse(str(file.indexed_at ?? file.created_at)); + const matchesSearch = + !q || + [filename, file.file_id, file.mimetype].some((value) => + str(value).toLowerCase().includes(q), + ); + const matchesDate = indexedSinceTime === null || (Number.isFinite(fileTime) && fileTime >= indexedSinceTime); + return matchesSearch && matchesDate; + }); + }, [fileRows, fileSearch, indexedSince]); const fileRowSelection = useMemo( () => (fileSelection.partition === selected ? fileSelection.rows : {}), [fileSelection.partition, fileSelection.rows, selected], @@ -158,10 +177,29 @@ export default function DocumentListPage() { [selected], ); const selectedFiles = useMemo( - () => fileRows.filter((file) => fileRowSelection[file.file_id]), - [fileRows, fileRowSelection], + () => filteredFileRows.filter((file) => fileRowSelection[file.file_id]), + [filteredFileRows, fileRowSelection], ); + const exportDocuments = () => { + try { + downloadCsv( + `openrag-documents-${selected || "partition"}.csv`, + [ + { header: "partition", value: () => selected }, + { header: "file_id", value: (file) => file.file_id }, + { header: "filename", value: (file) => fileLabel(file) }, + { header: "mimetype", value: (file) => file.mimetype }, + { header: "indexed_at", value: (file) => file.indexed_at }, + { header: "created_at", value: (file) => file.created_at }, + ], + filteredFileRows, + ); + } catch (error) { + toast.error(`CSV export failed: ${error instanceof Error ? error.message : "Unknown error"}`); + } + }; + useEffect(() => { if (!writable && Object.keys(fileRowSelection).length > 0) { // eslint-disable-next-line react-hooks/set-state-in-effect -- Clear stale controlled selection when write access is lost. @@ -358,7 +396,7 @@ export default function DocumentListPage() { } /> -
+
+
+ + setFileSearch(e.target.value)} + className="pl-9" + aria-label="Search files" + /> +
+ setIndexedSince(e.target.value)} + className="w-[150px]" + aria-label="Indexed since" + /> {writable && selectedFiles.length > 0 && ( <> )} -
+
{filesQuery.data && ( -

{fileRows.length} file(s)

+

+ {filteredFileRows.length} + {(fileSearch || indexedSince) && ` of ${fileRows.length}`} file(s) +

)} +
+

{filteredTasks.length} job{filteredTasks.length === 1 ? "" : "s"} @@ -262,6 +320,16 @@ export default function JobListPage() { isError={queueInfoQuery.isError} /> )} +