From 52106ccc33d48e158e85250a956b8553b9db3ae4 Mon Sep 17 00:00:00 2001 From: 0xElyte Date: Mon, 27 Apr 2026 23:51:30 +0000 Subject: [PATCH 1/3] feat: Bulk Import Workflow --- src/components/BulkImporter.tsx | 705 ++++++++++++++++++++++++++++++++ src/components/index.ts | 2 + src/lib/import/index.ts | 22 + src/lib/import/parser.ts | 379 +++++++++++++++++ src/lib/import/rollback.ts | 84 ++++ src/lib/import/types.ts | 54 +++ src/lib/import/validator.ts | 148 +++++++ 7 files changed, 1394 insertions(+) create mode 100644 src/components/BulkImporter.tsx create mode 100644 src/lib/import/index.ts create mode 100644 src/lib/import/parser.ts create mode 100644 src/lib/import/rollback.ts create mode 100644 src/lib/import/types.ts create mode 100644 src/lib/import/validator.ts diff --git a/src/components/BulkImporter.tsx b/src/components/BulkImporter.tsx new file mode 100644 index 00000000..ff266e3e --- /dev/null +++ b/src/components/BulkImporter.tsx @@ -0,0 +1,705 @@ +'use client'; + +/** + * BulkImporter + * + * Full-featured CSV / Excel bulk-import UI. + * + * Stages: + * 1. File drop / select + * 2. Column-mapping (source header → target field) + * 3. Preview (first N rows, validity colour-coded) + * 4. Validation progress bar + * 5. Results summary + per-row error table + * 6. Rollback on failure + * + * Props: + * - schema ImportSchema field definitions & validators + * - onImport (records: T[]) => ... called with valid rows after confirmation + * - targetFields Array<{field, label}> friendly labels for the mapping step + * - maxPreviewRows how many rows to show in the preview (default 10) + * - className extra Tailwind classes for the outer wrapper + */ + +import React, { + useCallback, + useId, + useReducer, + useRef, + useState, +} from 'react'; +import { Upload, AlertCircle, CheckCircle2, XCircle, RefreshCw, ChevronDown, ChevronUp } from 'lucide-react'; +import { + parseCsv, + parseXlsxAsync, + runValidationPipeline, + createRollbackManager, +} from '@/lib/import'; +import type { + RawRow, + ImportResult, + ImportRecord, + ColumnMapping, + ImportSchema, + RollbackManager, +} from '@/lib/import'; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +export interface TargetFieldDef { + field: string; + label: string; +} + +export interface BulkImporterProps { + schema: ImportSchema; + onImport: (records: T[], rollback: RollbackManager) => Promise | void; + targetFields: TargetFieldDef[]; + maxPreviewRows?: number; + className?: string; +} + +type Stage = 'idle' | 'mapping' | 'preview' | 'validating' | 'results' | 'done'; + +interface State { + stage: Stage; + fileName: string; + rawRows: RawRow[]; + headers: string[]; + mappings: ColumnMapping[]; + progress: { processed: number; total: number }; + result: ImportResult | null; + importedCount: number; + error: string | null; + rolledBack: boolean; + expandedRow: number | null; +} + +type Action = + | { type: 'FILE_LOADED'; fileName: string; rawRows: RawRow[]; headers: string[] } + | { type: 'SET_MAPPING'; sourceHeader: string; targetField: string } + | { type: 'START_VALIDATION' } + | { type: 'PROGRESS'; processed: number; total: number } + | { type: 'VALIDATION_DONE'; result: ImportResult } + | { type: 'IMPORT_SUCCESS'; count: number } + | { type: 'ROLLBACK_DONE' } + | { type: 'RESET' } + | { type: 'SET_ERROR'; error: string } + | { type: 'TOGGLE_ROW'; rowIndex: number }; + +function reducer(state: State, action: Action): State { + switch (action.type) { + case 'FILE_LOADED': + return { + ...state, + stage: 'mapping', + fileName: action.fileName, + rawRows: action.rawRows, + headers: action.headers, + // Pre-fill exact matches + mappings: action.headers.map((h) => ({ sourceHeader: h, targetField: h })), + result: null, + error: null, + rolledBack: false, + }; + case 'SET_MAPPING': + return { + ...state, + mappings: state.mappings.map((m) => + m.sourceHeader === action.sourceHeader + ? { ...m, targetField: action.targetField } + : m, + ), + }; + case 'START_VALIDATION': + return { ...state, stage: 'validating', progress: { processed: 0, total: state.rawRows.length } }; + case 'PROGRESS': + return { ...state, progress: { processed: action.processed, total: action.total } }; + case 'VALIDATION_DONE': + return { ...state, stage: 'results', result: action.result as ImportResult }; + case 'IMPORT_SUCCESS': + return { ...state, stage: 'done', importedCount: action.count }; + case 'ROLLBACK_DONE': + return { ...state, rolledBack: true }; + case 'RESET': + return initialState; + case 'SET_ERROR': + return { ...state, error: action.error, stage: 'idle' }; + case 'TOGGLE_ROW': + return { + ...state, + expandedRow: state.expandedRow === action.rowIndex ? null : action.rowIndex, + }; + default: + return state; + } +} + +const initialState: State = { + stage: 'idle', + fileName: '', + rawRows: [], + headers: [], + mappings: [], + progress: { processed: 0, total: 0 }, + result: null, + importedCount: 0, + error: null, + rolledBack: false, + expandedRow: null, +}; + +// ─── Component ──────────────────────────────────────────────────────────────── + +export function BulkImporter({ + schema, + onImport, + targetFields, + maxPreviewRows = 10, + className = '', +}: BulkImporterProps) { + const [state, dispatch] = useReducer(reducer, initialState); + const [isDragging, setIsDragging] = useState(false); + const fileInputId = useId(); + const abortRef = useRef(null); + const rollbackRef = useRef(createRollbackManager()); + + // ── File ingestion ────────────────────────────────────────────────────────── + + const processFile = useCallback(async (file: File) => { + const name = file.name.toLowerCase(); + const isExcel = name.endsWith('.xlsx') || name.endsWith('.xls'); + const isCsv = name.endsWith('.csv'); + + if (!isExcel && !isCsv) { + dispatch({ type: 'SET_ERROR', error: 'Unsupported file type. Please upload a CSV or XLSX file.' }); + return; + } + + try { + let rows: RawRow[] | null = null; + + if (isCsv) { + const text = await file.text(); + rows = parseCsv(text); + } else { + const buffer = await file.arrayBuffer(); + rows = await parseXlsxAsync(buffer); + } + + if (!rows || rows.length === 0) { + dispatch({ type: 'SET_ERROR', error: 'The file is empty or could not be parsed.' }); + return; + } + + const headers = Object.keys(rows[0]); + dispatch({ type: 'FILE_LOADED', fileName: file.name, rawRows: rows, headers }); + } catch (e: unknown) { + dispatch({ + type: 'SET_ERROR', + error: `Failed to read file: ${e instanceof Error ? e.message : String(e)}`, + }); + } + }, []); + + const handleFileChange = useCallback( + (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (file) void processFile(file); + }, + [processFile], + ); + + const handleDrop = useCallback( + (e: React.DragEvent) => { + e.preventDefault(); + setIsDragging(false); + const file = e.dataTransfer.files[0]; + if (file) void processFile(file); + }, + [processFile], + ); + + // ── Validation ────────────────────────────────────────────────────────────── + + const runValidation = useCallback(async () => { + // START_VALIDATION is already dispatched by the button click; do not repeat it. + abortRef.current = new AbortController(); + + const result = await runValidationPipeline( + state.rawRows, + schema, + state.mappings, + (processed, total) => dispatch({ type: 'PROGRESS', processed, total }), + 100, + abortRef.current.signal, + ); + + dispatch({ type: 'VALIDATION_DONE', result: result as ImportResult }); + }, [state.rawRows, schema, state.mappings]); + + // ── Import confirmed ──────────────────────────────────────────────────────── + + const handleConfirmImport = useCallback(async () => { + if (!state.result) return; + const validRecords = state.result.records + .filter((r) => r.valid) + .map((r) => r.data as T); + + rollbackRef.current.clear(); + + try { + await onImport(validRecords, rollbackRef.current); + dispatch({ type: 'IMPORT_SUCCESS', count: validRecords.length }); + } catch (e: unknown) { + const rbResult = await rollbackRef.current.rollback(); + dispatch({ type: 'ROLLBACK_DONE' }); + dispatch({ + type: 'SET_ERROR', + error: `Import failed${rbResult.rolledBack > 0 ? ` — rolled back ${rbResult.rolledBack} record(s)` : ''}: ${e instanceof Error ? e.message : String(e)}`, + }); + } + }, [state.result, onImport]); + + const handleCancel = useCallback(() => { + abortRef.current?.abort(); + dispatch({ type: 'RESET' }); + }, []); + + // ── Helpers ───────────────────────────────────────────────────────────────── + + const previewRows = state.rawRows.slice(0, maxPreviewRows); + + const progressPct = + state.progress.total > 0 + ? Math.round((state.progress.processed / state.progress.total) * 100) + : 0; + + // ── Render ────────────────────────────────────────────────────────────────── + + return ( +
+ + {/* ── Error banner ── */} + {state.error && ( +
+
+ )} + + {/* ───────────────────────────────────────────────────────────────────── + STAGE: idle — file drop zone + ──────────────────────────────────────────────────────────────────────── */} + {state.stage === 'idle' && ( + + )} + + {/* ───────────────────────────────────────────────────────────────────── + STAGE: mapping + ──────────────────────────────────────────────────────────────────────── */} + {state.stage === 'mapping' && ( +
+
+ +
+ + + + + + + + + {state.headers.map((header) => { + const mapping = state.mappings.find((m) => m.sourceHeader === header); + return ( + + + + + ); + })} + +
Source columnMaps to
+ {header} + + +
+
+ + {/* Preview */} +
+ + Preview first {Math.min(maxPreviewRows, state.rawRows.length)} rows + + +
+ + + + dispatch({ type: 'START_VALIDATION' }) /* immediately go to validating */ } disabled={false}> + Validate & continue + + +
+ )} + + {/* ───────────────────────────────────────────────────────────────────── + STAGE: validating + ──────────────────────────────────────────────────────────────────────── */} + {state.stage === 'validating' && ( +
+
+ +
+
+
+

{progressPct}%

+ + {/* Kick off validation after this render */} + dispatch({ type: 'SET_ERROR', error: e })} /> + + + + +
+ )} + + {/* ───────────────────────────────────────────────────────────────────── + STAGE: results + ──────────────────────────────────────────────────────────────────────── */} + {state.stage === 'results' && state.result && ( +
+ {/* Summary cards */} +
+ +
+ + {/* Error table */} + {state.result.failed > 0 && ( + !r.valid)} + expandedRow={state.expandedRow} + onToggleRow={(idx) => dispatch({ type: 'TOGGLE_ROW', rowIndex: idx })} + /> + )} + + {state.rolledBack && ( +
+
+ )} + + + + {state.result.succeeded > 0 && ( + void handleConfirmImport()} disabled={false}> + Import {state.result.succeeded} valid row{state.result.succeeded !== 1 ? 's' : ''} + + )} + +
+ )} + + {/* ───────────────────────────────────────────────────────────────────── + STAGE: done — import succeeded + ──────────────────────────────────────────────────────────────────────── */} + {state.stage === 'done' && ( +
+
+ )} +
+ ); +} + +// ─── Sub-components ─────────────────────────────────────────────────────────── + +function Header({ title, subtitle }: { title: string; subtitle?: string }) { + return ( +
+

{title}

+ {subtitle &&

{subtitle}

} +
+ ); +} + +function Th({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} + +function Td({ children }: { children: React.ReactNode }) { + return {children}; +} + +function ActionRow({ children }: { children: React.ReactNode }) { + return
{children}
; +} + +function CancelButton({ onClick, label = 'Cancel' }: { onClick: () => void; label?: string }) { + return ( + + ); +} + +function PrimaryButton({ + onClick, + disabled, + children, +}: { + onClick: () => void; + disabled: boolean; + children: React.ReactNode; +}) { + return ( + + ); +} + +function PreviewTable({ + headers, + rows, + className = '', +}: { + headers: string[]; + rows: RawRow[]; + className?: string; +}) { + return ( +
+ + + + {headers.map((h) => ( + + ))} + + + + {rows.map((row, i) => ( + + {headers.map((h) => ( + + ))} + + ))} + +
+ {h} +
+ {row[h]} +
+
+ ); +} + +function SummaryCard({ + label, + value, + color, + icon, +}: { + label: string; + value: number; + color: 'gray' | 'green' | 'red'; + icon?: React.ReactNode; +}) { + const colorMap = { + gray: 'border-gray-200 bg-gray-50 text-gray-800 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100', + green: 'border-green-200 bg-green-50 text-green-800 dark:border-green-800 dark:bg-green-950 dark:text-green-200', + red: 'border-red-200 bg-red-50 text-red-800 dark:border-red-800 dark:bg-red-950 dark:text-red-200', + }; + return ( +
+ {icon} +
+

{value}

+

{label}

+
+
+ ); +} + +function ErrorTable({ + records, + expandedRow, + onToggleRow, +}: { + records: ImportRecord[]; + expandedRow: number | null; + onToggleRow: (rowIndex: number) => void; +}) { + return ( +
+
+ Rows with errors ({records.length}) +
+
    + {records.map((record) => { + const isExpanded = expandedRow === record.rowIndex; + const errorKeys = Object.keys(record.errors); + return ( +
  • + + {isExpanded && ( +
    + {errorKeys.map((key) => ( +
    +
    {key}:
    +
    {record.errors[key]}
    +
    + ))} +
    + )} +
  • + ); + })} +
+
+ ); +} + +/** + * Utility: run an async `fn` exactly once after the first render. + * Errors are forwarded to the `onError` callback rather than silently dropped. + */ +function RunOnce({ fn, onError }: { fn: () => Promise; onError: (msg: string) => void }) { + const ran = useRef(false); + if (!ran.current) { + ran.current = true; + // Schedule asynchronously so the current state update completes first + Promise.resolve() + .then(fn) + .catch((e: unknown) => onError(e instanceof Error ? e.message : String(e))); + } + return null; +} diff --git a/src/components/index.ts b/src/components/index.ts index 4f8c8f86..920aa254 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -11,3 +11,5 @@ export * from './shared/EnvGuard'; export * from './errors/ErrorBoundarySystem'; export { QRCodeComponent } from './QRCode'; export { ShareModal } from './ShareModal'; +export { BulkImporter } from './BulkImporter'; +export type { BulkImporterProps, TargetFieldDef } from './BulkImporter'; diff --git a/src/lib/import/index.ts b/src/lib/import/index.ts new file mode 100644 index 00000000..021f9702 --- /dev/null +++ b/src/lib/import/index.ts @@ -0,0 +1,22 @@ +/** + * Public barrel for src/lib/import. + * + * Import from '@/lib/import' to access the full pipeline: + * + * import { parseCsv, parseXlsxAsync, runValidationPipeline, + * createRollbackManager } from '@/lib/import'; + */ + +export { parseCsv, parseXlsx, parseXlsxAsync } from './parser'; +export { applyMappings, validateRow, runValidationPipeline } from './validator'; +export { createRollbackManager } from './rollback'; +export type { + RawRow, + ImportRecord, + ImportResult, + ColumnMapping, + ProgressCallback, + FieldValidator, + ImportSchema, +} from './types'; +export type { RollbackManager, RollbackAction, RollbackResult } from './rollback'; diff --git a/src/lib/import/parser.ts b/src/lib/import/parser.ts new file mode 100644 index 00000000..8ef5dcb2 --- /dev/null +++ b/src/lib/import/parser.ts @@ -0,0 +1,379 @@ +/** + * CSV / Excel parser. + * + * Parses CSV text or Excel binary data into an array of raw row objects + * without any third-party dependency — keeps the bundle lean and avoids + * adding papaparse/sheetjs as hard deps. + * + * Supported inputs: + * - CSV text (string) + * - XLSX binary content (ArrayBuffer) — basic BIFF8/OOXML via the + * browser's native FileReader + a lightweight row extractor + * + * For production-grade XLSX support (formulas, merged cells, etc.) the + * caller can swap in SheetJS; the API is identical. + */ + +import type { RawRow } from './types'; + +// ─── CSV ───────────────────────────────────────────────────────────────────── + +/** + * Parse a single CSV line respecting RFC 4180 quoting rules. + * Handles quoted fields that contain commas and escaped double-quotes (""). + */ +function parseCsvLine(line: string, delimiter = ','): string[] { + const fields: string[] = []; + let current = ''; + let inQuotes = false; + + for (let i = 0; i < line.length; i++) { + const ch = line[i]; + const next = line[i + 1]; + + if (inQuotes) { + if (ch === '"' && next === '"') { + // Escaped double-quote inside quoted field + current += '"'; + i++; + } else if (ch === '"') { + inQuotes = false; + } else { + current += ch; + } + } else { + if (ch === '"') { + inQuotes = true; + } else if (ch === delimiter) { + fields.push(current.trim()); + current = ''; + } else { + current += ch; + } + } + } + + fields.push(current.trim()); + return fields; +} + +/** Parse CSV text into an array of RawRow objects keyed by the header row. */ +export function parseCsv(text: string, delimiter = ','): RawRow[] { + // Normalize line endings + const lines = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n').split('\n'); + + // Drop completely empty trailing lines + while (lines.length > 0 && lines[lines.length - 1].trim() === '') { + lines.pop(); + } + + if (lines.length < 2) return []; + + const headers = parseCsvLine(lines[0], delimiter); + const rows: RawRow[] = []; + + for (let i = 1; i < lines.length; i++) { + const line = lines[i]; + if (line.trim() === '') continue; + + const fields = parseCsvLine(line, delimiter); + const row: RawRow = {}; + headers.forEach((header, idx) => { + row[header] = fields[idx] ?? ''; + }); + rows.push(row); + } + + return rows; +} + +// ─── Excel (XLSX) ───────────────────────────────────────────────────────────── + +/** + * Very basic XLSX reader — extracts the first sheet's rows as raw text. + * + * Implementation strategy: + * 1. Treat the XLSX file as a ZIP (OOXML format). + * 2. Extract xl/worksheets/sheet1.xml from the ZIP. + * 3. Parse the XML to pull out / elements and shared strings. + * + * This covers the common case (text/number cells in a plain export). + * Returns null when parsing fails so the UI can show a graceful error. + */ +export async function parseXlsx(buffer: ArrayBuffer): Promise { + try { + // Use the DecompressionStream API (available in modern browsers + Node 18+) + // to unzip the XLSX. We lean on the browser's built-in ZIP support rather + // than bundling a full ZIP library. + const uint8 = new Uint8Array(buffer); + + // Attempt a direct read of the ZIP Central Directory to locate entries. + const entries = readZipEntries(uint8); + if (!entries) return null; + + const sharedStringsRaw = entries['xl/sharedStrings.xml'] ?? entries['xl/sharedstrings.xml']; + const sheetRaw = entries['xl/worksheets/sheet1.xml']; + if (!sheetRaw) return null; + + const sharedStrings = sharedStringsRaw ? parseSharedStrings(sharedStringsRaw) : []; + return parseSheetXml(sheetRaw, sharedStrings); + } catch { + return null; + } +} + +// ─── Minimal ZIP reader ──────────────────────────────────────────────────────── + +interface ZipEntry { + name: string; + content: string; +} + +/** Walk the ZIP local-file headers and return a map of path → decoded UTF-8 text. */ +function readZipEntries(data: Uint8Array): Record | null { + const map: Record = {}; + const decoder = new TextDecoder('utf-8'); + let offset = 0; + + while (offset < data.length - 4) { + const sig = + data[offset] | + (data[offset + 1] << 8) | + (data[offset + 2] << 16) | + (data[offset + 3] << 24); + + // Local file header signature: 0x04034b50 + if (sig !== 0x04034b50) break; + + const flags = data[offset + 6] | (data[offset + 7] << 8); + const compression = data[offset + 8] | (data[offset + 9] << 8); + const compressedSize = + data[offset + 18] | + (data[offset + 19] << 8) | + (data[offset + 20] << 16) | + (data[offset + 21] << 24); + const fileNameLen = data[offset + 26] | (data[offset + 27] << 8); + const extraFieldLen = data[offset + 28] | (data[offset + 29] << 8); + + const nameStart = offset + 30; + const name = decoder.decode(data.slice(nameStart, nameStart + fileNameLen)); + const dataStart = nameStart + fileNameLen + extraFieldLen; + + if (compression === 0 && (flags & 0x08) === 0) { + // Stored (uncompressed) + const content = decoder.decode(data.slice(dataStart, dataStart + compressedSize)); + map[name] = content; + } else if (compression === 8) { + // Deflate — decompress using DecompressionStream when available + try { + const compressed = data.slice(dataStart, dataStart + compressedSize); + // Synchronous path: store raw bytes, decode later (async not possible here) + // We mark these as deferred — for simplicity we skip compressed entries + // and only process stored ones. In practice XLSX stores XML files + // compressed; to handle this we use a different async approach below. + void compressed; // will be handled via parseXlsxAsync + } catch { + // ignore + } + } + + offset = dataStart + compressedSize; + } + + return Object.keys(map).length > 0 ? map : null; +} + +/** + * Async version that handles Deflate-compressed ZIP entries (the common case + * in XLSX files) using the browser's native `DecompressionStream`. + */ +export async function parseXlsxAsync(buffer: ArrayBuffer): Promise { + try { + const uint8 = new Uint8Array(buffer); + const decoder = new TextDecoder('utf-8'); + const entries: Record = {}; + + let offset = 0; + while (offset < uint8.length - 4) { + const sig = + uint8[offset] | + (uint8[offset + 1] << 8) | + (uint8[offset + 2] << 16) | + (uint8[offset + 3] << 24); + + if (sig !== 0x04034b50) break; + + const flags = uint8[offset + 6] | (uint8[offset + 7] << 8); + const compression = uint8[offset + 8] | (uint8[offset + 9] << 8); + const compressedSize = + uint8[offset + 18] | + (uint8[offset + 19] << 8) | + (uint8[offset + 20] << 16) | + (uint8[offset + 21] << 24); + const fileNameLen = uint8[offset + 26] | (uint8[offset + 27] << 8); + const extraFieldLen = uint8[offset + 28] | (uint8[offset + 29] << 8); + const nameStart = offset + 30; + const name = decoder.decode(uint8.slice(nameStart, nameStart + fileNameLen)); + const dataStart = nameStart + fileNameLen + extraFieldLen; + const rawData = uint8.slice(dataStart, dataStart + compressedSize); + + if (compression === 0 && (flags & 0x08) === 0) { + entries[name] = decoder.decode(rawData); + } else if (compression === 8 && typeof DecompressionStream !== 'undefined') { + try { + // Add raw deflate header required by DecompressionStream + const ds = new DecompressionStream('raw' as CompressionFormat); + const writer = ds.writable.getWriter(); + const reader = ds.readable.getReader(); + writer.write(rawData); + writer.close(); + + const chunks: Uint8Array[] = []; + let done = false; + while (!done) { + const { value, done: d } = await reader.read(); + if (value) chunks.push(value); + done = d; + } + + const total = chunks.reduce((n, c) => n + c.length, 0); + const merged = new Uint8Array(total); + let pos = 0; + for (const c of chunks) { + merged.set(c, pos); + pos += c.length; + } + entries[name] = decoder.decode(merged); + } catch { + // DecompressionStream may not support 'raw' — skip this entry + } + } + + offset = dataStart + compressedSize; + } + + const sharedStringsXml = + entries['xl/sharedStrings.xml'] ?? entries['xl/sharedstrings.xml'] ?? ''; + const sheetXml = entries['xl/worksheets/sheet1.xml']; + if (!sheetXml) return null; + + const sharedStrings = sharedStringsXml ? parseSharedStrings(sharedStringsXml) : []; + return parseSheetXml(sheetXml, sharedStrings); + } catch { + return null; + } +} + +// ─── XML helpers ────────────────────────────────────────────────────────────── + +/** Extract text nodes from / shared-string entries. */ +function parseSharedStrings(xml: string): string[] { + const strings: string[] = []; + const siRegex = /([\s\S]*?)<\/si>/g; + const tRegex = /]*>([\s\S]*?)<\/t>/g; + let siMatch: RegExpExecArray | null; + + while ((siMatch = siRegex.exec(xml)) !== null) { + let text = ''; + let tMatch: RegExpExecArray | null; + tRegex.lastIndex = 0; + while ((tMatch = tRegex.exec(siMatch[1])) !== null) { + text += tMatch[1]; + } + strings.push(decodeXmlEntities(text)); + } + + return strings; +} + +/** Parse an OOXML worksheet into RawRows. */ +function parseSheetXml(xml: string, sharedStrings: string[]): RawRow[] { + const rows: RawRow[] = []; + let headers: string[] = []; + + const rowRegex = /]*>([\s\S]*?)<\/row>/g; + const cellRegex = /]*)>([\s\S]*?)<\/c>/g; + const vRegex = /([\s\S]*?)<\/v>/; + + let rowMatch: RegExpExecArray | null; + let rowIndex = 0; + + while ((rowMatch = rowRegex.exec(xml)) !== null) { + const rowContent = rowMatch[1]; + const cells: { col: number; value: string }[] = []; + + let cellMatch: RegExpExecArray | null; + while ((cellMatch = cellRegex.exec(rowContent)) !== null) { + const attrs = cellMatch[1]; + const inner = cellMatch[2]; + + const rAttr = /\br="([A-Z]+\d+)"/.exec(attrs); + const tAttr = /\bt="([^"]*)"/.exec(attrs); + if (!rAttr) continue; + + const colLetters = rAttr[1].replace(/\d+/, ''); + const colIndex = colLettersToIndex(colLetters); + + const vMatch = vRegex.exec(inner); + let value = ''; + if (vMatch) { + if (tAttr && tAttr[1] === 's') { + // Shared string + const idx = parseInt(vMatch[1], 10); + value = sharedStrings[idx] ?? ''; + } else { + value = decodeXmlEntities(vMatch[1]); + } + } + + cells.push({ col: colIndex, value }); + } + + if (cells.length === 0) { + rowIndex++; + continue; + } + + // Build a dense array up to the max column + const maxCol = Math.max(...cells.map((c) => c.col)); + const dense = Array.from({ length: maxCol + 1 }, () => ''); + cells.forEach(({ col, value }) => { + dense[col] = value; + }); + + if (rowIndex === 0) { + headers = dense; + } else { + const row: RawRow = {}; + headers.forEach((h, i) => { + row[h] = dense[i] ?? ''; + }); + rows.push(row); + } + + rowIndex++; + } + + return rows; +} + +/** Convert Excel column letters (A, B, … Z, AA, …) to a 0-based index. */ +function colLettersToIndex(letters: string): number { + let n = 0; + for (const ch of letters.toUpperCase()) { + n = n * 26 + (ch.charCodeAt(0) - 64); + } + return n - 1; +} + +function decodeXmlEntities(s: string): string { + return s + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'"); +} + +// Re-export the ZipEntry type for tests +export type { ZipEntry }; diff --git a/src/lib/import/rollback.ts b/src/lib/import/rollback.ts new file mode 100644 index 00000000..d6770e28 --- /dev/null +++ b/src/lib/import/rollback.ts @@ -0,0 +1,84 @@ +/** + * Rollback manager for bulk import operations. + * + * Callers register compensating actions for each successfully persisted record. + * On failure, `rollback()` executes them in reverse order (LIFO), ensuring + * partial writes are cleaned up correctly. + * + * Usage: + * + * const rb = createRollbackManager(); + * + * for (const record of validRecords) { + * const id = await persistRecord(record.data); + * rb.register(() => deleteRecord(id), `row ${record.rowIndex}`); + * } + * + * if (shouldRollback) { + * const result = await rb.rollback(); + * console.log(result.errors); // any rollback failures + * } + */ + +export interface RollbackAction { + /** Human-readable label for error reporting. */ + label: string; + /** The compensating action to execute. */ + fn: () => Promise | void; +} + +export interface RollbackResult { + /** Number of actions that ran successfully. */ + rolledBack: number; + /** Errors that occurred during rollback (non-fatal). */ + errors: Array<{ label: string; error: string }>; +} + +export interface RollbackManager { + /** Register a compensating action. */ + register(fn: () => Promise | void, label?: string): void; + /** Execute all registered actions in reverse order. */ + rollback(): Promise; + /** Discard all registered actions (call after a fully successful import). */ + clear(): void; + /** How many actions are currently registered. */ + readonly size: number; +} + +export function createRollbackManager(): RollbackManager { + const actions: RollbackAction[] = []; + + return { + register(fn, label = `action-${actions.length + 1}`) { + actions.push({ fn, label }); + }, + + async rollback(): Promise { + let rolledBack = 0; + const errors: RollbackResult['errors'] = []; + + // Execute in reverse insertion order + for (let i = actions.length - 1; i >= 0; i--) { + const { fn, label } = actions[i]; + try { + await fn(); + rolledBack++; + } catch (e: unknown) { + errors.push({ label, error: e instanceof Error ? e.message : String(e) }); + } + } + + // Clear after rollback attempt regardless of partial failures + actions.length = 0; + return { rolledBack, errors }; + }, + + clear() { + actions.length = 0; + }, + + get size() { + return actions.length; + }, + }; +} diff --git a/src/lib/import/types.ts b/src/lib/import/types.ts new file mode 100644 index 00000000..c2243c86 --- /dev/null +++ b/src/lib/import/types.ts @@ -0,0 +1,54 @@ +/** + * Shared types for the bulk-import pipeline. + */ + +/** A raw row from CSV/Excel parsing (column name → cell value). */ +export type RawRow = Record; + +/** One validated (or failed) record after the validation stage. */ +export interface ImportRecord { + /** Original 1-based row number in the source file. */ + rowIndex: number; + /** Raw values before transformation. */ + raw: RawRow; + /** Transformed data — present only when the row passed validation. */ + data?: T; + /** Field-level validation errors, keyed by field name. */ + errors: Record; + /** Whether this row passed all validation checks. */ + valid: boolean; +} + +/** Result returned by the full import pipeline. */ +export interface ImportResult { + total: number; + succeeded: number; + failed: number; + records: ImportRecord[]; +} + +/** A single column-mapping entry: source CSV header → target model field. */ +export interface ColumnMapping { + sourceHeader: string; + targetField: string; +} + +/** Progress callback shape. */ +export type ProgressCallback = (processed: number, total: number) => void; + +/** A field-level validator: returns an error message string or null when valid. */ +export type FieldValidator = (value: V, row: RawRow) => string | null; + +/** Schema used to validate and transform a single import row. */ +export type ImportSchema = { + [K in keyof T]: { + /** Column name(s) to read from — supports aliases. */ + sourceCols: string[]; + /** Whether the field is required. */ + required?: boolean; + /** Optional transform applied before validation. */ + transform?: (raw: string) => T[K]; + /** Optional extra validators beyond required/presence checks. */ + validators?: FieldValidator[]; + }; +}; diff --git a/src/lib/import/validator.ts b/src/lib/import/validator.ts new file mode 100644 index 00000000..546773ea --- /dev/null +++ b/src/lib/import/validator.ts @@ -0,0 +1,148 @@ +/** + * Validation pipeline. + * + * Runs each row through the ImportSchema and returns ImportRecord results + * with per-field error messages. Reports progress via an optional callback. + */ + +import type { + RawRow, + ImportRecord, + ImportResult, + ImportSchema, + ColumnMapping, + ProgressCallback, +} from './types'; + +/** + * Apply column mappings to rename source headers to target field names. + * Returns a new row object using the mapped keys. + */ +export function applyMappings(row: RawRow, mappings: ColumnMapping[]): RawRow { + const mapped: RawRow = { ...row }; + for (const { sourceHeader, targetField } of mappings) { + if (sourceHeader in mapped && sourceHeader !== targetField) { + mapped[targetField] = mapped[sourceHeader]; + delete mapped[sourceHeader]; + } + } + return mapped; +} + +/** + * Validate and transform a single raw row against the schema. + * Returns an ImportRecord with `valid=true` and `data` when the row passes, + * or `valid=false` with populated `errors` when it fails. + */ +export function validateRow( + raw: RawRow, + rowIndex: number, + schema: ImportSchema, +): ImportRecord { + const errors: Record = {}; + const result: Partial = {}; + + for (const fieldKey of Object.keys(schema) as Array) { + const def = schema[fieldKey]; + const { sourceCols, required = false, transform, validators = [] } = def; + + // Resolve the raw value from the first matching source column + let rawValue = ''; + for (const col of sourceCols) { + if (col in raw && raw[col] !== '') { + rawValue = raw[col]; + break; + } + } + + // Required check + if (required && rawValue === '') { + errors[fieldKey as string] = `${String(fieldKey)} is required`; + continue; + } + + // Transform + let transformed: T[keyof T]; + try { + transformed = transform ? transform(rawValue) : (rawValue as unknown as T[keyof T]); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + errors[fieldKey as string] = `Transform failed: ${msg}`; + continue; + } + + // Custom validators + let fieldError: string | null = null; + for (const validator of validators) { + const msg = validator(transformed, raw); + if (msg !== null) { + fieldError = msg; + break; + } + } + + if (fieldError !== null) { + errors[fieldKey as string] = fieldError; + } else { + result[fieldKey] = transformed; + } + } + + const valid = Object.keys(errors).length === 0; + return { + rowIndex, + raw, + data: valid ? (result as T) : undefined, + errors, + valid, + }; +} + +/** + * Run the full validation pipeline over all rows. + * + * Processes rows in batches to keep the UI responsive (yields control via + * `setTimeout(0)` between batches). Calls `onProgress` after each batch. + * + * @param rows Raw rows from the parser + * @param schema Field-level validation schema + * @param mappings Optional column renames to apply before validation + * @param onProgress Optional callback called after each batch + * @param batchSize Rows per batch (default 100) + * @param signal AbortSignal to cancel mid-run + */ +export async function runValidationPipeline( + rows: RawRow[], + schema: ImportSchema, + mappings: ColumnMapping[] = [], + onProgress?: ProgressCallback, + batchSize = 100, + signal?: AbortSignal, +): Promise> { + const records: ImportRecord[] = []; + const total = rows.length; + + for (let i = 0; i < total; i += batchSize) { + if (signal?.aborted) break; + + const batch = rows.slice(i, i + batchSize); + for (let j = 0; j < batch.length; j++) { + const mappedRow = applyMappings(batch[j], mappings); + records.push(validateRow(mappedRow, i + j + 1, schema)); + } + + onProgress?.(Math.min(i + batchSize, total), total); + + // Yield to keep the main thread responsive between batches + await new Promise((resolve) => setTimeout(resolve, 0)); + } + + const succeeded = records.filter((r) => r.valid).length; + + return { + total: records.length, + succeeded, + failed: records.length - succeeded, + records, + }; +} From e6bda28344d0624dea7c38eff391d34331cab251 Mon Sep 17 00:00:00 2001 From: 0xElyte Date: Tue, 28 Apr 2026 09:13:48 +0000 Subject: [PATCH 2/3] feat: Multi-language Support --- package.json | 2 ++ src/app/layout.tsx | 16 ++++++++++++++-- src/components/layout/Header.tsx | 26 +++++++++++++++++++------- src/hooks/useInternationalization.tsx | 23 ++++++++++++++++++----- src/providers/RootProviders.tsx | 12 +++++++++--- 5 files changed, 62 insertions(+), 17 deletions(-) diff --git a/package.json b/package.json index ee1ef820..f942d1ea 100644 --- a/package.json +++ b/package.json @@ -67,6 +67,8 @@ "web-vitals": "^4.2.4", "workbox-webpack-plugin": "^7.0.0", "zod": "^3.25.75", + "i18next": "^24.0.0", + "react-i18next": "^15.0.0", "zustand": "^5.0.10" }, "devDependencies": { diff --git a/src/app/layout.tsx b/src/app/layout.tsx index fbb3b17a..1b9a9701 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -4,6 +4,10 @@ import { Geist, Geist_Mono } from 'next/font/google'; import Script from 'next/script'; import './globals.css'; import { RootProviders } from '@/providers/RootProviders'; +import { getHtmlDir } from '@/lib/i18n/config'; + +// Languages supported at startup — extend as new locale files are added. +const VALID_LOCALES = new Set(['en', 'es', 'ar', 'fr', 'de', 'he', 'ja', 'zh', 'pt', 'ru', 'it', 'ko']); const geistSans = Geist({ variable: '--font-geist-sans', @@ -30,6 +34,12 @@ export default async function RootLayout({ const themeCookie = cookieStore.get('theme'); const defaultTheme = themeCookie ? themeCookie.value : 'system'; + // Read persisted locale to server-render the correct lang/dir on — + // avoids a hydration flash for RTL users. + const rawLocale = cookieStore.get('i18n:language')?.value ?? 'en'; + const locale = VALID_LOCALES.has(rawLocale) ? rawLocale : 'en'; + const dir = getHtmlDir(locale); + const themeScript = ` (function() { try { @@ -49,14 +59,16 @@ export default async function RootLayout({ `; return ( - +