import { ref, shallowRef, type Ref, type ShallowRef } from 'vue' import { readErrorMessage } from '../utils/errors' export interface DelimitedTable { columns: string[] rows: Record[] rawRows: string[][] delimiter: string } export interface ParseDelimitedTextOptions { delimiter?: string headers?: readonly string[] hasHeaderRow?: boolean trim?: boolean } export interface FileImportValidationOptions { accept?: string | readonly string[] maxSizeBytes?: number } export interface FileImportOptions extends FileImportValidationOptions { encoding?: string parse?: (text: string, file: File) => TResult | Promise } export interface UseFileImportReturn { loading: ShallowRef error: Ref fileName: Ref result: Ref importFile: (file: File) => Promise clear: () => void } /** Stateful file reader for plugin import flows with shared validation and error handling. */ export function useFileImport( options: FileImportOptions = {}, ): UseFileImportReturn { const loading = shallowRef(false) const error = ref(null) const fileName = ref('') const result = ref(null) as Ref function clear(): void { loading.value = false error.value = null fileName.value = '' result.value = null } async function importFile(file: File): Promise { loading.value = true error.value = null fileName.value = file.name try { validateImportFile(file, options) const text = await readFileAsText(file, options.encoding) const parsed = options.parse ? await options.parse(text, file) : text as TResult result.value = parsed return parsed } catch (err) { const message = readErrorMessage(err, 'Failed to import file.') error.value = message throw err } finally { loading.value = false } } return { loading, error, fileName, result, importFile, clear, } } export function validateImportFile(file: File, options: FileImportValidationOptions = {}): void { if (options.maxSizeBytes !== undefined && file.size > options.maxSizeBytes) { throw new Error(`File is too large. Maximum size is ${formatBytes(options.maxSizeBytes)}.`) } const accept = normalizeAccept(options.accept) if (accept.length === 0) return const name = file.name.toLowerCase() const type = file.type.toLowerCase() const matches = accept.some((entry) => { if (entry.startsWith('.')) return name.endsWith(entry) if (entry.endsWith('/*')) return type.startsWith(entry.slice(0, -1)) return type === entry }) if (!matches) { throw new Error(`Unsupported file type. Expected ${accept.join(', ')}.`) } } export function readFileAsText(file: File, encoding?: string): Promise { if (typeof FileReader === 'undefined' && typeof file.text === 'function') { return file.text() } return new Promise((resolve, reject) => { const reader = new FileReader() reader.onload = () => resolve(String(reader.result ?? '')) reader.onerror = () => reject(reader.error ?? new Error('Failed to read file.')) reader.readAsText(file, encoding) }) } export function parseDelimitedText( text: string, options: ParseDelimitedTextOptions = {}, ): DelimitedTable { const trim = options.trim ?? true const delimiter = options.delimiter ?? detectDelimiter(text) const rawRows = parseDelimitedRows(text, delimiter) .filter(row => row.some(cell => cell.trim().length > 0)) .map(row => trim ? row.map(cell => cell.trim()) : row) const hasHeaderRow = options.hasHeaderRow ?? !options.headers const columns = options.headers ? [...options.headers] : hasHeaderRow ? rawRows[0] ?? [] : createColumnNames(maxRowLength(rawRows)) const dataRows = hasHeaderRow && !options.headers ? rawRows.slice(1) : rawRows const rows = dataRows.map(row => Object.fromEntries( columns.map((column, index) => [column, row[index] ?? '']), )) return { columns, rows, rawRows, delimiter, } } export function detectDelimiter(text: string): string { const firstLine = text.split(/\r?\n/, 1)[0] ?? '' const commaCount = countChar(firstLine, ',') const tabCount = countChar(firstLine, '\t') const semicolonCount = countChar(firstLine, ';') if (tabCount > commaCount && tabCount >= semicolonCount) return '\t' if (semicolonCount > commaCount) return ';' return ',' } function parseDelimitedRows(text: string, delimiter: string): string[][] { const rows: string[][] = [] let row: string[] = [] let field = '' let quoted = false for (let index = 0; index < text.length; index++) { const char = text[index] const next = text[index + 1] if (char === '"') { if (quoted && next === '"') { field += '"' index++ } else { quoted = !quoted } continue } if (!quoted && char === delimiter) { row.push(field) field = '' continue } if (!quoted && (char === '\n' || char === '\r')) { if (char === '\r' && next === '\n') index++ row.push(field) rows.push(row) row = [] field = '' continue } field += char } row.push(field) rows.push(row) return rows } function normalizeAccept(accept: string | readonly string[] | undefined): string[] { if (!accept) return [] const entries = typeof accept === 'string' ? accept.split(',') : [...accept] return entries .map((entry: string) => entry.trim().toLowerCase()) .filter(Boolean) } function createColumnNames(count: number): string[] { return Array.from({ length: count }, (_, index) => `column_${index + 1}`) } function maxRowLength(rows: readonly string[][]): number { return rows.reduce((max, row) => Math.max(max, row.length), 0) } function countChar(value: string, char: string): number { return [...value].filter(candidate => candidate === char).length } function formatBytes(bytes: number): string { if (bytes < 1024) return `${bytes} B` if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB` return `${Math.round(bytes / (1024 * 1024))} MB` }