From 982405ec944f94baf43594b0ed2f06329ff4e9ed Mon Sep 17 00:00:00 2001 From: Krishan Bhasin <164889026+krishanbhasin-px@users.noreply.github.com> Date: Sat, 29 Nov 2025 09:45:11 +0000 Subject: [PATCH] feat: render CSVs as table (#5569) Co-authored-by: Henrique Dias --- frontend/src/components/files/CsvViewer.vue | 202 ++++++++++++++++++++ frontend/src/i18n/en.json | 7 +- frontend/src/utils/csv.ts | 61 ++++++ frontend/src/views/Files.vue | 6 + frontend/src/views/files/Preview.vue | 37 +++- 5 files changed, 310 insertions(+), 3 deletions(-) create mode 100644 frontend/src/components/files/CsvViewer.vue create mode 100644 frontend/src/utils/csv.ts diff --git a/frontend/src/components/files/CsvViewer.vue b/frontend/src/components/files/CsvViewer.vue new file mode 100644 index 00000000..ad1e7e22 --- /dev/null +++ b/frontend/src/components/files/CsvViewer.vue @@ -0,0 +1,202 @@ + + + + + diff --git a/frontend/src/i18n/en.json b/frontend/src/i18n/en.json index d7299444..869991fa 100644 --- a/frontend/src/i18n/en.json +++ b/frontend/src/i18n/en.json @@ -43,7 +43,8 @@ "upload": "Upload", "openFile": "Open file", "discardChanges": "Discard", - "saveChanges": "Save changes" + "saveChanges": "Save changes", + "editAsText": "Edit as Text" }, "download": { "downloadFile": "Download File", @@ -75,7 +76,9 @@ "sortByLastModified": "Sort by last modified", "sortByName": "Sort by name", "sortBySize": "Sort by size", - "noPreview": "Preview is not available for this file." + "noPreview": "Preview is not available for this file.", + "csvTooLarge": "CSV file is too large for preview (>5MB). Please download to view.", + "csvLoadFailed": "Failed to load CSV file." }, "help": { "click": "select file or directory", diff --git a/frontend/src/utils/csv.ts b/frontend/src/utils/csv.ts new file mode 100644 index 00000000..5a55e501 --- /dev/null +++ b/frontend/src/utils/csv.ts @@ -0,0 +1,61 @@ +export interface CsvData { + headers: string[]; + rows: string[][]; +} + +/** + * Parse CSV content into headers and rows + * Supports quoted fields and handles commas within quotes + */ +export function parseCSV(content: string): CsvData { + if (!content || content.trim().length === 0) { + return { headers: [], rows: [] }; + } + + const lines = content.split(/\r?\n/); + const result: string[][] = []; + + for (const line of lines) { + if (line.trim().length === 0) continue; + + const row: string[] = []; + let currentField = ""; + let inQuotes = false; + + for (let i = 0; i < line.length; i++) { + const char = line[i]; + const nextChar = line[i + 1]; + + if (char === '"') { + if (inQuotes && nextChar === '"') { + // Escaped quote + currentField += '"'; + i++; // Skip next quote + } else { + // Toggle quote state + inQuotes = !inQuotes; + } + } else if (char === "," && !inQuotes) { + // Field separator + row.push(currentField); + currentField = ""; + } else { + currentField += char; + } + } + + // Add the last field + row.push(currentField); + result.push(row); + } + + if (result.length === 0) { + return { headers: [], rows: [] }; + } + + // First row is headers + const headers = result[0]; + const rows = result.slice(1); + + return { headers, rows }; +} diff --git a/frontend/src/views/Files.vue b/frontend/src/views/Files.vue index 5001659f..8952f209 100644 --- a/frontend/src/views/Files.vue +++ b/frontend/src/views/Files.vue @@ -69,6 +69,12 @@ const currentView = computed(() => { if (fileStore.req.isDir) { return FileListing; + } else if (fileStore.req.extension.toLowerCase() === ".csv") { + // CSV files use Preview for table view, unless ?edit=true + if (route.query.edit === "true") { + return Editor; + } + return Preview; } else if ( fileStore.req.type === "text" || fileStore.req.type === "textImmutable" diff --git a/frontend/src/views/files/Preview.vue b/frontend/src/views/files/Preview.vue index 69cbc489..09562856 100644 --- a/frontend/src/views/files/Preview.vue +++ b/frontend/src/views/files/Preview.vue @@ -6,7 +6,7 @@ @mousemove="toggleNavigation" @touchstart="toggleNavigation" > - + {{ name }} + {{ size }}% + (false); const autoPlay = ref(false); const previousRaw = ref(""); const nextRaw = ref(""); +const csvContent = ref(""); +const csvError = ref(""); const player = ref(null); @@ -248,6 +264,8 @@ const authStore = useAuthStore(); const fileStore = useFileStore(); const layoutStore = useLayoutStore(); +const { t } = useI18n(); + const route = useRoute(); const router = useRouter(); @@ -279,6 +297,7 @@ const isPdf = computed(() => fileStore.req?.extension.toLowerCase() == ".pdf"); const isEpub = computed( () => fileStore.req?.extension.toLowerCase() == ".epub" ); +const isCsv = computed(() => fileStore.req?.extension.toLowerCase() == ".csv"); const isResizeEnabled = computed(() => resizePreview); @@ -366,6 +385,18 @@ const updatePreview = async () => { const dirs = route.fullPath.split("/"); name.value = decodeURIComponent(dirs[dirs.length - 1]); + // Load CSV content if it's a CSV file + if (isCsv.value && fileStore.req) { + csvContent.value = ""; + csvError.value = ""; + + if (fileStore.req.size > CSV_MAX_SIZE) { + csvError.value = t("files.csvTooLarge"); + } else { + csvContent.value = fileStore.req.content ?? ""; + } + } + if (!listing.value) { try { const path = url.removeLastDir(route.path); @@ -435,4 +466,8 @@ const close = () => { }; const download = () => window.open(downloadUrl.value); + +const editAsText = () => { + router.push({ path: route.path, query: { edit: "true" } }); +};