diff --git a/README.md b/README.md index 7075f9a9..1523f220 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,10 @@ File menu → **Import ZPL**: paste ZPL code directly, or open a `.zpl` file. File menu → **Add page** creates a new page. With multiple pages, the control at the bottom-center of the canvas switches between them and removes them. All pages share the same dimensions; export and import handle each page as a separate label. +### Batch printing from CSV + +File menu → **Import CSV data** loads a CSV. The mapping dialog pairs each Variable with a column, saved with the design. **Export batch ZPL** or **Send to Zebra Printer** then outputs one label per row. + ### Keyboard shortcuts | Shortcut | Action | @@ -99,6 +103,7 @@ Both `.zpl` and `.json` round-trip cleanly. `.zpl` preserves all printable conte - Smart alignment and spacing guides - Layers panel with reordering - Variables: bind text and barcode fields to named defaults that emit as `^FN`/`^FV` slots, round-tripping with printer-side templates +- CSV batch printing: import a CSV, map columns to Variables, print or export one label per row - 32 UI languages (auto-detected from browser) - Light / dark mode (follows OS setting) diff --git a/package.json b/package.json index 1cb1f1a6..6953354f 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "bwip-js": "^4.10.1", "fflate": "^0.8.3", "konva": "^10.3.0", + "papaparse": "^5.5.3", "react": "^19.2.6", "react-dom": "^19.2.6", "react-konva": "^19.2.4", @@ -34,6 +35,7 @@ "@tailwindcss/vite": "^4.3.0", "@types/babel__core": "^7.20.5", "@types/node": "^24.12.4", + "@types/papaparse": "^5.5.2", "@types/pixelmatch": "^5.2.6", "@types/pngjs": "^6.0.5", "@types/react": "^19.2.15", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 412df701..096c6c51 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,6 +26,9 @@ importers: konva: specifier: ^10.3.0 version: 10.3.0 + papaparse: + specifier: ^5.5.3 + version: 5.5.3 react: specifier: ^19.2.6 version: 19.2.6 @@ -66,6 +69,9 @@ importers: '@types/node': specifier: ^24.12.4 version: 24.12.4 + '@types/papaparse': + specifier: ^5.5.2 + version: 5.5.2 '@types/pixelmatch': specifier: ^5.2.6 version: 5.2.6 @@ -632,6 +638,9 @@ packages: '@types/node@24.12.4': resolution: {integrity: sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA==} + '@types/papaparse@5.5.2': + resolution: {integrity: sha512-gFnFp/JMzLHCwRf7tQHrNnfhN4eYBVYYI897CGX4MY1tzY9l2aLkVyx2IlKZ/SAqDbB3I1AOZW5gTMGGsqWliA==} + '@types/pixelmatch@5.2.6': resolution: {integrity: sha512-wC83uexE5KGuUODn6zkm9gMzTwdY5L0chiK+VrKcDfEjzxh1uadlWTvOmAbCpnM9zx/Ww3f8uKlYQVnO/TrqVg==} @@ -1153,6 +1162,9 @@ packages: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} + papaparse@5.5.3: + resolution: {integrity: sha512-5QvjGxYVjxO59MGU2lHVYpRWBBtKHnlIAcSe1uNFCkkptUh63NFRj0FJQm7nR67puEruUci/ZkjmEFrjCAyP4A==} + path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -1895,6 +1907,10 @@ snapshots: dependencies: undici-types: 7.16.0 + '@types/papaparse@5.5.2': + dependencies: + '@types/node': 24.12.4 + '@types/pixelmatch@5.2.6': dependencies: '@types/node': 24.12.4 @@ -2403,6 +2419,8 @@ snapshots: dependencies: p-limit: 3.1.0 + papaparse@5.5.3: {} + path-exists@4.0.0: {} path-key@3.1.1: {} diff --git a/src/components/AppShell.tsx b/src/components/AppShell.tsx index c5eea6be..7956a77b 100644 --- a/src/components/AppShell.tsx +++ b/src/components/AppShell.tsx @@ -6,6 +6,8 @@ import type { LabelCanvasHandle } from "./Canvas/LabelCanvas"; import { RightSidebar } from "./RightSidebar/RightSidebar"; import { ZPLOutput } from "./Output/ZPLOutput"; import { ZplImportModal } from "./Output/ZplImportModal"; +import { VariableMappingModal } from "./Variables/VariableMappingModal"; +import { CsvImportConfirmDialog } from "./Variables/CsvImportConfirmDialog"; import { PrintToZebraDialog } from "./Output/PrintToZebraDialog"; import { DropdownMenu, @@ -22,6 +24,7 @@ import { DocumentDuplicateIcon, FolderOpenIcon, DocumentArrowDownIcon, + TableCellsIcon, PrinterIcon, PaperAirplaneIcon, GlobeAltIcon, @@ -38,6 +41,7 @@ import { useT } from "../lib/useT"; import { kbd } from "../lib/kbd"; import { useGlobalShortcuts } from "../hooks/useGlobalShortcuts"; import { useDesignFileActions } from "../hooks/useDesignFileActions"; +import { useCsvImportActions } from "../hooks/useCsvImportActions"; import { useZplImportExport } from "../hooks/useZplImportExport"; import { useOutputPanel, OUTPUT_DEFAULT_H } from "../hooks/useOutputPanel"; @@ -75,6 +79,17 @@ export function AppShell() { useGlobalShortcuts(); const { handleNew, handleSave, handleLoad, loadInputRef, loadError, dismissLoadError } = useDesignFileActions(); + const { + csvInputRef, + handleCsvImport, + csvError, + dismissCsvError, + pendingImport, + confirmPendingImport, + cancelPendingImport, + } = useCsvImportActions(); + const csvMappingModalOpen = useLabelStore((s) => s.csvMappingModalOpen); + const closeCsvMappingModal = useLabelStore((s) => s.closeCsvMappingModal); const { showZplImport, openZplImport, @@ -86,6 +101,9 @@ export function AppShell() { printError, dismissPrintError, handleDownload, + handleExportBatch, + canBatchExport, + batchRowCount, handlePrint, } = useZplImportExport(); const outputPanel = useOutputPanel(OUTPUT_DEFAULT_H); @@ -194,6 +212,15 @@ export function AppShell() { > {t.app.exportZpl} + {canBatchExport && ( + + {t.app.exportBatchZplFmt.replace('{n}', String(batchRowCount))} + + )} {t.app.saveDesign} + csvInputRef.current?.click()} + > + {t.app.importCsvData} + {/* Print routes through Labelary. The button is shown whenever the Labelary gate is on; clicking it before the notice has @@ -237,13 +270,20 @@ export function AppShell() { className="hidden" onChange={handleLoad} /> + {/* Notices */} - {(loadError ?? printError) && ( + {(loadError ?? printError ?? csvError) && (
- {loadError ?? printError} + {loadError ?? printError ?? csvError} {printError && (
{showZplImport && } + {csvMappingModalOpen && ( + + )} + {pendingImport && ( + + )} {showZebraPrint && ( )} diff --git a/src/components/Canvas/KonvaObject.tsx b/src/components/Canvas/KonvaObject.tsx index c0651cd8..a3b14f92 100644 --- a/src/components/Canvas/KonvaObject.tsx +++ b/src/components/Canvas/KonvaObject.tsx @@ -1,5 +1,7 @@ +import { useMemo } from "react"; import { useFontCacheVersion } from "../../hooks/useFontCacheVersion"; import { Ellipse, Group, Rect, Text } from "react-konva"; +import { lookupBoundVariable, shouldShowFallbackTint } from "../../lib/variableBinding"; import { BarcodeObject } from "./BarcodeObject"; import { LineObject } from "./LineObject"; import { ImageObject } from "./ImageObject"; @@ -9,7 +11,7 @@ import { outlineInset } from "../../lib/shapeGeometry"; import { reverseShapeStyle } from "./reverseShapeStyle"; import { useColorScheme } from "../../lib/useColorScheme"; import { useLabelStore } from "../../store/labelStore"; -import { applyBindingToObject } from "../../lib/variableBinding"; +import { applyBindingToObject, buildActiveCsvRow } from "../../lib/variableBinding"; import { ZPL_FONT_HEIGHT_TO_CSS_RATIO } from "./textPositionTransforms"; import { getTextRenderMetrics } from "./textRenderMetrics"; import { selectionHandlers, type KonvaObjectProps } from "./konvaObjectProps"; @@ -106,19 +108,72 @@ const BARCODE_TYPES = new Set([ ]); export function KonvaObject(props_: Props) { - // Substitute the bound variable's defaultValue into `props.content` + // Substitute the bound variable's resolved value into `props.content` // before any per-type renderer touches the obj. Keeps Konva blissfully - // unaware of the binding mechanism: every shape draws what the printer - // would print absent a runtime ^FV override. `applyBindingToObject` + // unaware of the binding mechanism: every shape draws what would + // print for the currently active CSV row (or the variable's + // defaultValue when no CSV is in play). `applyBindingToObject` // is identity-preserving when the obj isn't bound, so memoisation // downstream isn't affected for the common case. const variables = useLabelStore((s) => s.variables); - const obj = applyBindingToObject(props_.obj, variables); + const csvDataset = useLabelStore((s) => s.csvDataset); + const csvMapping = useLabelStore((s) => s.csvMapping); + const csvRenderMode = useLabelStore((s) => s.canvasSettings.csvRenderMode); + // useMemo so applyBindingToObject's identity-preservation downstream + // isn't defeated by a fresh ActiveCsvRow object on every render. + const active = useMemo( + () => buildActiveCsvRow(csvDataset, csvMapping), + [csvDataset, csvMapping], + ); + const obj = applyBindingToObject(props_.obj, variables, active, csvRenderMode); const renderProps = obj === props_.obj ? props_ : { ...props_, obj }; - if (obj.type === "line") return ; - if (obj.type === "image") return ; - if (BARCODE_TYPES.has(obj.type)) return ; - return ; + + // Bounds-tint when bound field is rendering fallback (no CSV cell + // for this variable). The rule lives in lib so it's testable + // without Konva; see shouldShowFallbackTint for the full predicate. + const boundVariable = lookupBoundVariable(obj, variables); + const showFallbackTint = shouldShowFallbackTint( + boundVariable, csvDataset, csvMapping, csvRenderMode, + ); + + const shape = + obj.type === "line" ? : + obj.type === "image" ? : + BARCODE_TYPES.has(obj.type) ? : + ; + + if (!showFallbackTint) return shape; + + // Read declared bbox from props. Shapes with explicit width/height + // (text, box, ellipse, image, qrcode, datamatrix) produce a visible + // tint; barcodes whose width is derived from content length (Code39, + // Code128, EAN-13, …) silently skip — their bars are visually + // distinctive enough that fallback ambiguity is less acute, and the + // source-state badge in the Variables panel covers them. + const props = (obj as { props?: { width?: unknown; height?: unknown } }).props; + const wDots = typeof props?.width === "number" ? props.width : 0; + const hDots = typeof props?.height === "number" ? props.height : 0; + if (wDots <= 0 || hDots <= 0) return shape; + const { scale, dpmm, offsetX, offsetY } = props_; + const tintX = dotsToPx(obj.x, scale, dpmm) - offsetX; + const tintY = dotsToPx(obj.y, scale, dpmm) - offsetY; + const tintW = dotsToPx(wDots, scale, dpmm); + const tintH = dotsToPx(hDots, scale, dpmm); + + return ( + + + {shape} + + ); } /** diff --git a/src/components/Variables/CsvImportConfirmDialog.tsx b/src/components/Variables/CsvImportConfirmDialog.tsx new file mode 100644 index 00000000..e1f5df6e --- /dev/null +++ b/src/components/Variables/CsvImportConfirmDialog.tsx @@ -0,0 +1,99 @@ +import { DialogShell } from '../ui/DialogShell'; +import { useT } from '../../lib/useT'; +import type { PendingImport } from '../../hooks/useCsvImportActions'; + +interface Props { + pending: PendingImport; + onConfirm: (opts: { keepMapping: boolean }) => void; + onCancel: () => void; +} + +/** Three-state confirmation surfaced from `useCsvImportActions` when + * the user picks a new CSV while one is already loaded. + * - `same` (compatible) → single Replace (mapping carries over). + * - `different` (incompatible) → Discard mapping / Keep & remap. + * Cancel always closes without touching state. Custom inline buttons + * instead of the shared ConfirmDialog because that component only + * supports a single confirm action. */ +export function CsvImportConfirmDialog({ pending, onConfirm, onCancel }: Props) { + const tv = useT().variables; + const newFilename = pending.parsed.file.name; + const oldFilename = pending.replacingFilename ?? ''; + const headline = tv.csvReplaceCsvBodyFmt + .replace('{old}', oldFilename) + .replace('{new}', newFilename); + const detail = + pending.kind === 'same' + ? pending.wasHeaderless + ? tv.csvReplaceCsvSameColumnsFmt.replace( + '{n}', + String(pending.previousColumnCount), + ) + : tv.csvReplaceCsvSameHeaders + : pending.wasHeaderless + ? tv.csvReplaceCsvDifferentColumnsFmt.replace( + '{n}', + String(pending.previousColumnCount), + ) + : tv.csvReplaceCsvDifferentHeaders; + + return ( + +
+

+ {tv.csvReplaceCsvTitle} +

+

+ {headline} +

+

{detail}

+
+
+ + {pending.kind === 'same' ? ( + + ) : ( + <> + + + + )} +
+
+ ); +} diff --git a/src/components/Variables/VariableMappingModal.tsx b/src/components/Variables/VariableMappingModal.tsx new file mode 100644 index 00000000..b8a5fcbe --- /dev/null +++ b/src/components/Variables/VariableMappingModal.tsx @@ -0,0 +1,653 @@ +import { useEffect, useMemo, useState, type ChangeEvent } from 'react'; +import { PlusIcon, XMarkIcon } from '@heroicons/react/16/solid'; +import { useLabelStore } from '../../store/labelStore'; +import { useT } from '../../lib/useT'; +import { + nextDefaultVariableName, + nextFreeFnNumber, + suggestCsvMapping, + type CsvMapping, + type CsvParseOptionsPersisted, + type Variable, +} from '../../types/Variable'; +import { + decodeImportedText, + getImportedText, + parseCsvText, +} from '../../lib/csvImport'; +import { DialogShell } from '../ui/DialogShell'; +import { CollapsibleSection } from '../ui/CollapsibleSection'; +import { inputCls } from '../Properties/styles'; +import { getVariableSource } from '../../lib/variableBinding'; +import { VariableSourceBadge } from './VariableSourceBadge'; + + +interface Props { + onClose: () => void; +} + +interface DraftOptions { + /** Stored as PapaParse delimiter string. '' means auto-detect. */ + delimiter: string; + hasHeaderRow: boolean; + skipRows: number; + /** TextDecoder label. 'utf-8' is the default; common alternatives + * cover German Excel exports (windows-1252) and legacy Latin + * files. The dropdown is curated; arbitrary TextDecoder labels + * would also work but aren't surfaced. */ + encoding: string; +} + +/** Modal for editing the Variable → CSV-column mapping and the + * associated CSV parse options. Full draft pattern: variable list, + * bindings, active row and parse options are cloned on open; Apply + * commits the whole bundle atomically; Cancel discards everything. + * Live re-parse of the cached raw text drives the table whenever + * options change, so the user sees the effect immediately. */ +export function VariableMappingModal({ onClose }: Props) { + const t = useT(); + const tv = t.variables; + const variables = useLabelStore((s) => s.variables); + const csvMapping = useLabelStore((s) => s.csvMapping); + const csvDataset = useLabelStore((s) => s.csvDataset); + const applyMappingDraft = useLabelStore((s) => s.applyMappingDraft); + + // Draft state, initialised once at modal-open. The init-from-prop + // pattern is the React-blessed way to seed local state from props + // without re-running on every render. + const [draftVariables, setDraftVariables] = useState(() => [ + ...variables, + ]); + // Snapshot of variable IDs that were already in the store at + // modal-open. Used to flag "will be created" on rows that exist + // only in the draft (added inline via the + Add variable button) + // so the user understands they haven't committed yet. + const [initialVariableIds] = useState>( + () => new Set(variables.map((v) => v.id)), + ); + const [draftOptions, setDraftOptions] = useState(() => ({ + // Seed from the persisted mapping first (so a reopen reflects the + // last Apply), then fall back to the dataset's source metadata + // (the values active at import time), then to library defaults. + delimiter: + csvMapping?.parseOptions?.delimiter ?? + csvDataset?.source.delimiter ?? + '', + hasHeaderRow: csvMapping?.parseOptions?.hasHeaderRow ?? true, + skipRows: csvMapping?.parseOptions?.skipRows ?? 0, + encoding: + csvMapping?.parseOptions?.encoding ?? + csvDataset?.source.encoding ?? + 'utf-8', + })); + + // Re-decode the cached bytes whenever encoding changes. For UTF-8 + // (the default) skip the roundtrip and use the already-decoded text + // from import time. + const rawText = useMemo(() => { + if (draftOptions.encoding === 'utf-8') return getImportedText(); + return decodeImportedText(draftOptions.encoding); + }, [draftOptions.encoding]); + const [draftRow, setDraftRow] = useState( + csvDataset?.activeRowIndex ?? 0, + ); + const [addError, setAddError] = useState(null); + + // Live re-parse from the (possibly re-decoded) raw text whenever + // options change. Synchronous + memoised so option-tweaks feel + // instant. + const draftParse = useMemo(() => { + if (!rawText) return null; + return parseCsvText(rawText, { + delimiter: draftOptions.delimiter || undefined, + hasHeaderRow: draftOptions.hasHeaderRow, + skipRows: draftOptions.skipRows, + encoding: draftOptions.encoding, + filename: csvDataset?.source.filename, + }); + }, [rawText, draftOptions, csvDataset?.source.filename]); + + // Memoise so the useEffect deps below stay reference-stable across + // renders that didn't change the underlying parse. + const virtualHeaders = useMemo( + () => (draftParse?.ok ? draftParse.value.headers : csvDataset?.headers ?? []), + [draftParse, csvDataset?.headers], + ); + const virtualRows = useMemo( + () => (draftParse?.ok ? draftParse.value.rows : csvDataset?.rows ?? []), + [draftParse, csvDataset?.rows], + ); + + // Bindings draft. Seeded from existing mapping (only entries whose + // header still exists in the virtual parse), then auto-suggest fills + // the rest. Re-derived when virtualHeaders change so newly-vanished + // headers drop out and newly-appeared ones can be auto-suggested. + const [draftBindings, setDraftBindings] = useState>( + () => buildInitialBindings(csvMapping, draftVariables, virtualHeaders), + ); + useEffect(() => { + setDraftBindings((prev) => { + const headerSet = new Set(virtualHeaders); + const filtered: Record = {}; + let changed = false; + for (const [varId, header] of Object.entries(prev)) { + if (headerSet.has(header)) filtered[varId] = header; + else changed = true; + } + // Auto-suggest only for variables that existed at modal-open + // and don't yet have a binding. Inline-added drafts stay + // unbound so the user isn't surprised by a header silently + // attaching to a freshly added row whose default name + // happens to fuzzy-match a column. + const unboundVars = draftVariables.filter( + (v) => initialVariableIds.has(v.id) && !(v.id in filtered), + ); + const usedHeaders = new Set(Object.values(filtered)); + const freeHeaders = virtualHeaders.filter((h) => !usedHeaders.has(h)); + const suggested = suggestCsvMapping(unboundVars, freeHeaders); + const merged = { ...filtered, ...suggested }; + if (!changed && Object.keys(suggested).length === 0) return prev; + return merged; + }); + }, [virtualHeaders, draftVariables, initialVariableIds]); + + // Clamp active-row to virtual rows length (option-change may have + // shrunk the dataset). + useEffect(() => { + if (virtualRows.length === 0) return; + setDraftRow((r) => Math.min(r, virtualRows.length - 1)); + }, [virtualRows.length]); + + // Headers that are bound by more than one variable. Almost always a + // mistake (the same column would feed two slots and produce confusing + // labels); flagged inline so the user notices before Apply. + const duplicateHeaders = useMemo(() => { + const counts = new Map(); + for (const h of Object.values(draftBindings)) { + counts.set(h, (counts.get(h) ?? 0) + 1); + } + const dups = new Set(); + for (const [h, n] of counts) if (n > 1) dups.add(h); + return dups; + }, [draftBindings]); + + // Compute name validity per row. Empty-name is always invalid; + // duplicate-name is invalid for every row sharing the same trimmed + // value. Duplicates are computed against trimmed text so trailing + // whitespace doesn't accidentally "fix" the collision. Computed + // before the defensive early-return so the hook order is stable. + const nameErrors = useMemo(() => { + const counts = new Map(); + for (const v of draftVariables) { + const t = v.name.trim(); + counts.set(t, (counts.get(t) ?? 0) + 1); + } + const errors: Record = {}; + for (const v of draftVariables) { + const t = v.name.trim(); + if (t === '') errors[v.id] = tv.csvNameEmpty; + else if ((counts.get(t) ?? 0) > 1) errors[v.id] = tv.csvNameDuplicate; + } + return errors; + }, [draftVariables, tv.csvNameEmpty, tv.csvNameDuplicate]); + const hasNameError = Object.keys(nameErrors).length > 0; + + if (!rawText || !csvDataset) { + // Defensive: trigger paths gate on csvDataset, but if the cache is + // empty (e.g. user reloaded the page mid-session) show a friendly + // close-only shell. + return ( + +
+

{tv.csvNoCsvLoaded}

+ +
+
+ ); + } + + const handleChangeBinding = + (variableId: string) => (e: ChangeEvent) => { + const value = e.target.value; + setDraftBindings((prev) => { + if (value === '') { + if (!(variableId in prev)) return prev; + const { [variableId]: _drop, ...next } = prev; + void _drop; + return next; + } + return { ...prev, [variableId]: value }; + }); + }; + + const handleRemoveDraftVariable = (id: string) => { + setDraftVariables((prev) => prev.filter((v) => v.id !== id)); + setDraftBindings((prev) => { + if (!(id in prev)) return prev; + const { [id]: _drop, ...rest } = prev; + void _drop; + return rest; + }); + }; + + const handleAddVariable = () => { + // Eligibility check first against the current snapshot so the + // error message doesn't depend on closure mutation from inside + // setDraftVariables (StrictMode runs updaters twice, concurrent + // rendering may defer them). The updater itself re-checks against + // prev so chained adds compute slot/name from the up-to-date + // list and don't collide. Residual edge case: two clicks in one + // batch both pass the outer check, but only the first commits a + // new row; no error surfaces for the swallowed second. + if (nextFreeFnNumber(draftVariables.map((v) => v.fnNumber)) === null) { + setAddError(tv.noSlotsLeft); + return; + } + setDraftVariables((prev) => { + const fn = nextFreeFnNumber(prev.map((v) => v.fnNumber)); + if (fn === null) return prev; + const newVar: Variable = { + id: crypto.randomUUID(), + name: nextDefaultVariableName(prev), + fnNumber: fn, + defaultValue: '', + }; + return [...prev, newVar]; + }); + setAddError(null); + }; + + const handleConfirm = () => { + if (!draftParse?.ok) return; + const parse = draftParse.value; + applyMappingDraft({ + variables: draftVariables, + dataset: parse, + mapping: { + bindings: draftBindings, + headerSnapshot: parse.headers, + parseOptions: persistableParseOptions(draftOptions), + }, + activeRowIndex: draftRow, + }); + onClose(); + }; + + const showMismatchWarning = + csvMapping !== null && + !arraysShallowEqual(csvMapping.headerSnapshot, virtualHeaders); + + const allSlotsTaken = + nextFreeFnNumber(draftVariables.map((v) => v.fnNumber)) === null; + + const parseError = draftParse && !draftParse.ok; + + return ( + +
+ + {tv.csvMappingTitle} + + +
+ +
+

+ {tv.csvMappingHint} +

+ + {showMismatchWarning && ( +

+ {tv.csvHeaderMismatchWarning} +

+ )} + + {parseError && ( +

+ {tv.csvParseError} +

+ )} + +
+
+ + + + + + + + + + {draftVariables.length === 0 ? ( + + + + ) : ( + draftVariables.map((v) => { + const nameError = nameErrors[v.id]; + const isNew = !initialVariableIds.has(v.id); + const boundHeader = draftBindings[v.id]; + const isDuplicate = + boundHeader !== undefined && duplicateHeaders.has(boundHeader); + // Classify against the draft (not the committed store + // state) so the badge reflects live binding edits before + // Apply. Both inputs synthesised here have the minimal + // shape getVariableSource needs. + const draftSource = getVariableSource( + v, + { headers: virtualHeaders }, + { bindings: draftBindings, headerSnapshot: virtualHeaders as string[] }, + ); + return ( + + + + + + ); + }) + )} + +
{tv.csvVariableHeader}{tv.csvColumnHeader}{tv.csvSampleHeader}
+ {tv.csvNoVariables} +
+
+ + { + const newName = e.target.value; + setDraftVariables((prev) => + prev.map((x) => (x.id === v.id ? { ...x, name: newName } : x)), + ); + }} + /> + {isNew && ( + + )} +
+ {nameError ? ( +

+ {nameError} +

+ ) : isNew ? ( +

+ {tv.csvWillBeCreated} +

+ ) : null} +
+ + {isDuplicate && ( +

+ {tv.csvDuplicateColumn} +

+ )} +
+ {(() => { + // Sample value for the active preview row. When + // bound + header present in current parse → + // cell from virtualRows[draftRow]. Otherwise + // show the variable's default (or empty marker) + // so the user always knows what would print. + if (boundHeader !== undefined) { + const colIdx = virtualHeaders.indexOf(boundHeader); + const cell = + colIdx >= 0 + ? virtualRows[draftRow]?.[colIdx] ?? '' + : ''; + return cell === '' ? ( + + {tv.csvSampleEmpty} + + ) : ( + + {cell} + + ); + } + return ( + + {v.defaultValue || tv.csvSamplePlaceholder} + + ); + })()} +
+
+
+ + {addError && ( +

{addError}

+ )} +
+
+ + {virtualRows.length > 0 && ( +
+ + { + const n = parseInt(e.target.value, 10); + if (!Number.isNaN(n)) { + setDraftRow(Math.max(0, Math.min(n - 1, virtualRows.length - 1))); + } + }} + /> + + {tv.csvActiveRowOf} {virtualRows.length} + +
+ )} + + + + +
+ +
+ + +
+
+ ); +} + +interface CsvOptionsEditorProps { + value: DraftOptions; + onChange: (next: DraftOptions) => void; +} + +function CsvOptionsEditor({ value, onChange }: CsvOptionsEditorProps) { + const tv = useT().variables; + return ( +
+
+ + +
+ + + +
+ + { + const n = parseInt(e.target.value, 10); + onChange({ ...value, skipRows: Math.max(0, Number.isNaN(n) ? 0 : n) }); + }} + /> +
+ +
+ + +
+
+ ); +} + +/** Build the initial draft-bindings: keep existing mapping entries + * whose header is still present in the current parse, then auto- + * suggest for variables that have no binding yet. */ +function buildInitialBindings( + csvMapping: CsvMapping | null, + variables: readonly Variable[], + headers: readonly string[], +): Record { + const headerSet = new Set(headers); + const carried: Record = {}; + if (csvMapping) { + for (const [varId, header] of Object.entries(csvMapping.bindings)) { + if (headerSet.has(header)) carried[varId] = header; + } + } + const unmapped = variables.filter((v) => !(v.id in carried)); + const usedHeaders = new Set(Object.values(carried)); + const free = headers.filter((h) => !usedHeaders.has(h)); + const suggested = suggestCsvMapping(unmapped, free); + return { ...carried, ...suggested }; +} + +/** Strip default values from the draft so a saved mapping only carries + * the options the user actually customised. Keeps the design file + * minimal and lets future default-changes pick up automatically. */ +function persistableParseOptions(d: DraftOptions): CsvParseOptionsPersisted | undefined { + const opts: CsvParseOptionsPersisted = {}; + if (d.delimiter !== '') opts.delimiter = d.delimiter; + if (d.hasHeaderRow === false) opts.hasHeaderRow = false; + if (d.skipRows > 0) opts.skipRows = d.skipRows; + if (d.encoding !== 'utf-8') opts.encoding = d.encoding; + return Object.keys(opts).length === 0 ? undefined : opts; +} + +function arraysShallowEqual( + a: readonly string[], + b: readonly string[], +): boolean { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false; + return true; +} diff --git a/src/components/Variables/VariableSourceBadge.tsx b/src/components/Variables/VariableSourceBadge.tsx new file mode 100644 index 00000000..91b4196b --- /dev/null +++ b/src/components/Variables/VariableSourceBadge.tsx @@ -0,0 +1,79 @@ +import { + ExclamationTriangleIcon, + MinusIcon, + TableCellsIcon, +} from '@heroicons/react/16/solid'; +import { useT } from '../../lib/useT'; +import type { VariableSource } from '../../lib/variableBinding'; + +interface Props { + source: VariableSource; + /** Header name a `csv` or `orphan` source is bound to. Shown as + * inline text for csv/orphan so the user knows the column without + * hovering. Ignored for `default`. */ + boundHeader?: string; + /** Visual size. `xs` for tight contexts (mapping modal rows); + * `sm` for the Variables-panel rows. */ + size?: 'xs' | 'sm'; + /** When false (default in modal rows), render icon only. When true, + * include the column name text — used in the Variables panel where + * the badge competes with bindings-count text and needs to win. */ + showLabel?: boolean; +} + + +/** Universal source indicator for a Variable. Same vocabulary in + * Variables panel + mapping modal rows. With `showLabel`, includes + * the bound column name inline so it stays informative even when + * sitting next to other status text. */ +export function VariableSourceBadge({ + source, + boundHeader, + size = 'sm', + showLabel = false, +}: Props) { + const tv = useT().variables; + const TIP: Record = { + csv: tv.csvSourceCsvTip, + orphan: tv.csvSourceOrphanTip, + default: tv.csvSourceDefaultTip, + }; + const colorCls = + source === 'csv' + ? 'text-accent' + : source === 'orphan' + ? 'text-amber-400' + : 'text-muted/60'; + const iconSize = size === 'xs' ? 'w-3 h-3' : 'w-3.5 h-3.5'; + const Icon = + source === 'csv' + ? TableCellsIcon + : source === 'orphan' + ? ExclamationTriangleIcon + : MinusIcon; + const tip = TIP[source].replace('{header}', boundHeader ?? ''); + // Label uses bare 'orphan' / 'csv' when no header is known instead of + // 'orphan: ' (trailing colon-space) — the latter happens on a + // freshly-cleared select where boundHeader transiently goes undefined. + const label = + source === 'csv' + ? boundHeader ?? 'csv' + : source === 'orphan' + ? boundHeader ? `orphan: ${boundHeader}` : 'orphan' + : 'default'; + + return ( + + + {showLabel && ( + + {label} + + )} + + ); +} diff --git a/src/components/Variables/VariablesPanel.tsx b/src/components/Variables/VariablesPanel.tsx index 96dabc1e..e8c8491a 100644 --- a/src/components/Variables/VariablesPanel.tsx +++ b/src/components/Variables/VariablesPanel.tsx @@ -1,10 +1,19 @@ import { useState, type ChangeEvent } from 'react'; -import { PlusIcon, TrashIcon } from '@heroicons/react/16/solid'; +import { + ChevronLeftIcon, + ChevronRightIcon, + Cog6ToothIcon, + PlusIcon, + TableCellsIcon, + TrashIcon, + XMarkIcon, +} from '@heroicons/react/16/solid'; import { useLabelStore } from '../../store/labelStore'; import { FN_NUMBER_MIN, FN_NUMBER_MAX, nextFreeFnNumber, + nextDefaultVariableName, type Variable, } from '../../types/Variable'; import { walkObjects, type LabelObject } from '../../types/Group'; @@ -13,6 +22,8 @@ import { ConfirmDialog } from '../ui/ConfirmDialog'; import { FieldLabel } from '../ui/FieldLabel'; import { useT } from '../../lib/useT'; import type { Translations } from '../../locales'; +import { getVariableSource, type VariableSource } from '../../lib/variableBinding'; +import { VariableSourceBadge } from './VariableSourceBadge'; export function VariablesPanel() { const t = useT(); @@ -22,6 +33,24 @@ export function VariablesPanel() { const addVariable = useLabelStore((s) => s.addVariable); const updateVariable = useLabelStore((s) => s.updateVariable); const removeVariable = useLabelStore((s) => s.removeVariable); + const csvDataset = useLabelStore((s) => s.csvDataset); + const csvMapping = useLabelStore((s) => s.csvMapping); + const clearCsv = useLabelStore((s) => s.clearCsv); + const setActiveRow = useLabelStore((s) => s.setActiveRow); + const setCsvMapping = useLabelStore((s) => s.setCsvMapping); + const openCsvMappingModal = useLabelStore((s) => s.openCsvMappingModal); + const csvRenderMode = useLabelStore((s) => s.canvasSettings.csvRenderMode); + const setCanvasSettings = useLabelStore((s) => s.setCanvasSettings); + const [pendingCsvDiscard, setPendingCsvDiscard] = useState(false); + + // Mapping completeness for the badge's secondary line. Counts only + // bindings whose header still exists in the active dataset, since + // stale entries (header dropped) don't actually map anything. + const mappedCount = (() => { + if (!csvMapping || !csvDataset) return 0; + const headerSet = new Set(csvDataset.headers); + return Object.values(csvMapping.bindings).filter((h) => headerSet.has(h)).length; + })(); const [pendingDelete, setPendingDelete] = useState(null); @@ -39,7 +68,7 @@ export function VariablesPanel() { nextFreeFnNumber(variables.map((v) => v.fnNumber)) === null; const handleAdd = () => { - const base = nextDefaultName(variables); + const base = nextDefaultVariableName(variables); const id = addVariable({ name: base }); if (id === null) { // Name collisions on a fresh `var_n` are essentially impossible @@ -78,9 +107,142 @@ export function VariablesPanel() { return (
-

- {tv.panelHint} -

+
+

+ {tv.panelHint} +

+ {variables.length > 0 && ( + + )} +
+ + {!csvDataset && csvMapping && Object.keys(csvMapping.bindings).length > 0 && ( + /* Mapping persisted (design.json or localStorage) but no CSV + data in this session — reload, Discard CSV, or opening a + saved design. Surface it so the saved bindings don't look + lost. User re-imports via the File menu's "Import CSV data" + to bring values back; the X here drops the mapping entirely. */ +
+
+ + + {tv.csvSavedMappingTitle} + + +
+

+ {tv.csvSavedMappingDescFmt + .replace('{mapped}', String(Object.keys(csvMapping.bindings).length)) + .replace('{total}', String(variables.length))} +

+
+ )} + + {csvDataset && ( +
+ {/* i18n: Phase-2 strings here get locale keys at end-of-branch sweep. */} +
+ + + {csvDataset.source.filename} + + + +
+

+ {tv.csvBadgeRowsMappedFmt + .replace('{rowCount}', String(csvDataset.source.rowCount)) + .replace('{mapped}', String(mappedCount)) + .replace('{total}', String(variables.length))} +

+ {csvDataset.rows.length > 0 && ( +
+ + {tv.csvBadgeRowLabel} + { + const n = parseInt(e.target.value, 10); + if (!Number.isNaN(n)) setActiveRow(n - 1); + }} + disabled={csvRenderMode === 'schema'} + className="w-10 bg-surface-2 border border-border rounded px-1 py-0 text-[10px] font-mono text-text focus:border-accent focus:outline-none text-center disabled:opacity-30 disabled:cursor-not-allowed" + /> + / {csvDataset.rows.length} + +
+ )} +
+ )} {variables.length === 0 ? (
@@ -91,25 +253,39 @@ export function VariablesPanel() {
) : (
    - {variables.map((entry) => ( - - tryUpdate(entry.id, { name }, tv.nameInUse) - } - onChangeFnNumber={(fnNumber) => - tryUpdate(entry.id, { fnNumber }, tv.slotInUse) - } - onChangeDefault={(defaultValue) => - tryUpdate(entry.id, { defaultValue }, '') - } - onRequestDelete={() => setPendingDelete(entry)} - /> - ))} + {variables.map((entry) => { + const source = getVariableSource(entry, csvDataset, csvMapping); + const boundHeader = csvMapping?.bindings[entry.id]; + return ( + + tryUpdate(entry.id, { name }, tv.nameInUse) + } + onChangeFnNumber={(fnNumber) => + tryUpdate(entry.id, { fnNumber }, tv.slotInUse) + } + onChangeDefault={(defaultValue) => + tryUpdate(entry.id, { defaultValue }, '') + } + onRequestDelete={() => setPendingDelete(entry)} + onDirtyChange={() => + setRowError((prev) => { + if (!(entry.id in prev)) return prev; + const { [entry.id]: _drop, ...rest } = prev; + void _drop; + return rest; + }) + } + /> + ); + })}
)} @@ -143,6 +319,23 @@ export function VariablesPanel() { onCancel={() => setPendingDelete(null)} /> )} + + {pendingCsvDiscard && csvDataset && ( + { + clearCsv(); + setPendingCsvDiscard(false); + }} + onCancel={() => setPendingCsvDiscard(false)} + /> + )}
); } @@ -150,23 +343,32 @@ export function VariablesPanel() { interface RowProps { variable: Variable; bindings: number; + source: VariableSource; + boundHeader: string | undefined; error?: string; tv: Translations['variables']; onChangeName: (next: string) => void; onChangeFnNumber: (next: number) => void; onChangeDefault: (next: string) => void; onRequestDelete: () => void; + /** Called when any input value diverges from the committed value so + * the panel can clear a stale rowError. Without this a duplicate- + * name rejection would linger after the user kept typing. */ + onDirtyChange: () => void; } function VariableRow({ variable, bindings, + source, + boundHeader, error, tv, onChangeName, onChangeFnNumber, onChangeDefault, onRequestDelete, + onDirtyChange, }: RowProps) { // Mirror inputs locally so the user can transiently type invalid values // (empty name, mid-edit number) without the store snapping them back on @@ -210,7 +412,10 @@ function VariableRow({ ) => setName(e.target.value)} + onChange={(e: ChangeEvent) => { + setName(e.target.value); + onDirtyChange(); + }} onBlur={commitName} /> @@ -222,7 +427,10 @@ function VariableRow({ max={FN_NUMBER_MAX} className={inputCls} value={fn} - onChange={(e: ChangeEvent) => setFn(e.target.value)} + onChange={(e: ChangeEvent) => { + setFn(e.target.value); + onDirtyChange(); + }} onBlur={commitFn} /> @@ -243,7 +451,7 @@ function VariableRow({ onBlur={commitDef} /> -
+
{bindings === 0 ? tv.noBindings @@ -251,7 +459,16 @@ function VariableRow({ ? tv.bindingsSingular : tv.bindingsPluralFmt.replace('{n}', String(bindings))} - {error && {error}} + {error ? ( + {error} + ) : ( + + )}
); @@ -288,12 +505,3 @@ function countBindings( return counts; } -/** `var_{n}` where n is the lowest integer that yields a unique name. - * Keeps the user from having to type a name to add an entry while still - * giving each one a distinct default. */ -function nextDefaultName(existing: readonly Variable[]): string { - const taken = new Set(existing.map((v) => v.name)); - let i = 1; - while (taken.has(`var_${i}`)) i++; - return `var_${i}`; -} diff --git a/src/hooks/useCsvImportActions.ts b/src/hooks/useCsvImportActions.ts new file mode 100644 index 00000000..e851eed9 --- /dev/null +++ b/src/hooks/useCsvImportActions.ts @@ -0,0 +1,148 @@ +import { useRef, useState, type ChangeEvent } from "react"; +import { useLabelStore } from "../store/labelStore"; +import { + parseCsvText, + rememberImport, + csvParseErrors, + type CsvParseResult, +} from "../lib/csvImport"; +import { isMappingCompatibleWith, type CsvMapping } from "../types/Variable"; + +/** Captures everything decided during parse so the caller can either + * apply directly or stash on the pending-import slot until the user + * confirms. Bytes/text live here because a "Cancel" must not pollute + * the module-scope cache that the modal re-decodes from. */ +interface ParsedImport { + file: File; + bytes: Uint8Array; + text: string; + result: CsvParseResult; +} + +/** Compatibility decision for the confirm dialog: `same` shows a + * single Replace button (mapping carries over); `different` shows + * Discard mapping / Keep & remap. */ +export type PendingImportKind = "same" | "different"; + +export interface PendingImport { + kind: PendingImportKind; + parsed: ParsedImport; + /** Filename of the dataset being replaced. */ + replacingFilename: string; + /** True when the previous mapping treated CSV as headerless. Drives + * the dialog copy: column-count match vs. column-name match. */ + wasHeaderless: boolean; + /** Header / column count from the saved mapping. Used in the + * dialog body so the user sees what they're comparing against. */ + previousColumnCount: number; +} + +/** File-picker hook for "Import CSV data" in the File menu. Owns the + * hidden ref, the parse-error state, and the pending-import + * slot that gates a destructive replace behind a ConfirmDialog. */ +export function useCsvImportActions() { + const csvInputRef = useRef(null); + const [csvError, setCsvError] = useState(null); + const [pendingImport, setPendingImport] = useState(null); + + const handleCsvImport = async (e: ChangeEvent) => { + const file = e.target.files?.[0]; + e.target.value = ""; + if (!file) return; + setCsvError(null); + + let bytes: Uint8Array; + try { + bytes = new Uint8Array(await file.arrayBuffer()); + } catch { + setCsvError(csvParseErrors.read_failed); + return; + } + + // Re-read store state AFTER the await so a discard/replace that + // happened during file I/O doesn't drive the decision off a stale + // snapshot. + const { csvMapping, csvDataset } = useLabelStore.getState(); + // Re-use the parse options the mapping was last applied with so a + // headerless / windows-1252 / semicolon-delimited dataset doesn't + // get re-parsed under defaults and falsely flagged as "different". + const persistedOpts = csvMapping?.parseOptions; + const encoding = persistedOpts?.encoding ?? "utf-8"; + let text: string; + try { + text = new TextDecoder(encoding).decode(bytes); + } catch { + setCsvError(csvParseErrors.read_failed); + return; + } + const result = parseCsvText(text, { + filename: file.name, + delimiter: persistedOpts?.delimiter, + hasHeaderRow: persistedOpts?.hasHeaderRow, + skipRows: persistedOpts?.skipRows, + encoding, + }); + if (!result.ok) { + setCsvError(csvParseErrors[result.error]); + return; + } + + const parsed: ParsedImport = { file, bytes, text, result: result.value }; + + // Fresh import (nothing to overwrite): commit immediately. The + // mapping-modal auto-open (driven by absent or incompatible + // mapping) inside applyImport handles UX from there. + if (!csvDataset) { + applyImport(parsed, { keepMapping: true }); + return; + } + + // Existing dataset → confirm before overwriting. Compatibility + // controls the dialog shape (single Replace vs. three-way choice). + setPendingImport({ + kind: + csvMapping && isMappingCompatibleWith(csvMapping, result.value.headers) + ? "same" + : "different", + parsed, + replacingFilename: csvDataset.source.filename, + wasHeaderless: csvMapping?.parseOptions?.hasHeaderRow === false, + previousColumnCount: csvMapping?.headerSnapshot.length ?? 0, + }); + }; + + const confirmPendingImport = (opts: { keepMapping: boolean }) => { + if (!pendingImport) return; + applyImport(pendingImport.parsed, opts); + setPendingImport(null); + }; + + const cancelPendingImport = () => setPendingImport(null); + + return { + csvInputRef, + handleCsvImport, + csvError, + dismissCsvError: () => setCsvError(null), + pendingImport, + confirmPendingImport, + cancelPendingImport, + }; +} + +/** Commit a parsed import to the store + module cache. Optionally + * drop the existing mapping (Discard path). Surfaces the mapping + * modal whenever the resulting state needs user review: no mapping + * (fresh / discarded) or a kept-but-incompatible mapping. */ +function applyImport(p: ParsedImport, opts: { keepMapping: boolean }): void { + const { loadCsv, setCsvMapping, openCsvMappingModal, csvMapping } = + useLabelStore.getState(); + rememberImport(p.file, p.bytes, p.text); + const effectiveMapping: CsvMapping | null = opts.keepMapping ? csvMapping : null; + if (!opts.keepMapping) setCsvMapping(null); + loadCsv(p.result); + const needsReview = + !effectiveMapping || + !isMappingCompatibleWith(effectiveMapping, p.result.headers); + if (needsReview) openCsvMappingModal(); +} diff --git a/src/hooks/useDesignFileActions.ts b/src/hooks/useDesignFileActions.ts index 5b5532ca..be97d4b7 100644 --- a/src/hooks/useDesignFileActions.ts +++ b/src/hooks/useDesignFileActions.ts @@ -8,6 +8,7 @@ export function useDesignFileActions() { const label = useLabelStore((s) => s.label); const pages = useLabelStore((s) => s.pages); const variables = useLabelStore((s) => s.variables); + const csvMapping = useLabelStore((s) => s.csvMapping); const loadDesign = useLabelStore((s) => s.loadDesign); const [loadError, setLoadError] = useState(null); const loadInputRef = useRef(null); @@ -17,7 +18,7 @@ export function useDesignFileActions() { }; const handleSave = () => { - const data = serializeDesign(label, pages, variables); + const data = serializeDesign(label, pages, variables, csvMapping); triggerDownload(new Blob([data], { type: "application/json" }), "label.json"); }; @@ -41,7 +42,12 @@ export function useDesignFileActions() { } setLoadError(null); - loadDesign(result.value.label, result.value.pages, result.value.variables); + loadDesign( + result.value.label, + result.value.pages, + result.value.variables, + result.value.csvMapping, + ); }; return { diff --git a/src/hooks/useZplImportExport.ts b/src/hooks/useZplImportExport.ts index 4efb54a2..3efb0fe5 100644 --- a/src/hooks/useZplImportExport.ts +++ b/src/hooks/useZplImportExport.ts @@ -1,34 +1,63 @@ import { useState } from "react"; import { useLabelStore, useCurrentObjects } from "../store/labelStore"; -import { generateMultiPageZPL } from "../lib/zplGenerator"; +import { generateMultiPageZPL, generateBatchZpl } from "../lib/zplGenerator"; import { printLabel } from "../lib/printPreview"; import { triggerDownload } from "../lib/triggerDownload"; import { labelaryErrorMessage } from "../lib/labelary"; +import { buildActiveCsvRow } from "../lib/variableBinding"; export function useZplImportExport() { const label = useLabelStore((s) => s.label); const pages = useLabelStore((s) => s.pages); const variables = useLabelStore((s) => s.variables); + const csvDataset = useLabelStore((s) => s.csvDataset); + const csvMapping = useLabelStore((s) => s.csvMapping); const objects = useCurrentObjects(); const [showZplImport, setShowZplImport] = useState(false); const [showZebraPrint, setShowZebraPrint] = useState(false); const [printError, setPrintError] = useState(null); + // Batch export is only meaningful with both a dataset and at least one + // mapped variable. Without either, the output would be identical to a + // single label and surfacing the action just clutters the menu. + const canBatchExport = + csvDataset !== null && + csvDataset.rows.length > 0 && + csvMapping !== null && + Object.keys(csvMapping.bindings).length > 0; + const handleDownload = () => { const zpl = generateMultiPageZPL(label, pages, variables); triggerDownload(new Blob([zpl], { type: "text/plain" }), "label.zpl"); }; + const handleExportBatch = () => { + if (!canBatchExport) return; + const zpl = generateBatchZpl(label, objects, variables, csvDataset, csvMapping); + triggerDownload(new Blob([zpl], { type: "text/plain" }), "label-batch.zpl"); + }; + // Print previews via Labelary, which renders one image at a time. We send - // only the current page so the preview matches what the user sees. + // only the current page so the preview matches what the user sees. The + // active CSV row (if any) is substituted into bound fields so the + // preview reflects what would actually print for the selected row. const handlePrint = async () => { try { - await printLabel(label, objects, variables); + const active = buildActiveCsvRow(csvDataset, csvMapping); + await printLabel(label, objects, variables, active); } catch (e) { setPrintError(labelaryErrorMessage(e)); } }; + // ZPL surfaced to direct-print: batch form when a CSV is in play (so + // sending to the printer produces N labels), otherwise the same + // template the editor displays. + const currentZpl = () => + canBatchExport + ? generateBatchZpl(label, objects, variables, csvDataset, csvMapping) + : generateMultiPageZPL(label, pages, variables); + return { showZplImport, openZplImport: () => setShowZplImport(true), @@ -36,10 +65,13 @@ export function useZplImportExport() { showZebraPrint, openZebraPrint: () => setShowZebraPrint(true), closeZebraPrint: () => setShowZebraPrint(false), - currentZpl: () => generateMultiPageZPL(label, pages, variables), + currentZpl, printError, dismissPrintError: () => setPrintError(null), handleDownload, + handleExportBatch, + canBatchExport, + batchRowCount: csvDataset?.rows.length ?? 0, handlePrint, }; } diff --git a/src/lib/csvImport.test.ts b/src/lib/csvImport.test.ts new file mode 100644 index 00000000..d2297073 --- /dev/null +++ b/src/lib/csvImport.test.ts @@ -0,0 +1,167 @@ +import { describe, it, expect } from "vitest"; +import { + parseCsvFile, + parseCsvText, + rememberImport, + forgetImport, + decodeImportedText, + getImportedBytes, +} from "./csvImport"; + +function fileOf(text: string, name = "test.csv"): File { + return new File([text], name, { type: "text/csv" }); +} + +describe("parseCsvFile", () => { + it("parses headers + rows from a simple comma-delimited CSV", async () => { + const file = fileOf("sku,qty\nA1,10\nB2,5\n"); + const result = await parseCsvFile(file); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.headers).toEqual(["sku", "qty"]); + expect(result.value.rows).toEqual([ + ["A1", "10"], + ["B2", "5"], + ]); + expect(result.value.source.filename).toBe("test.csv"); + expect(result.value.source.rowCount).toBe(2); + }); + + it("pads ragged rows to header length", async () => { + const file = fileOf("a,b,c\n1,2\n4,5,6\n"); + const result = await parseCsvFile(file); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.rows).toEqual([ + ["1", "2", ""], + ["4", "5", "6"], + ]); + }); + + it("truncates rows that are longer than headers", async () => { + const file = fileOf("a,b\n1,2,3\n"); + const result = await parseCsvFile(file); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.rows).toEqual([["1", "2"]]); + }); + + it("auto-detects semicolon delimiter (Excel-locale CSVs)", async () => { + const file = fileOf("sku;qty\nA1;10\n"); + const result = await parseCsvFile(file); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.headers).toEqual(["sku", "qty"]); + expect(result.value.source.delimiter).toBe(";"); + }); + + it("returns 'empty' for a zero-byte file", async () => { + const file = fileOf(""); + const result = await parseCsvFile(file); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error).toBe("empty"); + }); + + it("preserves quoted values containing the delimiter", async () => { + const file = fileOf('name,note\n"Smith, J.","hi, there"\n'); + const result = await parseCsvFile(file); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.rows[0]).toEqual(["Smith, J.", "hi, there"]); + }); + + it("returns header-only CSV with zero rows (not an error)", async () => { + const file = fileOf("sku,qty\n"); + const result = await parseCsvFile(file); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.headers).toEqual(["sku", "qty"]); + expect(result.value.rows).toEqual([]); + expect(result.value.source.rowCount).toBe(0); + }); +}); + +describe('parseCsvText options', () => { + it('skipRows discards leading rows before treating the next as header', () => { + const text = 'Preamble line\nMore preamble\nsku,qty\nA1,10\n'; + const result = parseCsvText(text, { skipRows: 2 }); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.headers).toEqual(['sku', 'qty']); + expect(result.value.rows).toEqual([['A1', '10']]); + }); + + it('hasHeaderRow=false synthesises Column N names', () => { + const text = 'A1,10\nB2,5\n'; + const result = parseCsvText(text, { hasHeaderRow: false }); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.headers).toEqual(['Column 1', 'Column 2']); + expect(result.value.rows).toEqual([['A1', '10'], ['B2', '5']]); + }); + + it('skipRows + headerless combines correctly', () => { + const text = 'Preamble\nA1,10\nB2,5\n'; + const result = parseCsvText(text, { skipRows: 1, hasHeaderRow: false }); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.headers).toEqual(['Column 1', 'Column 2']); + expect(result.value.rows).toEqual([['A1', '10'], ['B2', '5']]); + }); + + it('skipRows greater than row count returns empty', () => { + const text = 'a,b\n1,2\n'; + const result = parseCsvText(text, { skipRows: 10 }); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error).toBe('empty'); + }); + + it('headerless with ragged rows uses max width for synthetic headers', () => { + const text = 'A1\nB2,5,extra\n'; + const result = parseCsvText(text, { hasHeaderRow: false }); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.headers).toEqual(['Column 1', 'Column 2', 'Column 3']); + expect(result.value.rows).toEqual([ + ['A1', '', ''], + ['B2', '5', 'extra'], + ]); + }); +}); + +describe('encoding cache', () => { + it('decodeImportedText returns null when no import is cached', () => { + forgetImport(); + expect(decodeImportedText('utf-8')).toBeNull(); + }); + + it('rememberImport + decodeImportedText round-trips UTF-8', () => { + const file = new File([''], 't.csv', { type: 'text/csv' }); + const bytes = new TextEncoder().encode('sku,qty\n'); + rememberImport(file, bytes, new TextDecoder('utf-8').decode(bytes)); + expect(getImportedBytes()).toBe(bytes); + expect(decodeImportedText('utf-8')).toBe('sku,qty\n'); + forgetImport(); + }); + + it('decodeImportedText with windows-1252 turns 0xE4 into ä', () => { + const file = new File([''], 't.csv', { type: 'text/csv' }); + // 0xE4 in windows-1252 (ANSI) is "ä". The same byte in UTF-8 is + // a continuation byte (invalid as a standalone), so the two + // decodings of the same bytes should differ. + const bytes = new Uint8Array([0x73, 0xE4, 0x6F]); // s ä o (CP1252) + rememberImport(file, bytes, new TextDecoder('utf-8').decode(bytes)); + expect(decodeImportedText('windows-1252')).toBe('säo'); + forgetImport(); + }); + + it('forgetImport clears the cache', () => { + const file = new File([''], 't.csv', { type: 'text/csv' }); + rememberImport(file, new Uint8Array([1, 2, 3]), 'abc'); + forgetImport(); + expect(getImportedBytes()).toBeNull(); + expect(decodeImportedText('utf-8')).toBeNull(); + }); +}); diff --git a/src/lib/csvImport.ts b/src/lib/csvImport.ts new file mode 100644 index 00000000..2b83e6b9 --- /dev/null +++ b/src/lib/csvImport.ts @@ -0,0 +1,208 @@ +// csvImport.ts is the single source of truth for CSV ingestion. Everything +// that touches CSV data goes through the helpers here — no direct +// `papaparse` import elsewhere. Keeps the strategy-pattern migration to a +// Tauri-streaming backend a single-file refactor (see tauri plan). +import Papa from "papaparse"; +import { ok, err, type Result } from "./result"; + +export interface CsvParseResult { + /** Header names from the first row, in source order. */ + headers: string[]; + /** Data rows. Each row is an array of strings aligned with `headers`; + * ragged rows are padded with empty strings so consumers can index + * by column without bounds-checks. */ + rows: string[][]; + source: { + filename: string; + /** ISO 8601 UTC timestamp captured at import time. */ + importedAt: string; + /** Resolved encoding label (e.g. 'utf-8', 'windows-1252'). */ + encoding: string; + /** Field delimiter auto-detected by PapaParse, or the explicit one + * the caller passed. Typical: ',', ';', '\t'. */ + delimiter: string; + rowCount: number; + }; +} + +export type CsvParseError = + | "read_failed" + | "parse_failed" + | "empty" + | "no_headers"; + +export interface CsvParseOptions { + /** Field delimiter override. Empty string (default) lets PapaParse + * auto-detect. */ + delimiter?: string; + /** Pass-through label that ends up in `source.encoding`. Decoding + * itself happens before parseCsvText is called (the modal does + * it via decodeImportedText); this is purely metadata so the + * caller can record which encoding produced the text. */ + encoding?: string; + /** When false, no row is consumed as header; columns get synthetic + * names (`Column 1`, `Column 2`, …) so downstream mapping still + * has stable identifiers. Defaults to true. */ + hasHeaderRow?: boolean; + /** Number of leading rows to discard before the header / first data + * row. Lets Excel-exported CSVs with preamble lines parse cleanly. + * Defaults to 0. */ + skipRows?: number; +} + +/** + * Parse a CSV file via PapaParse. Returns a Result containing headers, + * rows, and import metadata. Handles BOM detection, encoding override, + * and delimiter auto-detection. + * + * Design notes: + * - Header row is required. Files that look truly empty or have only + * a header with no data rows still parse successfully (caller can + * decide what to do with `rows.length === 0`). + * - Ragged rows are padded to `headers.length` so downstream code can + * index by column without per-row bounds checks. + * - Whitespace inside cells is preserved verbatim; only trailing CR/LF + * from line endings is stripped (PapaParse default). + */ +export async function parseCsvFile( + file: File, + options: CsvParseOptions = {}, +): Promise> { + // Read the file as a string first instead of handing the File to + // PapaParse's streaming path. Two reasons: (1) modern Blob.text() + // is universally available and uses the browser's native UTF-8 + // decoder; (2) jsdom's FileReader stub doesn't implement + // readAsText, which would break unit tests of this helper. + let text: string; + try { + text = await file.text(); + } catch { + return err("read_failed"); + } + return parseCsvText(text, { ...options, filename: file.name }); +} + +/** + * Parse a CSV string directly. Same output shape as `parseCsvFile`. + * Exposed so callers that already have the text in hand (the mapping + * modal re-parses on every options change against the cached raw text) + * don't pay the file.text() roundtrip again. + */ +export function parseCsvText( + text: string, + options: CsvParseOptions & { filename?: string } = {}, +): Result { + const skipRows = Math.max(0, options.skipRows ?? 0); + const hasHeaderRow = options.hasHeaderRow !== false; + const result = Papa.parse(text, { + header: false, + skipEmptyLines: true, + delimiter: options.delimiter ?? "", + }); + const dataAll = result.data; + if (dataAll.length === 0) return err("empty"); + const data = dataAll.slice(skipRows); + if (data.length === 0) return err("empty"); + + let headers: string[]; + let dataRows: string[][]; + if (hasHeaderRow) { + headers = data[0] ?? []; + if (headers.length === 0) return err("no_headers"); + dataRows = data.slice(1); + } else { + // Synthesise stable column names. Width = max columns across all + // rows (handles ragged data gracefully). + const width = Math.max(...data.map((r) => r.length), 0); + if (width === 0) return err("no_headers"); + headers = Array.from({ length: width }, (_, i) => `Column ${i + 1}`); + dataRows = data; + } + // Pad ragged rows so every row has exactly headers.length cells. + // Excel-exported CSVs sometimes omit trailing empty cells; without + // padding, downstream lookup-by-index would surface `undefined` + // and force every consumer to guard against it. + const rows = dataRows.map((row) => { + if (row.length === headers.length) return row; + if (row.length < headers.length) { + return [...row, ...Array(headers.length - row.length).fill("")]; + } + return row.slice(0, headers.length); + }); + return ok({ + headers, + rows, + source: { + filename: options.filename ?? "(pasted)", + importedAt: new Date().toISOString(), + encoding: options.encoding ?? "utf-8", + delimiter: options.delimiter || result.meta.delimiter || ",", + rowCount: rows.length, + }, + }); +} + +/** + * Module-scope cache for the most-recently-imported CSV's File plus + * its raw bytes (so encoding changes can re-decode without re-reading + * the file from disk). `lastImportedText` is the default UTF-8 + * decoding kept around so the common case (no encoding override) + * doesn't pay a re-decode roundtrip every render. + * + * Lives outside the store because (a) File / bytes / text are runtime- + * only values that can't survive persist/rehydrate, and (b) the + * mapping modal needs synchronous re-parse on every option-change + * keystroke. Mirrors the previewCache pattern in labelStore.ts. + */ +let lastImportedFile: File | null = null; +let lastImportedBytes: Uint8Array | null = null; +let lastImportedText: string | null = null; + +export function rememberImport(file: File, bytes: Uint8Array, text: string): void { + lastImportedFile = file; + lastImportedBytes = bytes; + lastImportedText = text; +} + +export function forgetImport(): void { + lastImportedFile = null; + lastImportedBytes = null; + lastImportedText = null; +} + +export function getImportedFile(): File | null { + return lastImportedFile; +} + +export function getImportedText(): string | null { + return lastImportedText; +} + +export function getImportedBytes(): Uint8Array | null { + return lastImportedBytes; +} + +/** + * Decode the cached raw bytes with the given encoding. Returns null + * when no CSV is loaded. Uses the platform's TextDecoder so the same + * set of encodings the browser supports (utf-8, windows-1252, + * iso-8859-1, utf-16le/be, gbk, shift_jis, …) is available. + * + * Invalid encoding labels throw at TextDecoder construction time; + * the modal validates against a curated dropdown so callers don't + * surface that path. `fatal: false` (default) means malformed + * sequences become U+FFFD replacement chars rather than throwing — + * the UI shows the result and the user adjusts encoding if it looks + * garbled. + */ +export function decodeImportedText(encoding: string): string | null { + if (!lastImportedBytes) return null; + return new TextDecoder(encoding).decode(lastImportedBytes); +} + +export const csvParseErrors: Record = { + read_failed: "Could not read the file.", + parse_failed: "Could not parse the CSV. Check delimiter and encoding.", + empty: "The file appears to be empty.", + no_headers: "First row is empty; CSV needs a header row.", +}; diff --git a/src/lib/designFile.test.ts b/src/lib/designFile.test.ts index 002718a2..e3108001 100644 --- a/src/lib/designFile.test.ts +++ b/src/lib/designFile.test.ts @@ -204,5 +204,45 @@ describe('parseDesignFile', () => { expect(result.ok).toBe(true); if (!result.ok) return; expect(result.value.variables).toEqual([]); + expect(result.value.csvMapping).toBeNull(); + }); + + it('roundtrips csvMapping when present', () => { + const mapping = { + bindings: { v1: 'SKU', v2: 'Quantity' }, + headerSnapshot: ['SKU', 'Quantity', 'Notes'], + }; + const json = serializeDesign( + { widthMm: 100, heightMm: 60, dpmm: 8 }, + [{ objects: SAMPLE_OBJECTS }], + [], + mapping, + ); + const result = parseDesignFile(json); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.csvMapping).toEqual(mapping); + }); + + it('omits csvMapping from JSON when null (back-compat)', () => { + const json = serializeDesign( + { widthMm: 100, heightMm: 60, dpmm: 8 }, + [{ objects: SAMPLE_OBJECTS }], + [], + null, + ); + const parsed = JSON.parse(json) as Record; + expect(parsed).not.toHaveProperty('csvMapping'); + }); + + it('defaults to null csvMapping when JSON lacks the field', () => { + const json = serializeDesign( + { widthMm: 100, heightMm: 60, dpmm: 8 }, + [{ objects: SAMPLE_OBJECTS }], + ); + const result = parseDesignFile(json); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.csvMapping).toBeNull(); }); }); diff --git a/src/lib/designFile.ts b/src/lib/designFile.ts index cc0fbae1..8e4c43a8 100644 --- a/src/lib/designFile.ts +++ b/src/lib/designFile.ts @@ -1,6 +1,11 @@ import { z } from "zod"; import { labelConfigSchema, labelObjectBaseSchema, type LabelConfig } from "../types/ObjectType"; -import { variableSchema, type Variable } from "../types/Variable"; +import { + variableSchema, + csvMappingSchema, + type Variable, + type CsvMapping, +} from "../types/Variable"; import type { LabelObject } from "../types/Group"; import { ok, err, type Result } from "./result"; @@ -10,6 +15,10 @@ export interface DesignFile { label: LabelConfig; pages: DesignFilePage[]; variables: Variable[]; + /** Optional: present only when the user has imported a CSV and set + * up a mapping for the current design. Round-trips with the design; + * rows themselves are session-only and not part of the save. */ + csvMapping: CsvMapping | null; } // Two distinct shapes share the base fields: @@ -41,6 +50,8 @@ const designFileSchema = z.object({ pages: z.array(pageSchema), // Optional so designs saved before the variables feature still load. variables: z.array(variableSchema).optional(), + // Optional: designs without a CSV import omit the field entirely. + csvMapping: csvMappingSchema.optional(), }); const legacyDesignFileSchema = z.object({ @@ -65,6 +76,7 @@ export function parseDesignFile(text: string): Result 0) payload.variables = variables; + if (csvMapping) payload.csvMapping = csvMapping; return JSON.stringify(payload, null, 2); } diff --git a/src/lib/printPreview.ts b/src/lib/printPreview.ts index 2a439a63..b38a8b29 100644 --- a/src/lib/printPreview.ts +++ b/src/lib/printPreview.ts @@ -3,6 +3,22 @@ import { fetchPreview } from "./labelary"; import type { LabelConfig } from "../types/ObjectType"; import type { LabelObject } from "../types/Group"; import type { Variable } from "../types/Variable"; +import { applyBindingToTree, type ActiveCsvRow } from "./variableBinding"; + +/** Generate the ZPL we hand to Labelary: row-substituted + flat + * (no ^FN), so the rendered preview matches what would print for + * the active CSV row (or the variable defaults when no row is + * loaded). Shared by `printLabel` (new window with image) and + * `enterPreviewMode` (canvas overlay) so the two stay in lockstep. */ +export function buildPreviewZpl( + label: LabelConfig, + objects: LabelObject[], + variables: readonly Variable[], + active: ActiveCsvRow | null, +): string { + const substituted = applyBindingToTree(objects, variables, active); + return generateZPL(label, substituted, []); +} export function buildLoadingHtml(): string { return `