From 32a812dac6949b65d5db6bdc8149eacfd5feedee Mon Sep 17 00:00:00 2001 From: u8array Date: Sat, 23 May 2026 00:12:48 +0200 Subject: [PATCH 1/7] feat(variables-csv): PapaParse + store slice + file-menu import (Phase 2 step 2a) First slice of CSV support. No mapping UI yet (that's 2b); imported data lands in the store as csvDataset (transient, not persisted) and shows up as a small badge in the Variables tab. - pnpm add papaparse @types/papaparse - src/lib/csvImport.ts: parseCsvFile (reads file.text() first to sidestep PapaParse's jsdom-incompatible FileReader streaming) + parseCsvText helper. Pads ragged rows, auto-detects delimiter, surfaces filename / encoding / delimiter in source metadata. - src/lib/csvImport.test.ts: 7 tests covering happy path, ragged rows, quoted values, semicolon delimiter, empty / header-only files. - store: CsvDataset interface, csvDataset state, loadCsv + setActiveRow actions. NOT in persist partialize (would bloat localStorage and leak data); design.json mapping persistence is Phase 2b. - src/hooks/useCsvImportActions.ts: file-picker hook mirroring useDesignFileActions shape. - AppShell: TableCellsIcon, 'Import CSV data' menu entry, hidden file input, csvError piped into the existing notice banner. - VariablesPanel: 'CSV: filename.csv (N rows)' badge with clear button when a dataset is loaded. Locale keys deferred to end-of-branch sweep. --- package.json | 2 + pnpm-lock.yaml | 18 ++++ src/components/AppShell.tsx | 23 ++++- src/components/Variables/VariablesPanel.tsx | 22 ++++- src/hooks/useCsvImportActions.ts | 32 ++++++ src/lib/csvImport.test.ts | 76 ++++++++++++++ src/lib/csvImport.ts | 104 ++++++++++++++++++++ src/store/labelStore.test.ts | 64 ++++++++++++ src/store/labelStore.ts | 56 +++++++++++ 9 files changed, 393 insertions(+), 4 deletions(-) create mode 100644 src/hooks/useCsvImportActions.ts create mode 100644 src/lib/csvImport.test.ts create mode 100644 src/lib/csvImport.ts 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..b64e7015 100644 --- a/src/components/AppShell.tsx +++ b/src/components/AppShell.tsx @@ -22,6 +22,7 @@ import { DocumentDuplicateIcon, FolderOpenIcon, DocumentArrowDownIcon, + TableCellsIcon, PrinterIcon, PaperAirplaneIcon, GlobeAltIcon, @@ -38,6 +39,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 +77,7 @@ export function AppShell() { useGlobalShortcuts(); const { handleNew, handleSave, handleLoad, loadInputRef, loadError, dismissLoadError } = useDesignFileActions(); + const { csvInputRef, handleCsvImport, csvError, dismissCsvError } = useCsvImportActions(); const { showZplImport, openZplImport, @@ -208,6 +211,13 @@ export function AppShell() { > {t.app.saveDesign} + csvInputRef.current?.click()} + > + {/* i18n: locale key gets added in the end-of-branch sweep. */} + Import CSV data + {/* Print routes through Labelary. The button is shown whenever the Labelary gate is on; clicking it before the notice has @@ -237,13 +247,20 @@ export function AppShell() { className="hidden" onChange={handleLoad} /> + {/* Notices */} - {(loadError ?? printError) && ( + {(loadError ?? printError ?? csvError) && (
- {loadError ?? printError} + {loadError ?? printError ?? csvError} {printError && ( +
+ )} + {variables.length === 0 ? (

{tv.empty}

diff --git a/src/hooks/useCsvImportActions.ts b/src/hooks/useCsvImportActions.ts new file mode 100644 index 00000000..4c7a8bfb --- /dev/null +++ b/src/hooks/useCsvImportActions.ts @@ -0,0 +1,32 @@ +import { useRef, useState, type ChangeEvent } from "react"; +import { useLabelStore } from "../store/labelStore"; +import { parseCsvFile, csvParseErrors } from "../lib/csvImport"; + +/** File-picker hook for "Import CSV data" in the File menu. Owns the + * hidden ref and the parse-error state. Mirrors the shape of + * useDesignFileActions for consistency. Mapping UI lives in Phase 2b. */ +export function useCsvImportActions() { + const loadCsv = useLabelStore((s) => s.loadCsv); + const csvInputRef = useRef(null); + const [csvError, setCsvError] = useState(null); + + const handleCsvImport = async (e: ChangeEvent) => { + const file = e.target.files?.[0]; + e.target.value = ""; + if (!file) return; + setCsvError(null); + const result = await parseCsvFile(file); + if (!result.ok) { + setCsvError(csvParseErrors[result.error]); + return; + } + loadCsv(result.value); + }; + + return { + csvInputRef, + handleCsvImport, + csvError, + dismissCsvError: () => setCsvError(null), + }; +} diff --git a/src/lib/csvImport.test.ts b/src/lib/csvImport.test.ts new file mode 100644 index 00000000..3bc0498d --- /dev/null +++ b/src/lib/csvImport.test.ts @@ -0,0 +1,76 @@ +import { describe, it, expect } from "vitest"; +import { parseCsvFile } 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); + }); +}); diff --git a/src/lib/csvImport.ts b/src/lib/csvImport.ts new file mode 100644 index 00000000..66f96955 --- /dev/null +++ b/src/lib/csvImport.ts @@ -0,0 +1,104 @@ +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. Phase 2b adds an encoding override too, paired with + * a TextDecoder-backed reader path that actually applies it. */ + delimiter?: string; +} + +/** + * 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"); + } + const result = Papa.parse(text, { + header: false, + skipEmptyLines: true, + delimiter: options.delimiter ?? "", + }); + const data = result.data; + if (data.length === 0) return err("empty"); + const headers = data[0] ?? []; + if (headers.length === 0) return err("no_headers"); + // 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 = data.slice(1).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: file.name, + importedAt: new Date().toISOString(), + encoding: "utf-8", + delimiter: options.delimiter || result.meta.delimiter || ",", + rowCount: rows.length, + }, + }); +} + +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/store/labelStore.test.ts b/src/store/labelStore.test.ts index d2346128..fb658920 100644 --- a/src/store/labelStore.test.ts +++ b/src/store/labelStore.test.ts @@ -40,6 +40,7 @@ function reset() { clipboard: [], pasteCount: 0, variables: [], + csvDataset: null, previewMode: { status: 'idle' }, canvasSettings: { showGrid: false, @@ -1299,3 +1300,66 @@ describe('variables', () => { expect(state().variables).toEqual([]); }); }); + +describe('csvDataset', () => { + beforeEach(reset); + + const sampleResult = { + headers: ['sku', 'qty'], + rows: [['A1', '10'], ['B2', '5'], ['C3', '7']], + source: { + filename: 'test.csv', + importedAt: '2026-05-23T00:00:00.000Z', + encoding: 'utf-8', + delimiter: ',', + rowCount: 3, + }, + }; + + it('loadCsv stores headers, rows, source and resets activeRowIndex to 0', () => { + state().loadCsv(sampleResult); + const ds = state().csvDataset; + expect(ds?.headers).toEqual(['sku', 'qty']); + expect(ds?.rows).toHaveLength(3); + expect(ds?.source.filename).toBe('test.csv'); + expect(ds?.activeRowIndex).toBe(0); + }); + + it('clearCsv drops the dataset', () => { + state().loadCsv(sampleResult); + state().clearCsv(); + expect(state().csvDataset).toBeNull(); + }); + + it('setActiveRow updates within bounds', () => { + state().loadCsv(sampleResult); + state().setActiveRow(2); + expect(state().csvDataset?.activeRowIndex).toBe(2); + }); + + it('setActiveRow clamps below 0 and above rows.length - 1', () => { + state().loadCsv(sampleResult); + state().setActiveRow(-5); + expect(state().csvDataset?.activeRowIndex).toBe(0); + state().setActiveRow(99); + expect(state().csvDataset?.activeRowIndex).toBe(2); + }); + + it('setActiveRow is a no-op when no CSV is loaded', () => { + state().setActiveRow(5); + expect(state().csvDataset).toBeNull(); + }); + + it('loadCsv with subsequent loadCsv replaces dataset and resets activeRowIndex', () => { + state().loadCsv(sampleResult); + state().setActiveRow(2); + state().loadCsv({ + ...sampleResult, + rows: [['X', '1']], + source: { ...sampleResult.source, rowCount: 1, filename: 'other.csv' }, + }); + expect(state().csvDataset?.source.filename).toBe('other.csv'); + expect(state().csvDataset?.activeRowIndex).toBe(0); + expect(state().csvDataset?.rows).toHaveLength(1); + }); +}); diff --git a/src/store/labelStore.ts b/src/store/labelStore.ts index b877c4c5..702c9f32 100644 --- a/src/store/labelStore.ts +++ b/src/store/labelStore.ts @@ -27,6 +27,19 @@ import { type Variable, type VariableInput, } from '../types/Variable'; +import type { CsvParseResult } from '../lib/csvImport'; + +/** Snapshot of an imported CSV plus the row the canvas is currently + * previewing. Distinct from the Variable→header mapping (which lives + * in the design file): this struct is the data itself, transient. */ +export interface CsvDataset { + headers: string[]; + rows: string[][]; + source: CsvParseResult['source']; + /** Index into `rows`. Clamped to [0, rows.length - 1] by setters. + * Meaningless when `rows.length === 0`, callers should guard. */ + activeRowIndex: number; +} export type { ObjectChanges }; export type { Variable, VariableInput }; @@ -156,6 +169,16 @@ interface LabelState { * Order is user-controlled and surfaces in the Variables panel. */ variables: Variable[]; + /** Session-only CSV data feeding the template variables. Holds the + * most-recently-imported file's headers + rows plus the + * active-row index the canvas previews. Intentionally NOT in + * persist-partialize: the file path can't be reopened on rehydrate, + * and persisting raw rows would bloat localStorage and leak + * customer data into the design file. User re-imports per session. + * Mapping (which variable maps to which header) lives in the + * design file separately. */ + csvDataset: CsvDataset | null; + addObject: ( type: string, position?: { x: number; y: number }, @@ -215,6 +238,17 @@ interface LabelState { * every page. The field's own content prop (kept since binding) takes * over on render/export. */ removeVariable: (id: string) => void; + + /** Replace the entire CSV dataset and reset the active row to 0. */ + loadCsv: (result: CsvParseResult) => void; + /** Drop the current CSV dataset. Phase-2b: also unbinds the design's + * csvMapping headerSnapshot since it would point at a file the user + * is no longer working with. */ + clearCsv: () => void; + /** Move the canvas preview to a different row. Out-of-range indices + * are silently clamped to [0, rows.length - 1]; no-op when no CSV + * is loaded. */ + setActiveRow: (index: number) => void; moveObjectForward: (id: string) => void; moveObjectBackward: (id: string) => void; moveObjectToFront: (id: string) => void; @@ -438,6 +472,7 @@ export const useLabelStore = create()( clipboard: [], pasteCount: 0, variables: [], + csvDataset: null, locale: detectLocale(), theme: detectInitialTheme(), thirdParty: thirdPartyDefaults(), @@ -1013,6 +1048,27 @@ export const useLabelStore = create()( }; }), + loadCsv: (result) => + set(() => ({ + csvDataset: { + headers: result.headers, + rows: result.rows, + source: result.source, + activeRowIndex: 0, + }, + })), + + clearCsv: () => set({ csvDataset: null }), + + setActiveRow: (index) => + set((state) => { + const ds = state.csvDataset; + if (!ds || ds.rows.length === 0) return {}; + const clamped = Math.max(0, Math.min(index, ds.rows.length - 1)); + if (clamped === ds.activeRowIndex) return {}; + return { csvDataset: { ...ds, activeRowIndex: clamped } }; + }), + enterPreviewMode: async () => { const state = get(); if (state.previewMode.status === 'loading' || state.previewMode.status === 'active') { From 9d4d777e733bf3951e8086844aa2e9cbaf3cf907 Mon Sep 17 00:00:00 2001 From: u8array Date: Sat, 23 May 2026 14:29:44 +0200 Subject: [PATCH 2/7] feat(variables-csv): Phase 2b - mapping modal + draft pattern + CSV options + UX iterations User opens the mapping modal from the CSV badge or via auto-open after import. The modal holds the full editing state in a draft, allowing atomic Apply / Cancel: - Per-variable column dropdowns, inline rename with empty/duplicate name validation - Inline 'Add variable' so missing slots can be filled without leaving the modal - 'will be created' marker on draft-only rows; X-discard button to drop them - CSV options block (delimiter, hasHeaderRow, skipRows, encoding), collapsed by default; live re-parse from cached raw bytes on any option change so the user sees the impact immediately - Encoding override: UTF-8 / Windows-1252 (ANSI) / ISO-8859-1 / UTF-16 LE via TextDecoder, re-decoding cached bytes per choice - Active-row stepper in the modal footer for preview navigation - Scrollable variable list with bound height CSV badge in the Variables panel: filename + row count + mapping completeness + cog (configure) + discard. Discard goes through a ConfirmDialog. Layout polished across multiple iterations to fit the narrow side panel without losing scanability. Persistence: mapping (bindings + headerSnapshot + parseOptions) round-trips with the design via design.json. Raw CSV bytes stay in a module-scope cache; the rows themselves don't persist. --- src/components/AppShell.tsx | 6 + .../Variables/VariableMappingModal.tsx | 588 ++++++++++++++++++ src/components/Variables/VariablesPanel.tsx | 88 ++- src/hooks/useCsvImportActions.ts | 45 +- src/hooks/useDesignFileActions.ts | 10 +- src/lib/csvImport.test.ts | 93 ++- src/lib/csvImport.ts | 120 +++- src/lib/designFile.test.ts | 40 ++ src/lib/designFile.ts | 33 +- src/store/labelStore.test.ts | 2 + src/store/labelStore.ts | 117 +++- src/types/Variable.test.ts | 76 +++ src/types/Variable.ts | 61 ++ 13 files changed, 1214 insertions(+), 65 deletions(-) create mode 100644 src/components/Variables/VariableMappingModal.tsx create mode 100644 src/types/Variable.test.ts diff --git a/src/components/AppShell.tsx b/src/components/AppShell.tsx index b64e7015..40128a4f 100644 --- a/src/components/AppShell.tsx +++ b/src/components/AppShell.tsx @@ -6,6 +6,7 @@ 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 { PrintToZebraDialog } from "./Output/PrintToZebraDialog"; import { DropdownMenu, @@ -78,6 +79,8 @@ export function AppShell() { useGlobalShortcuts(); const { handleNew, handleSave, handleLoad, loadInputRef, loadError, dismissLoadError } = useDesignFileActions(); const { csvInputRef, handleCsvImport, csvError, dismissCsvError } = useCsvImportActions(); + const csvMappingModalOpen = useLabelStore((s) => s.csvMappingModalOpen); + const closeCsvMappingModal = useLabelStore((s) => s.closeCsvMappingModal); const { showZplImport, openZplImport, @@ -327,6 +330,9 @@ export function AppShell() {
{showZplImport && } + {csvMappingModalOpen && ( + + )} {showZebraPrint && ( )} diff --git a/src/components/Variables/VariableMappingModal.tsx b/src/components/Variables/VariableMappingModal.tsx new file mode 100644 index 00000000..9f134aa0 --- /dev/null +++ b/src/components/Variables/VariableMappingModal.tsx @@ -0,0 +1,588 @@ +import { useEffect, useMemo, useState, type ChangeEvent } from 'react'; +import { PlusIcon, XMarkIcon } from '@heroicons/react/16/solid'; +import { useLabelStore } from '../../store/labelStore'; +import { + nextDefaultVariableName, + nextFreeFnNumber, + suggestCsvMapping, + type CsvMapping, + 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'; + +/* i18n: literal strings here get locale keys at the end-of-branch sweep. */ +const COPY = { + title: 'CSV mapping', + hint: + 'Match each Variable to a CSV column. Leave a Variable unmapped to keep using its default.', + variableHeader: 'Variable', + columnHeader: 'CSV column', + ignoreOption: '(unmapped)', + nameEmpty: 'Required', + nameDuplicate: 'Duplicate', + willBeCreated: 'will be created', + removeDraftAria: 'Discard from draft', + noVariables: + 'No variables defined yet. Add one below or in the Variables panel.', + addVariable: 'Add variable', + noSlotsLeft: 'All 99 ^FN slots are taken.', + activeRowLabel: 'Preview row', + activeRowOf: 'of', + activeRowTooltip: 'Which row the canvas previews. Switch any time.', + confirm: 'Apply', + cancel: 'Cancel', + close: 'Close', + headerMismatchWarning: + 'CSV headers have changed since the last mapping was saved. Check CSV options below if the file structure looks off.', + csvOptionsTitle: 'CSV options', + delimiterLabel: 'Delimiter', + delimiterAuto: 'Auto-detect', + delimiterComma: 'Comma (,)', + delimiterSemicolon: 'Semicolon (;)', + delimiterTab: 'Tab', + hasHeaderRow: 'First row contains headers', + skipRowsLabel: 'Skip first N rows', + encodingLabel: 'Text encoding', + encodingUtf8: 'UTF-8 (default)', + encodingWin1252: 'Windows-1252 (ANSI / Western European)', + encodingIso88591: 'ISO-8859-1 (Latin 1)', + encodingUtf16le: 'UTF-16 LE', + noCsvLoaded: 'Import a CSV from the File menu first.', + parseError: 'Could not parse with current options.', +} as const; + +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 variables = useLabelStore((s) => s.variables); + const csvMapping = useLabelStore((s) => s.csvMapping); + const csvDataset = useLabelStore((s) => s.csvDataset); + const setVariables = useLabelStore((s) => s.setVariables); + const setCsvMapping = useLabelStore((s) => s.setCsvMapping); + const setActiveRow = useLabelStore((s) => s.setActiveRow); + const loadCsv = useLabelStore((s) => s.loadCsv); + + // 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(() => ({ + delimiter: csvDataset?.source.delimiter ?? '', + hasHeaderRow: true, + skipRows: 0, + 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 for variables that have no binding yet. + const unboundVars = draftVariables.filter((v) => !(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]); + + // 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]); + + // 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] = COPY.nameEmpty; + else if ((counts.get(t) ?? 0) > 1) errors[v.id] = COPY.nameDuplicate; + } + return errors; + }, [draftVariables]); + 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 ( + +
+

{COPY.noCsvLoaded}

+ +
+
+ ); + } + + 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 = () => { + // Read latest draft inside the setter so rapid clicks (or future + // batched updaters) compute slot/name from the up-to-date list + // instead of a stale closure. Without this, two clicks in one + // React batch both pick `var_1` / fnNumber 1 and collide. + let failed = false; + setDraftVariables((prev) => { + const fn = nextFreeFnNumber(prev.map((v) => v.fnNumber)); + if (fn === null) { + failed = true; + return prev; + } + const newVar: Variable = { + id: crypto.randomUUID(), + name: nextDefaultVariableName(prev), + fnNumber: fn, + defaultValue: '', + }; + return [...prev, newVar]; + }); + setAddError(failed ? COPY.noSlotsLeft : null); + }; + + const handleConfirm = () => { + if (!draftParse?.ok) return; + const parse = draftParse.value; + setVariables(draftVariables); + loadCsv(parse); + setCsvMapping({ + bindings: draftBindings, + headerSnapshot: parse.headers, + }); + // loadCsv resets activeRowIndex to 0; re-apply the draft row + // clamped to the new rows.length. + setActiveRow(Math.min(draftRow, Math.max(0, parse.rows.length - 1))); + onClose(); + }; + + const showMismatchWarning = + csvMapping !== null && + !arraysShallowEqual(csvMapping.headerSnapshot, virtualHeaders); + + const allSlotsTaken = + nextFreeFnNumber(draftVariables.map((v) => v.fnNumber)) === null; + + const parseError = draftParse && !draftParse.ok; + + return ( + +
+ + {COPY.title} + + +
+ +
+

+ {COPY.hint} +

+ + {showMismatchWarning && ( +

+ {COPY.headerMismatchWarning} +

+ )} + + {parseError && ( +

+ {COPY.parseError} +

+ )} + +
+
+ + + + + + + + + {draftVariables.length === 0 ? ( + + + + ) : ( + draftVariables.map((v) => { + const nameError = nameErrors[v.id]; + const isNew = !initialVariableIds.has(v.id); + return ( + + + + + ); + }) + )} + +
{COPY.variableHeader}{COPY.columnHeader}
+ {COPY.noVariables} +
+
+ { + const newName = e.target.value; + setDraftVariables((prev) => + prev.map((x) => (x.id === v.id ? { ...x, name: newName } : x)), + ); + }} + /> + {isNew && ( + + )} +
+ {nameError ? ( +

+ {nameError} +

+ ) : isNew ? ( +

+ {COPY.willBeCreated} +

+ ) : null} +
+ +
+
+
+ + {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))); + } + }} + /> + + {COPY.activeRowOf} {virtualRows.length} + +
+ )} + + + + +
+ +
+ + +
+
+ ); +} + +interface CsvOptionsEditorProps { + value: DraftOptions; + onChange: (next: DraftOptions) => void; +} + +function CsvOptionsEditor({ value, onChange }: CsvOptionsEditorProps) { + 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 }; +} + +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/VariablesPanel.tsx b/src/components/Variables/VariablesPanel.tsx index 3830ccd3..fcf5a2d5 100644 --- a/src/components/Variables/VariablesPanel.tsx +++ b/src/components/Variables/VariablesPanel.tsx @@ -1,10 +1,17 @@ import { useState, type ChangeEvent } from 'react'; -import { PlusIcon, TrashIcon, XMarkIcon } from '@heroicons/react/16/solid'; +import { + 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'; @@ -23,7 +30,19 @@ export function VariablesPanel() { 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 openCsvMappingModal = useLabelStore((s) => s.openCsvMappingModal); + 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); @@ -41,7 +60,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 @@ -85,20 +104,36 @@ export function VariablesPanel() {

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

+ {csvDataset.source.rowCount} rows · {mappedCount} of {variables.length} mapped +

)} @@ -163,6 +198,20 @@ export function VariablesPanel() { onCancel={() => setPendingDelete(null)} /> )} + + {pendingCsvDiscard && csvDataset && ( + { + clearCsv(); + setPendingCsvDiscard(false); + }} + onCancel={() => setPendingCsvDiscard(false)} + /> + )}
); } @@ -308,12 +357,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 index 4c7a8bfb..0a59fd77 100644 --- a/src/hooks/useCsvImportActions.ts +++ b/src/hooks/useCsvImportActions.ts @@ -1,12 +1,19 @@ import { useRef, useState, type ChangeEvent } from "react"; import { useLabelStore } from "../store/labelStore"; -import { parseCsvFile, csvParseErrors } from "../lib/csvImport"; +import { + parseCsvText, + rememberImport, + csvParseErrors, +} from "../lib/csvImport"; /** File-picker hook for "Import CSV data" in the File menu. Owns the - * hidden ref and the parse-error state. Mirrors the shape of - * useDesignFileActions for consistency. Mapping UI lives in Phase 2b. */ + * hidden ref and the parse-error state, plus the auto-open + * trigger for the mapping modal: imports whose headers don't match + * the saved mapping (or whose design has no mapping yet) pop the + * modal automatically. Imports with a valid mapping silent-reuse. */ export function useCsvImportActions() { const loadCsv = useLabelStore((s) => s.loadCsv); + const openCsvMappingModal = useLabelStore((s) => s.openCsvMappingModal); const csvInputRef = useRef(null); const [csvError, setCsvError] = useState(null); @@ -15,12 +22,36 @@ export function useCsvImportActions() { e.target.value = ""; if (!file) return; setCsvError(null); - const result = await parseCsvFile(file); + // Read raw bytes up front so the modal can re-decode with a + // different encoding later (German Excel ANSI exports, etc.) + // without re-reading from disk. Default decode is UTF-8. + let bytes: Uint8Array; + try { + bytes = new Uint8Array(await file.arrayBuffer()); + } catch { + setCsvError(csvParseErrors.read_failed); + return; + } + const text = new TextDecoder("utf-8").decode(bytes); + const result = parseCsvText(text, { filename: file.name }); if (!result.ok) { setCsvError(csvParseErrors[result.error]); return; } + rememberImport(file, bytes, text); loadCsv(result.value); + + // Decide whether to surface the mapping modal. Snapshot the store + // state after loadCsv so we see the just-imported headers; the + // mapping itself doesn't change during loadCsv so reading it + // before vs after is identical. + const { csvMapping } = useLabelStore.getState(); + const needsMappingReview = + !csvMapping || + !headerArraysEqual(csvMapping.headerSnapshot, result.value.headers); + if (needsMappingReview) { + openCsvMappingModal(); + } }; return { @@ -30,3 +61,9 @@ export function useCsvImportActions() { dismissCsvError: () => setCsvError(null), }; } + +function headerArraysEqual(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/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/lib/csvImport.test.ts b/src/lib/csvImport.test.ts index 3bc0498d..d2297073 100644 --- a/src/lib/csvImport.test.ts +++ b/src/lib/csvImport.test.ts @@ -1,5 +1,12 @@ import { describe, it, expect } from "vitest"; -import { parseCsvFile } from "./csvImport"; +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" }); @@ -74,3 +81,87 @@ describe("parseCsvFile", () => { 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 index 66f96955..2b83e6b9 100644 --- a/src/lib/csvImport.ts +++ b/src/lib/csvImport.ts @@ -1,3 +1,7 @@ +// 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"; @@ -29,9 +33,21 @@ export type CsvParseError = export interface CsvParseOptions { /** Field delimiter override. Empty string (default) lets PapaParse - * auto-detect. Phase 2b adds an encoding override too, paired with - * a TextDecoder-backed reader path that actually applies it. */ + * 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; } /** @@ -63,20 +79,50 @@ export async function parseCsvFile( } 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 data = result.data; + const dataAll = result.data; + if (dataAll.length === 0) return err("empty"); + const data = dataAll.slice(skipRows); if (data.length === 0) return err("empty"); - const headers = data[0] ?? []; - if (headers.length === 0) return err("no_headers"); + + 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 = data.slice(1).map((row) => { + 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("")]; @@ -87,15 +133,73 @@ export async function parseCsvFile( headers, rows, source: { - filename: file.name, + filename: options.filename ?? "(pasted)", importedAt: new Date().toISOString(), - encoding: "utf-8", + 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.", 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/store/labelStore.test.ts b/src/store/labelStore.test.ts index fb658920..f821e6e6 100644 --- a/src/store/labelStore.test.ts +++ b/src/store/labelStore.test.ts @@ -41,6 +41,8 @@ function reset() { pasteCount: 0, variables: [], csvDataset: null, + csvMapping: null, + csvMappingModalOpen: false, previewMode: { status: 'idle' }, canvasSettings: { showGrid: false, diff --git a/src/store/labelStore.ts b/src/store/labelStore.ts index 702c9f32..84813ee0 100644 --- a/src/store/labelStore.ts +++ b/src/store/labelStore.ts @@ -26,8 +26,9 @@ import { FN_NUMBER_MAX, type Variable, type VariableInput, + type CsvMapping, } from '../types/Variable'; -import type { CsvParseResult } from '../lib/csvImport'; +import { forgetImport, type CsvParseResult } from '../lib/csvImport'; /** Snapshot of an imported CSV plus the row the canvas is currently * previewing. Distinct from the Variable→header mapping (which lives @@ -175,10 +176,17 @@ interface LabelState { * persist-partialize: the file path can't be reopened on rehydrate, * and persisting raw rows would bloat localStorage and leak * customer data into the design file. User re-imports per session. - * Mapping (which variable maps to which header) lives in the - * design file separately. */ + * Mapping (which variable maps to which header) lives separately + * in `csvMapping` and round-trips with the design. */ csvDataset: CsvDataset | null; + /** Persistent mapping between Variables and CSV column names. + * Lives in the design file (round-tripped via Save/Load) so a + * user can re-import the same CSV structure later without + * re-mapping. Null when no CSV has ever been imported into this + * design. */ + csvMapping: CsvMapping | null; + addObject: ( type: string, position?: { x: number; y: number }, @@ -222,7 +230,12 @@ interface LabelState { setThirdPartyEnabled: (service: 'labelary', enabled: boolean) => void; acknowledgeLabelaryNotice: () => void; setCanvasSettings: (settings: Partial) => void; - loadDesign: (label: LabelConfig, pages: Page[], variables?: Variable[]) => void; + loadDesign: ( + label: LabelConfig, + pages: Page[], + variables?: Variable[], + csvMapping?: CsvMapping | null, + ) => void; appendPages: (pages: Page[]) => void; /** Create a new variable. Returns the new id, or null when all 99 @@ -238,17 +251,35 @@ interface LabelState { * every page. The field's own content prop (kept since binding) takes * over on render/export. */ removeVariable: (id: string) => void; + /** Bulk-replace the entire variables list. Used by the mapping + * modal's Apply path so add-variable-inline can commit atomically + * with the new mapping. No cleanup of bindings or fields: callers + * are expected to only append (every existing id stays in the + * array); removals still go through `removeVariable` for the + * full strip-and-unbind dance. */ + setVariables: (variables: Variable[]) => void; /** Replace the entire CSV dataset and reset the active row to 0. */ loadCsv: (result: CsvParseResult) => void; - /** Drop the current CSV dataset. Phase-2b: also unbinds the design's - * csvMapping headerSnapshot since it would point at a file the user - * is no longer working with. */ + /** Drop the current CSV dataset (session-only data). Does not touch + * `csvMapping`; the mapping persists in the design so the next CSV + * with the same headers reuses it silently. */ clearCsv: () => void; /** Move the canvas preview to a different row. Out-of-range indices * are silently clamped to [0, rows.length - 1]; no-op when no CSV * is loaded. */ setActiveRow: (index: number) => void; + /** Set or replace the CSV mapping on the current design. Passing + * null clears the mapping (e.g. user picks "Reset mapping"). */ + setCsvMapping: (mapping: CsvMapping | null) => void; + + /** Whether the CSV mapping modal is currently open. Lives in the + * store so the auto-open trigger (after import, on header + * mismatch) and the manual-open trigger (button in Variables + * panel) can share one flag without prop drilling. */ + csvMappingModalOpen: boolean; + openCsvMappingModal: () => void; + closeCsvMappingModal: () => void; moveObjectForward: (id: string) => void; moveObjectBackward: (id: string) => void; moveObjectToFront: (id: string) => void; @@ -473,6 +504,8 @@ export const useLabelStore = create()( pasteCount: 0, variables: [], csvDataset: null, + csvMapping: null, + csvMappingModalOpen: false, locale: detectLocale(), theme: detectInitialTheme(), thirdParty: thirdPartyDefaults(), @@ -856,17 +889,21 @@ export const useLabelStore = create()( }); }), - loadDesign: (label, pages, variables) => - set((state) => { - if (selectPreviewLocksEditor(state)) return {}; - return { - label, - pages: pages.length > 0 ? pages : [{ objects: [] }], - currentPageIndex: 0, - selectedIds: [], - variables: variables ?? [], - }; - }), + loadDesign: (label, pages, variables, csvMapping) => { + if (selectPreviewLocksEditor(get())) return; + // Drop the prior design's CSV cache too: the raw text in the + // module cache belongs to that file, not the one being loaded. + forgetImport(); + set({ + label, + pages: pages.length > 0 ? pages : [{ objects: [] }], + currentPageIndex: 0, + selectedIds: [], + variables: variables ?? [], + csvMapping: csvMapping ?? null, + csvDataset: null, + }); + }, // Append-mode counterpart to loadDesign: keeps the current label // config (the user opted into the existing design's dimensions / @@ -1031,6 +1068,28 @@ export const useLabelStore = create()( }; }), + setVariables: (variables) => + set((state) => { + if (selectPreviewLocksEditor(state)) return {}; + // Mirror addVariable's validation. Bulk-replace bypasses + // per-entry guards so we re-check here; a stray duplicate + // would leave the Variables panel in an unfixable state + // (two rows with identical name or slot, neither + // editable to the other's value). + const names = new Set(); + const fns = new Set(); + for (const v of variables) { + const trimmed = v.name.trim(); + if (trimmed === '') return {}; + if (names.has(trimmed)) return {}; + names.add(trimmed); + if (v.fnNumber < FN_NUMBER_MIN || v.fnNumber > FN_NUMBER_MAX) return {}; + if (fns.has(v.fnNumber)) return {}; + fns.add(v.fnNumber); + } + return { variables }; + }), + removeVariable: (id) => set((state) => { if (selectPreviewLocksEditor(state)) return {}; @@ -1042,9 +1101,19 @@ export const useLabelStore = create()( pagesChanged = true; return { ...p, objects: stripped }; }); + // Mapping-cleanup: drop any csvMapping entry pointing at the + // deleted variable so the design file doesn't carry orphan + // references. Other entries stay intact. + let nextMapping = state.csvMapping; + if (state.csvMapping && id in state.csvMapping.bindings) { + const { [id]: _drop, ...rest } = state.csvMapping.bindings; + void _drop; + nextMapping = { ...state.csvMapping, bindings: rest }; + } return { variables: state.variables.filter((v) => v.id !== id), ...(pagesChanged ? { pages: nextPages } : {}), + ...(nextMapping !== state.csvMapping ? { csvMapping: nextMapping } : {}), }; }), @@ -1058,7 +1127,10 @@ export const useLabelStore = create()( }, })), - clearCsv: () => set({ csvDataset: null }), + clearCsv: () => { + forgetImport(); + set({ csvDataset: null }); + }, setActiveRow: (index) => set((state) => { @@ -1069,6 +1141,11 @@ export const useLabelStore = create()( return { csvDataset: { ...ds, activeRowIndex: clamped } }; }), + setCsvMapping: (mapping) => set({ csvMapping: mapping }), + + openCsvMappingModal: () => set({ csvMappingModalOpen: true }), + closeCsvMappingModal: () => set({ csvMappingModalOpen: false }), + enterPreviewMode: async () => { const state = get(); if (state.previewMode.status === 'loading' || state.previewMode.status === 'active') { @@ -1137,6 +1214,7 @@ export const useLabelStore = create()( labelaryNoticeAcknowledged: state.labelaryNoticeAcknowledged, canvasSettings: state.canvasSettings, variables: state.variables, + csvMapping: state.csvMapping, }), } ), @@ -1146,6 +1224,7 @@ export const useLabelStore = create()( pages: state.pages, currentPageIndex: state.currentPageIndex, variables: state.variables, + csvMapping: state.csvMapping, }), } ) diff --git a/src/types/Variable.test.ts b/src/types/Variable.test.ts new file mode 100644 index 00000000..911749ed --- /dev/null +++ b/src/types/Variable.test.ts @@ -0,0 +1,76 @@ +import { describe, it, expect } from 'vitest'; +import { + normalizeHeaderForMatch, + suggestCsvMapping, + uniqueVariableName, + nextFreeFnNumber, + type Variable, +} from './Variable'; + +function v(name: string, id = name): Variable { + return { id, name, fnNumber: 1, defaultValue: '' }; +} + +describe('normalizeHeaderForMatch', () => { + it('lowercases and collapses spaces, dashes, underscores', () => { + expect(normalizeHeaderForMatch('Product Code')).toBe('productcode'); + expect(normalizeHeaderForMatch('product_code')).toBe('productcode'); + expect(normalizeHeaderForMatch('Product-Code')).toBe('productcode'); + expect(normalizeHeaderForMatch('PRODUCT CODE')).toBe('productcode'); + }); + + it('leaves digits and other punctuation untouched', () => { + expect(normalizeHeaderForMatch('SKU#1')).toBe('sku#1'); + }); +}); + +describe('suggestCsvMapping', () => { + it('matches variables to headers case- and whitespace-insensitively', () => { + const variables = [v('sku'), v('productCode'), v('customer')]; + const headers = ['SKU', 'Product Code', 'Customer Name']; + const result = suggestCsvMapping(variables, headers); + expect(result).toEqual({ + sku: 'SKU', + productCode: 'Product Code', + }); + // 'customer' has no exact normalised match to 'Customer Name'. + expect(result.customer).toBeUndefined(); + }); + + it('consumes each header at most once (ties go to the first variable)', () => { + const variables = [v('a', 'idA'), v('A', 'idA2')]; + const headers = ['a']; + const result = suggestCsvMapping(variables, headers); + expect(result).toEqual({ idA: 'a' }); + }); + + it('returns empty object when nothing matches', () => { + const variables = [v('sku')]; + const headers = ['totally-unrelated']; + expect(suggestCsvMapping(variables, headers)).toEqual({}); + }); + + it('returns empty object when no variables exist', () => { + expect(suggestCsvMapping([], ['a', 'b'])).toEqual({}); + }); +}); + +describe('uniqueVariableName + nextFreeFnNumber', () => { + it('uniqueVariableName appends _2, _3 on collision', () => { + const existing = [v('sku'), v('sku_2', 'x')]; + expect(uniqueVariableName('sku', existing)).toBe('sku_3'); + }); + + it('nextFreeFnNumber returns 1 on empty set', () => { + expect(nextFreeFnNumber([])).toBe(1); + }); + + it('nextFreeFnNumber skips taken slots', () => { + expect(nextFreeFnNumber([1, 2, 4])).toBe(3); + }); + + it('nextFreeFnNumber returns null when 1-99 are all taken', () => { + const all = Array.from({ length: 99 }, (_, i) => i + 1); + expect(nextFreeFnNumber(all)).toBeNull(); + }); +}); diff --git a/src/types/Variable.ts b/src/types/Variable.ts index 65f2fb21..8d457044 100644 --- a/src/types/Variable.ts +++ b/src/types/Variable.ts @@ -48,3 +48,64 @@ export function uniqueVariableName( while (taken.has(`${base}_${i}`)) i++; return `${base}_${i}`; } + +/** Auto-generated default name for a freshly added variable: `var_N` + * where N is the lowest integer that yields a unique name in the + * current set. Used both by the Variables panel's add button and by + * the mapping modal's inline add. Keeps the naming convention in + * one place. */ +export function nextDefaultVariableName(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}`; +} + +/** Persistent mapping between document Variables and CSV columns. + * Lives in the design file (design.json) because it is design-time + * config: it references variable.id (only meaningful inside this + * document) and dictates how the data feeds the template. Header + * NAME, not index, so column reorders between imports don't break + * the mapping. */ +export const csvMappingSchema = z.object({ + /** variableId → header name. Variables without an entry fall back + * to their defaultValue when the dataset is active. */ + bindings: z.record(z.string(), z.string()), + /** Snapshot of the headers the mapping was made against. Empty + * array = no CSV ever imported (mapping shouldn't exist either). + * Re-import with a different header set triggers a UI warning. */ + headerSnapshot: z.array(z.string()), +}); +export type CsvMapping = z.infer; + +/** Loose header-name comparison for auto-suggesting CSV → Variable + * matches at import time. Case-insensitive; spaces, dashes and + * underscores collapse so `"Product Code"`, `"product_code"` and + * `"ProductCode"` all match a variable named `productCode`. */ +export function normalizeHeaderForMatch(s: string): string { + return s.toLowerCase().replace(/[\s_-]+/g, ""); +} + +/** Build a `variableId → headerName` mapping by matching each variable + * against the supplied CSV headers via `normalizeHeaderForMatch`. + * Variables with no match are absent from the output (caller can + * surface them in the modal so the user picks manually). Each header + * is consumed at most once; ties go to the first variable in `variables`. */ +export function suggestCsvMapping( + variables: readonly Variable[], + headers: readonly string[], +): Record { + const taken = new Set(); + const bindings: Record = {}; + for (const v of variables) { + const normName = normalizeHeaderForMatch(v.name); + const match = headers.find( + (h) => !taken.has(h) && normalizeHeaderForMatch(h) === normName, + ); + if (match !== undefined) { + bindings[v.id] = match; + taken.add(match); + } + } + return bindings; +} From f8c059585773de0ce77dc1a947e3d6b66771ef8e Mon Sep 17 00:00:00 2001 From: u8array Date: Sat, 23 May 2026 11:43:18 +0200 Subject: [PATCH 3/7] feat(variables-csv): Phase 2c - active-row preview, variable source visibility, re-import confirm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three intertwined capabilities so the user can actually see what their CSV mapping does and trust it: 1. Active-row canvas substitution. Bound variables render the active CSV row's cell value on the canvas; ZPL output stays template form (^FN/^FV) so the print artifact is unchanged. Row stepper in the CSV badge for live navigation. Empty cells render as empty strings (no fallback to defaultValue) so a deliberate blank stays visible. 2. Preview/Schema render-mode toggle. Sits in the Variables-panel header, surfaces whenever any variable exists (independent of CSV state). Preview shows the resolved value; Schema renders «variableName» placeholders matching the InDesign Data Merge / Word Mail Merge idiom. canvasSettings.csvRenderMode persists with v3→v4 store migration. 3. Universal variable-source badge. TableCellsIcon for `csv`, ExclamationTriangleIcon (amber) for `orphan` mapping, MinusIcon (muted) for `default`. Same component renders in the Variables-panel rows and mapping-modal rows; tooltip names the bound header. Complemented on the canvas by a 12% opacity amber bbox tint behind fields rendering fallback content in preview mode (shapes with explicit width/height — text, box, ellipse, image, qrcode, datamatrix; barcode width is content-derived so tint silently skips, badge in the panel still covers it). Pure helpers in lib/variableBinding.ts: `getVariableSource`, `buildActiveCsvRow`, `resolveVariableValue` with mode and `isMappingCompatibleWith`. Layer-clean: lib does not depend on store; KonvaObject assembles inputs from store state. Re-import confirmation. Picking a new CSV while one is loaded now surfaces a dialog instead of silently overwriting. Compatibility against the saved mapping decides the shape: same headers (order- independent set comparison, or column-count match in headerless mode) → single Replace, mapping carries over; different → Cancel / Discard mapping / Keep & remap. Mapping persists the parseOptions it was last applied with (delimiter, hasHeaderRow, skipRows, encoding), used as defaults on re-import so a headerless or windows-1252 dataset is not re-parsed under defaults and falsely flagged as different. Tests: 19 new in variableBinding.test.ts (resolveVariableValue, buildActiveCsvRow, applyBindingToObject, getVariableSource) + 7 in Variable.test.ts (isMappingCompatibleWith). 1083 total. --- src/components/AppShell.tsx | 18 +- src/components/Canvas/KonvaObject.tsx | 80 ++++++- .../Variables/CsvImportConfirmDialog.tsx | 117 ++++++++++ .../Variables/VariableMappingModal.tsx | 47 +++- .../Variables/VariableSourceBadge.tsx | 64 ++++++ src/components/Variables/VariablesPanel.tsx | 143 +++++++++--- src/hooks/useCsvImportActions.ts | 131 ++++++++--- src/lib/variableBinding.test.ts | 206 ++++++++++++++++++ src/lib/variableBinding.ts | 107 ++++++++- src/store/labelStore.test.ts | 1 + src/store/labelStore.ts | 22 +- src/types/Variable.test.ts | 49 +++++ src/types/Variable.ts | 39 ++++ 13 files changed, 942 insertions(+), 82 deletions(-) create mode 100644 src/components/Variables/CsvImportConfirmDialog.tsx create mode 100644 src/components/Variables/VariableSourceBadge.tsx create mode 100644 src/lib/variableBinding.test.ts diff --git a/src/components/AppShell.tsx b/src/components/AppShell.tsx index 40128a4f..6ab3fb20 100644 --- a/src/components/AppShell.tsx +++ b/src/components/AppShell.tsx @@ -7,6 +7,7 @@ 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, @@ -78,7 +79,15 @@ export function AppShell() { useGlobalShortcuts(); const { handleNew, handleSave, handleLoad, loadInputRef, loadError, dismissLoadError } = useDesignFileActions(); - const { csvInputRef, handleCsvImport, csvError, dismissCsvError } = useCsvImportActions(); + const { + csvInputRef, + handleCsvImport, + csvError, + dismissCsvError, + pendingImport, + confirmPendingImport, + cancelPendingImport, + } = useCsvImportActions(); const csvMappingModalOpen = useLabelStore((s) => s.csvMappingModalOpen); const closeCsvMappingModal = useLabelStore((s) => s.closeCsvMappingModal); const { @@ -333,6 +342,13 @@ export function AppShell() { {csvMappingModalOpen && ( )} + {pendingImport && ( + + )} {showZebraPrint && ( )} diff --git a/src/components/Canvas/KonvaObject.tsx b/src/components/Canvas/KonvaObject.tsx index c0651cd8..76c38830 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 { getVariableSource, lookupBoundVariable } 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,79 @@ 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 ; + + // Fallback-tint: bound field is rendering its defaultValue (or empty) + // instead of a CSV cell. Surface this as a translucent amber bbox + // behind the shape so the user sees "this is not data" without + // having to inspect the Variables panel. Only meaningful in + // preview mode with a CSV loaded; schema mode already says «name» + // and pre-CSV everything renders default by design. Orphaned + // variableId (variable was deleted) skips the tint too — the field + // is already in an invalid state and gets its own badge upstream. + const boundVariable = lookupBoundVariable(obj, variables); + const showFallbackTint = + csvRenderMode === "preview" && + csvDataset !== null && + boundVariable !== undefined && + getVariableSource(boundVariable, csvDataset, csvMapping) !== "csv"; + + 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..b55655bd --- /dev/null +++ b/src/components/Variables/CsvImportConfirmDialog.tsx @@ -0,0 +1,117 @@ +import { DialogShell } from '../ui/DialogShell'; +import type { PendingImport } from '../../hooks/useCsvImportActions'; + +interface Props { + pending: PendingImport; + onConfirm: (opts: { keepMapping: boolean }) => void; + onCancel: () => void; +} + +/* i18n: Phase-2 strings here get locale keys at end-of-branch sweep. */ +const COPY = { + title: 'Replace CSV data', + // {old} replaced by old filename, {new} by new filename. + bodyReplacePrefixFmt: 'Replace "{old}" with "{new}"?', + // Filled when headers/columns line up (mapping carries over). + bodySameHeaders: 'Same column names. Mapping stays intact.', + bodySameColumnsHeaderlessFmt: + 'Same column count ({n}). Mapping stays intact.', + // Filled when headers/columns diverge (mapping needs review). + bodyDifferentHeaders: + 'The new file has different column names. The current mapping will not match cleanly.', + bodyDifferentColumnsHeaderlessFmt: + 'The new file has a different column count (was {n}). The current mapping will not match cleanly.', + cancel: 'Cancel', + replace: 'Replace', + discardMapping: 'Discard mapping', + keepAndRemap: 'Keep & remap', +} as const; + +/** 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 newFilename = pending.parsed.file.name; + const oldFilename = pending.replacingFilename ?? ''; + const headline = COPY.bodyReplacePrefixFmt + .replace('{old}', oldFilename) + .replace('{new}', newFilename); + const detail = + pending.kind === 'same' + ? pending.wasHeaderless + ? COPY.bodySameColumnsHeaderlessFmt.replace( + '{n}', + String(pending.previousColumnCount), + ) + : COPY.bodySameHeaders + : pending.wasHeaderless + ? COPY.bodyDifferentColumnsHeaderlessFmt.replace( + '{n}', + String(pending.previousColumnCount), + ) + : COPY.bodyDifferentHeaders; + + return ( + +
+

+ {COPY.title} +

+

+ {headline} +

+

{detail}

+
+
+ + {pending.kind === 'same' ? ( + + ) : ( + <> + + + + )} +
+
+ ); +} diff --git a/src/components/Variables/VariableMappingModal.tsx b/src/components/Variables/VariableMappingModal.tsx index 9f134aa0..34a8382e 100644 --- a/src/components/Variables/VariableMappingModal.tsx +++ b/src/components/Variables/VariableMappingModal.tsx @@ -6,6 +6,7 @@ import { nextFreeFnNumber, suggestCsvMapping, type CsvMapping, + type CsvParseOptionsPersisted, type Variable, } from '../../types/Variable'; import { @@ -16,6 +17,8 @@ import { import { DialogShell } from '../ui/DialogShell'; import { CollapsibleSection } from '../ui/CollapsibleSection'; import { inputCls } from '../Properties/styles'; +import { getVariableSource } from '../../lib/variableBinding'; +import { VariableSourceBadge } from './VariableSourceBadge'; /* i18n: literal strings here get locale keys at the end-of-branch sweep. */ const COPY = { @@ -103,10 +106,19 @@ export function VariableMappingModal({ onClose }: Props) { () => new Set(variables.map((v) => v.id)), ); const [draftOptions, setDraftOptions] = useState(() => ({ - delimiter: csvDataset?.source.delimiter ?? '', - hasHeaderRow: true, - skipRows: 0, - encoding: csvDataset?.source.encoding ?? 'utf-8', + // 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 @@ -279,6 +291,7 @@ export function VariableMappingModal({ onClose }: Props) { setCsvMapping({ bindings: draftBindings, headerSnapshot: parse.headers, + parseOptions: persistableParseOptions(draftOptions), }); // loadCsv resets activeRowIndex to 0; re-apply the draft row // clamped to the new rows.length. @@ -354,10 +367,24 @@ export function VariableMappingModal({ onClose }: Props) { draftVariables.map((v) => { const nameError = nameErrors[v.id]; const isNew = !initialVariableIds.has(v.id); + // 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 (
+ 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[], diff --git a/src/components/Variables/VariableSourceBadge.tsx b/src/components/Variables/VariableSourceBadge.tsx new file mode 100644 index 00000000..d0dd06d1 --- /dev/null +++ b/src/components/Variables/VariableSourceBadge.tsx @@ -0,0 +1,64 @@ +import { + ExclamationTriangleIcon, + MinusIcon, + TableCellsIcon, +} from '@heroicons/react/16/solid'; +import type { VariableSource } from '../../lib/variableBinding'; + +interface Props { + source: VariableSource; + /** Optional: header name a `csv` or `orphan` source is bound to. + * Surfaced in the tooltip so the user sees which column the + * variable feeds from without opening the mapping modal. */ + boundHeader?: string; + /** Visual size. `xs` for tight contexts (mapping modal rows); + * `sm` for the Variables-panel rows where there's a bit more + * vertical room. */ + size?: 'xs' | 'sm'; +} + +/* i18n: Phase-2 strings here get locale keys at end-of-branch sweep. */ +const COPY: Record = { + csv: { + label: 'CSV', + tipFmt: 'Value comes from CSV column "{header}".', + }, + orphan: { + label: 'orphan', + tipFmt: 'Mapped to "{header}" — that column is not in the current CSV. Renders defaultValue.', + }, + default: { + label: 'default', + tipFmt: 'Not mapped to a CSV column. Renders defaultValue.', + }, +}; + +/** Universal source indicator for a Variable. Same component appears + * in the Variables panel, the mapping modal, and (later) the canvas + * selection HUD so the user learns one visual vocabulary for + * "where is this value coming from?". */ +export function VariableSourceBadge({ source, boundHeader, size = 'sm' }: Props) { + const cls = + source === 'csv' + ? 'text-muted' + : 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 = COPY[source].tipFmt.replace('{header}', boundHeader ?? ''); + return ( + + + + ); +} diff --git a/src/components/Variables/VariablesPanel.tsx b/src/components/Variables/VariablesPanel.tsx index fcf5a2d5..fb34b890 100644 --- a/src/components/Variables/VariablesPanel.tsx +++ b/src/components/Variables/VariablesPanel.tsx @@ -1,5 +1,7 @@ import { useState, type ChangeEvent } from 'react'; import { + ChevronLeftIcon, + ChevronRightIcon, Cog6ToothIcon, PlusIcon, TableCellsIcon, @@ -20,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(); @@ -32,7 +36,10 @@ export function VariablesPanel() { 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 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 @@ -99,9 +106,35 @@ export function VariablesPanel() { return (
-

- {tv.panelHint} -

+
+

+ {tv.panelHint} +

+ {variables.length > 0 && ( + + )} +
{csvDataset && (
@@ -134,6 +167,47 @@ export function VariablesPanel() {

{csvDataset.source.rowCount} rows · {mappedCount} of {variables.length} mapped

+ {csvDataset.rows.length > 0 && ( +
+ + row + { + 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} + +
+ )}
)} @@ -146,25 +220,31 @@ 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)} + /> + ); + })}
)} @@ -219,6 +299,8 @@ export function VariablesPanel() { interface RowProps { variable: Variable; bindings: number; + source: VariableSource; + boundHeader: string | undefined; error?: string; tv: Translations['variables']; onChangeName: (next: string) => void; @@ -230,6 +312,8 @@ interface RowProps { function VariableRow({ variable, bindings, + source, + boundHeader, error, tv, onChangeName, @@ -313,12 +397,15 @@ function VariableRow({ />
- - {bindings === 0 - ? tv.noBindings - : bindings === 1 - ? tv.bindingsSingular - : tv.bindingsPluralFmt.replace('{n}', String(bindings))} + + + + {bindings === 0 + ? tv.noBindings + : bindings === 1 + ? tv.bindingsSingular + : tv.bindingsPluralFmt.replace('{n}', String(bindings))} + {error && {error}}
diff --git a/src/hooks/useCsvImportActions.ts b/src/hooks/useCsvImportActions.ts index 0a59fd77..fc8ae6df 100644 --- a/src/hooks/useCsvImportActions.ts +++ b/src/hooks/useCsvImportActions.ts @@ -4,27 +4,55 @@ 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 and the parse-error state, plus the auto-open - * trigger for the mapping modal: imports whose headers don't match - * the saved mapping (or whose design has no mapping yet) pop the - * modal automatically. Imports with a valid mapping silent-reuse. */ + * hidden ref, the parse-error state, and the pending-import + * slot that gates a destructive replace behind a ConfirmDialog. */ export function useCsvImportActions() { - const loadCsv = useLabelStore((s) => s.loadCsv); - const openCsvMappingModal = useLabelStore((s) => s.openCsvMappingModal); 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); - // Read raw bytes up front so the modal can re-decode with a - // different encoding later (German Excel ANSI exports, etc.) - // without re-reading from disk. Default decode is UTF-8. + + const { csvMapping, csvDataset } = useLabelStore.getState(); + let bytes: Uint8Array; try { bytes = new Uint8Array(await file.arrayBuffer()); @@ -32,38 +60,87 @@ export function useCsvImportActions() { setCsvError(csvParseErrors.read_failed); return; } - const text = new TextDecoder("utf-8").decode(bytes); - const result = parseCsvText(text, { filename: file.name }); + + // 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; } - rememberImport(file, bytes, text); - loadCsv(result.value); - // Decide whether to surface the mapping modal. Snapshot the store - // state after loadCsv so we see the just-imported headers; the - // mapping itself doesn't change during loadCsv so reading it - // before vs after is identical. - const { csvMapping } = useLabelStore.getState(); - const needsMappingReview = - !csvMapping || - !headerArraysEqual(csvMapping.headerSnapshot, result.value.headers); - if (needsMappingReview) { - openCsvMappingModal(); + 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, }; } -function headerArraysEqual(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; +/** 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/lib/variableBinding.test.ts b/src/lib/variableBinding.test.ts new file mode 100644 index 00000000..e43dd16d --- /dev/null +++ b/src/lib/variableBinding.test.ts @@ -0,0 +1,206 @@ +import { describe, it, expect } from 'vitest'; +import { + resolveVariableValue, + buildActiveCsvRow, + applyBindingToObject, + getVariableSource, + type ActiveCsvRow, +} from './variableBinding'; +import type { CsvMapping, Variable } from '../types/Variable'; +import type { LabelObject } from '../types/Group'; + +const variable = (over: Partial = {}): Variable => ({ + id: 'v1', + name: 'sku', + fnNumber: 1, + defaultValue: 'DEFAULT', + ...over, +}); + +const mapping = (bindings: Record = {}): CsvMapping => ({ + bindings, + headerSnapshot: [], +}); + +const active = ( + headers: string[], + row: string[], + bindings: Record, +): ActiveCsvRow => ({ headers, row, mapping: mapping(bindings) }); + +describe('resolveVariableValue', () => { + it('returns defaultValue when no active row', () => { + expect(resolveVariableValue(variable(), null)).toBe('DEFAULT'); + }); + + it('returns defaultValue when variable is not bound', () => { + expect( + resolveVariableValue(variable(), active(['sku'], ['A1'], { other: 'sku' })), + ).toBe('DEFAULT'); + }); + + it('returns the bound cell value when header exists', () => { + expect( + resolveVariableValue( + variable(), + active(['sku', 'qty'], ['A1', '10'], { v1: 'sku' }), + ), + ).toBe('A1'); + }); + + it('returns defaultValue when bound header is missing from headers', () => { + expect( + resolveVariableValue( + variable(), + active(['qty'], ['10'], { v1: 'sku' }), + ), + ).toBe('DEFAULT'); + }); + + it('returns empty string for an empty cell (no fallback to default)', () => { + expect( + resolveVariableValue( + variable(), + active(['sku'], [''], { v1: 'sku' }), + ), + ).toBe(''); + }); + + it('returns empty string when row is shorter than headers', () => { + expect( + resolveVariableValue( + variable(), + active(['sku', 'qty'], ['A1'], { v1: 'qty' }), + ), + ).toBe(''); + }); + + it('schema mode returns «name» regardless of CSV state', () => { + expect(resolveVariableValue(variable({ name: 'sku' }), null, 'schema')).toBe('«sku»'); + expect( + resolveVariableValue( + variable({ name: 'sku' }), + active(['sku'], ['A1'], { v1: 'sku' }), + 'schema', + ), + ).toBe('«sku»'); + }); +}); + +describe('buildActiveCsvRow', () => { + it('returns null when dataset is null', () => { + expect(buildActiveCsvRow(null, mapping())).toBeNull(); + }); + + it('returns null when mapping is null', () => { + expect( + buildActiveCsvRow( + { headers: ['sku'], rows: [['A1']], activeRowIndex: 0 }, + null, + ), + ).toBeNull(); + }); + + it('returns null when activeRowIndex is out of bounds', () => { + expect( + buildActiveCsvRow( + { headers: ['sku'], rows: [], activeRowIndex: 0 }, + mapping(), + ), + ).toBeNull(); + }); + + it('assembles row when dataset, mapping and index align', () => { + const result = buildActiveCsvRow( + { headers: ['sku', 'qty'], rows: [['A1', '10'], ['B2', '5']], activeRowIndex: 1 }, + mapping({ v1: 'sku' }), + ); + expect(result).toEqual({ + headers: ['sku', 'qty'], + row: ['B2', '5'], + mapping: mapping({ v1: 'sku' }), + }); + }); +}); + +describe('applyBindingToObject', () => { + const obj = (variableId?: string, content = 'orig'): LabelObject => + ({ + id: 'o1', + type: 'text', + x: 0, + y: 0, + rotation: 0, + ...(variableId ? { variableId } : {}), + props: { content }, + }) as unknown as LabelObject; + + it('returns identity when object has no variableId', () => { + const o = obj(); + expect(applyBindingToObject(o, [variable()])).toBe(o); + }); + + it('substitutes defaultValue when no active row', () => { + const o = obj('v1'); + const out = applyBindingToObject(o, [variable()]); + expect((out as unknown as { props: { content: string } }).props.content).toBe( + 'DEFAULT', + ); + }); + + it('substitutes CSV cell when bound and row is active', () => { + const o = obj('v1'); + const out = applyBindingToObject( + o, + [variable()], + active(['sku'], ['A1'], { v1: 'sku' }), + ); + expect((out as unknown as { props: { content: string } }).props.content).toBe( + 'A1', + ); + }); + + it('returns identity when resolved value already matches', () => { + const o = obj('v1', 'DEFAULT'); + expect(applyBindingToObject(o, [variable()])).toBe(o); + }); +}); + +describe('getVariableSource', () => { + const v = variable(); + const datasetWith = (headers: string[]) => ({ headers }); + + it("returns 'default' when no mapping exists", () => { + expect(getVariableSource(v, datasetWith(['sku']), null)).toBe('default'); + }); + + it("returns 'default' when variable is not in mapping bindings", () => { + expect( + getVariableSource(v, datasetWith(['sku']), { bindings: {}, headerSnapshot: ['sku'] }), + ).toBe('default'); + }); + + it("returns 'default' when bound but no dataset is loaded", () => { + expect( + getVariableSource(v, null, { bindings: { v1: 'sku' }, headerSnapshot: ['sku'] }), + ).toBe('default'); + }); + + it("returns 'csv' when bound and the header exists in the dataset", () => { + expect( + getVariableSource(v, datasetWith(['sku', 'qty']), { + bindings: { v1: 'sku' }, + headerSnapshot: ['sku', 'qty'], + }), + ).toBe('csv'); + }); + + it("returns 'orphan' when bound but header is missing from current dataset", () => { + expect( + getVariableSource(v, datasetWith(['qty']), { + bindings: { v1: 'sku' }, + headerSnapshot: ['sku', 'qty'], + }), + ).toBe('orphan'); + }); +}); diff --git a/src/lib/variableBinding.ts b/src/lib/variableBinding.ts index 4c418005..63e3dba9 100644 --- a/src/lib/variableBinding.ts +++ b/src/lib/variableBinding.ts @@ -1,5 +1,5 @@ import type { LabelObject } from "../types/Group"; -import type { Variable } from "../types/Variable"; +import type { CsvMapping, Variable } from "../types/Variable"; /** * Resolve an object's `variableId` against the variable list and return @@ -14,32 +14,117 @@ export function lookupBoundVariable( return variables.find((v) => v.id === obj.variableId); } +/** How a bound variable should be visualised on the canvas. `preview` + * shows the actual content that would print (CSV row or default); + * `schema` shows a `«name»` placeholder so the user sees structure + * without data. Matches the established print-design idiom + * (InDesign Data Merge, Word Mail Merge). */ +export type RenderMode = "preview" | "schema"; + +/** + * Resolve which string a bound Variable currently represents. Default + * is `variable.defaultValue` (template fallback). When an active CSV + * row is supplied, a binding for this Variable in the mapping picks + * the corresponding cell instead. Empty cells render as empty + * strings (no fallback to defaultValue), so a deliberate blank in + * the CSV stays visible. + * + * In `schema` mode, returns `«variableName»` regardless of CSV state. + */ +export function resolveVariableValue( + variable: Variable, + active: ActiveCsvRow | null, + mode: RenderMode = "preview", +): string { + if (mode === "schema") return `«${variable.name}»`; + if (!active) return variable.defaultValue; + const header = active.mapping.bindings[variable.id]; + if (header === undefined) return variable.defaultValue; + const idx = active.headers.indexOf(header); + if (idx === -1) return variable.defaultValue; + return active.row[idx] ?? ""; +} + +/** Where a Variable's currently-rendered value comes from. Surfaced as + * a status badge across the Variables panel + mapping modal so the + * user can tell at a glance whether a value is data-driven, falling + * back to default, or has a broken mapping. */ +export type VariableSource = "csv" | "orphan" | "default"; + +/** Classify a variable against the current CSV state. `csv`: bound to + * a header that exists in the active dataset. `orphan`: bound but + * the header is missing (mapping is stale, value falls back to + * defaultValue). `default`: not bound (intentionally or because no + * CSV is loaded at all). Pure — works with or without csvDataset. */ +export function getVariableSource( + variable: Variable, + csvDataset: { headers: readonly string[] } | null, + csvMapping: CsvMapping | null, +): VariableSource { + const header = csvMapping?.bindings[variable.id]; + if (header === undefined) return "default"; + if (!csvDataset) return "default"; + return csvDataset.headers.includes(header) ? "csv" : "orphan"; +} + +/** Snapshot of the currently active CSV row plus the mapping needed + * to resolve a Variable to a cell. Caller assembles this from store + * state so this lib stays unaware of the store layer. */ +export interface ActiveCsvRow { + headers: readonly string[]; + row: readonly string[]; + mapping: CsvMapping; +} + +/** Assemble an ActiveCsvRow from the loose store-side inputs (the + * store can't expose ActiveCsvRow directly without depending on + * this lib). Returns null when there's nothing to substitute: + * no dataset, no mapping, or the active index is out of bounds. */ +export function buildActiveCsvRow( + csvDataset: { + headers: readonly string[]; + rows: readonly (readonly string[])[]; + activeRowIndex: number; + } | null, + csvMapping: CsvMapping | null, +): ActiveCsvRow | null { + if (!csvDataset || !csvMapping) return null; + const row = csvDataset.rows[csvDataset.activeRowIndex]; + if (!row) return null; + return { headers: csvDataset.headers, row, mapping: csvMapping }; +} + /** * Return `obj` with `props.content` swapped for the bound Variable's - * `defaultValue`, so canvas renderers preview what the printer will - * actually print absent a runtime `^FV` override. Identity-preserving: - * returns the same reference when the object isn't bound or already - * carries the resolved value, so React's referential-equality - * optimisations stay effective. + * resolved value (see `resolveVariableValue`), so canvas renderers + * preview exactly what would print for the current CSV row (or the + * defaultValue when no row data feeds this binding). Identity- + * preserving: returns the same reference when the object isn't + * bound or already carries the resolved value, so React's + * referential-equality optimisations stay effective. * - * Every bindable type today exposes `props.content` (text + 8 barcode - * types; see `bindable: true` in the registry). Non-bindable types - * never have a `variableId`, so the early return covers them. + * Every bindable type today exposes `props.content` (text and the + * barcode types tagged `bindable: true` in the registry). Non- + * bindable types never have a `variableId`, so the early return + * covers them. */ export function applyBindingToObject( obj: T, variables: readonly Variable[], + active: ActiveCsvRow | null = null, + mode: RenderMode = "preview", ): T { const variable = lookupBoundVariable(obj, variables); if (!variable) return obj; const props = (obj as { props?: { content?: unknown } }).props; if (!props || typeof props.content !== "string") return obj; - if (props.content === variable.defaultValue) return obj; + const resolved = resolveVariableValue(variable, active, mode); + if (props.content === resolved) return obj; // The discriminated union doesn't narrow through a spread, so we cast // back to T. Runtime shape preserves the original variant; only // `props.content` changes. return { ...obj, - props: { ...props, content: variable.defaultValue }, + props: { ...props, content: resolved }, } as unknown as T; } diff --git a/src/store/labelStore.test.ts b/src/store/labelStore.test.ts index f821e6e6..187db171 100644 --- a/src/store/labelStore.test.ts +++ b/src/store/labelStore.test.ts @@ -51,6 +51,7 @@ function reset() { zoom: 1, unit: 'mm', viewRotation: 0, + csvRenderMode: 'preview', }, }); } diff --git a/src/store/labelStore.ts b/src/store/labelStore.ts index 84813ee0..41315066 100644 --- a/src/store/labelStore.ts +++ b/src/store/labelStore.ts @@ -123,6 +123,14 @@ export interface CanvasSettings { zoom: number; unit: Unit; viewRotation: ViewRotation; + /** Controls how bound variables render on the canvas. + * - 'preview': substitute the active CSV row's cell (falls back to + * defaultValue when no row data is available for the variable). + * - 'schema': render the placeholder `«variableName»` so the user + * sees the field structure regardless of data. + * Only meaningful while a `csvDataset` is loaded; the toolbar + * toggle is hidden otherwise. */ + csvRenderMode: 'preview' | 'schema'; } export type ThemePreference = 'light' | 'dark'; @@ -456,6 +464,16 @@ export function migrateLegacy(persistedState: unknown, version: number): unknown s = { ...s, pages: migrateCirclesInPages(s.pages) }; } + // v3→v4: canvasSettings.csvRenderMode added for the schema/preview toggle. + // Default to 'preview' so existing sessions keep showing data-substituted + // canvas exactly as before. + if (version < 4) { + const cs = s.canvasSettings; + if (cs && typeof cs === 'object' && !('csvRenderMode' in cs)) { + s = { ...s, canvasSettings: { ...(cs as Record), csvRenderMode: 'preview' } }; + } + } + return s; } @@ -511,7 +529,7 @@ export const useLabelStore = create()( thirdParty: thirdPartyDefaults(), labelaryNoticeAcknowledged: false, previewMode: { status: 'idle' }, - canvasSettings: { showGrid: false, snapEnabled: false, snapSizeMm: 1, zoom: 1, unit: 'mm', viewRotation: 0 }, + canvasSettings: { showGrid: false, snapEnabled: false, snapSizeMm: 1, zoom: 1, unit: 'mm', viewRotation: 0, csvRenderMode: 'preview' }, addObject: (type, position = { x: 50, y: 50 }, propsOverride) => { if (selectPreviewLocksEditor(get())) return; @@ -1198,7 +1216,7 @@ export const useLabelStore = create()( }), { name: 'zpl-designer-session', - version: 3, + version: 4, migrate: (persistedState, version) => migrateLegacy(persistedState, version) as LabelState, storage: createJSONStorage(() => localStorage), partialize: (state) => ({ diff --git a/src/types/Variable.test.ts b/src/types/Variable.test.ts index 911749ed..5e08450a 100644 --- a/src/types/Variable.test.ts +++ b/src/types/Variable.test.ts @@ -4,9 +4,18 @@ import { suggestCsvMapping, uniqueVariableName, nextFreeFnNumber, + isMappingCompatibleWith, + type CsvMapping, type Variable, } from './Variable'; +function mapping( + headerSnapshot: string[], + parseOptions?: CsvMapping['parseOptions'], +): CsvMapping { + return { bindings: {}, headerSnapshot, ...(parseOptions ? { parseOptions } : {}) }; +} + function v(name: string, id = name): Variable { return { id, name, fnNumber: 1, defaultValue: '' }; } @@ -74,3 +83,43 @@ describe('uniqueVariableName + nextFreeFnNumber', () => { expect(nextFreeFnNumber(all)).toBeNull(); }); }); + +describe('isMappingCompatibleWith', () => { + it('header-row: same names in same order → compatible', () => { + expect(isMappingCompatibleWith(mapping(['sku', 'qty']), ['sku', 'qty'])).toBe(true); + }); + + it('header-row: same names reordered → compatible', () => { + expect(isMappingCompatibleWith(mapping(['sku', 'qty']), ['qty', 'sku'])).toBe(true); + }); + + it('header-row: different name set → incompatible', () => { + expect(isMappingCompatibleWith(mapping(['sku', 'qty']), ['sku', 'price'])).toBe(false); + }); + + it('header-row: subset (one column dropped) → incompatible', () => { + expect(isMappingCompatibleWith(mapping(['sku', 'qty']), ['sku'])).toBe(false); + }); + + it('header-row: superset (extra column) → incompatible', () => { + expect(isMappingCompatibleWith(mapping(['sku', 'qty']), ['sku', 'qty', 'note'])).toBe(false); + }); + + it('headerless: same column count → compatible regardless of names', () => { + expect( + isMappingCompatibleWith( + mapping(['Column 1', 'Column 2', 'Column 3'], { hasHeaderRow: false }), + ['Column 1', 'Column 2', 'Column 3'], + ), + ).toBe(true); + }); + + it('headerless: different column count → incompatible', () => { + expect( + isMappingCompatibleWith( + mapping(['Column 1', 'Column 2'], { hasHeaderRow: false }), + ['Column 1', 'Column 2', 'Column 3'], + ), + ).toBe(false); + }); +}); diff --git a/src/types/Variable.ts b/src/types/Variable.ts index 8d457044..a7d3b6ec 100644 --- a/src/types/Variable.ts +++ b/src/types/Variable.ts @@ -67,6 +67,20 @@ export function nextDefaultVariableName(existing: readonly Variable[]): string { * document) and dictates how the data feeds the template. Header * NAME, not index, so column reorders between imports don't break * the mapping. */ +/** Parse options remembered alongside the mapping so a re-import of + * the same logical dataset uses the same delimiter / encoding / + * headerless decision without the user having to re-pick them. + * Optional fields: omission means "use default" (auto-detect + * delimiter, UTF-8 encoding, header-row present, no rows skipped). + * Persisted in design.json so the choice survives Save/Load. */ +export const csvParseOptionsPersistedSchema = z.object({ + delimiter: z.string().optional(), + hasHeaderRow: z.boolean().optional(), + skipRows: z.number().int().min(0).optional(), + encoding: z.string().optional(), +}); +export type CsvParseOptionsPersisted = z.infer; + export const csvMappingSchema = z.object({ /** variableId → header name. Variables without an entry fall back * to their defaultValue when the dataset is active. */ @@ -75,9 +89,34 @@ export const csvMappingSchema = z.object({ * array = no CSV ever imported (mapping shouldn't exist either). * Re-import with a different header set triggers a UI warning. */ headerSnapshot: z.array(z.string()), + /** Parse options used when the mapping was last applied. Re-used + * on re-import so the same delimiter/encoding/headerless choice + * applies without the user re-picking. Optional for back-compat + * with mappings saved before this field existed. */ + parseOptions: csvParseOptionsPersistedSchema.optional(), }); export type CsvMapping = z.infer; +/** Whether a saved CsvMapping still lines up with a freshly parsed + * set of headers, so the caller can decide if the mapping can be + * reused silently or needs user review. In headerless mode + * (`parseOptions.hasHeaderRow === false`) column count is what + * matters — synthetic `Column N` names line up trivially when + * counts match. In normal header-row mode the comparison is + * order-independent on the set of header names so reordered Excel + * exports stay compatible. */ +export function isMappingCompatibleWith( + mapping: CsvMapping, + headers: readonly string[], +): boolean { + const headerless = mapping.parseOptions?.hasHeaderRow === false; + if (headerless) return mapping.headerSnapshot.length === headers.length; + if (mapping.headerSnapshot.length !== headers.length) return false; + const known = new Set(mapping.headerSnapshot); + for (const h of headers) if (!known.has(h)) return false; + return true; +} + /** Loose header-name comparison for auto-suggesting CSV → Variable * matches at import time. Case-insensitive; spaces, dashes and * underscores collapse so `"Product Code"`, `"product_code"` and From 1bafb4c601d6d4b68d9497d5b44bcd61c38419e4 Mon Sep 17 00:00:00 2001 From: u8array Date: Sat, 23 May 2026 12:10:47 +0200 Subject: [PATCH 4/7] feat(variables-csv): Phase 2d - batch ZPL export + Labelary row-aware preview MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three CSV-aware emit paths now line up with three distinct outputs: 1. Direct print (Send to Zebra) - sends template + ^XFR per-row merge blocks via the idiomatic ZPL data-merge protocol (^DFR:LBL.ZPL once, then N small ^XA^XFR:LBL.ZPL^FN…^FD…^XZ blocks). Volatile R: storage so the stored form drops on power-cycle, which matches a single-run batch. 2. File menu "Export batch ZPL (N labels)" - same batch output, downloaded as label-batch.zpl. Only surfaces when a CSV with at least one mapped variable is loaded. 3. Labelary preview (header Print button + canvas preview overlay) now substitutes the active CSV row (or defaults when no row) so the rendered image shows what would print for the selected row instead of empty ^FN slots. Emits flat ZPL (no ^FN) by pre- applying bindings to the object tree. The existing "Export ZPL" (template-form, round-trip-able) is unchanged. ZPL output panel stays template form too - that's the source-of-truth for editor round-trip. Pure helper `applyBindingToTree` recurses into groups so the substitution covers grouped fields, not just top-level leaves. `generateBatchZpl` takes narrow object shapes so it stays lib-pure (no store dependency). Five tests for generateBatchZpl cover template-stored-once, per-row recall blocks, unmapped/orphan skip, empty-cell payload, and zero-row template-only output. Four tests for applyBindingToTree cover leaves, group recursion, CSV row substitution, and schema-mode «name» replacement. --- src/components/AppShell.tsx | 12 +++++ src/hooks/useZplImportExport.ts | 40 ++++++++++++-- src/lib/printPreview.ts | 11 +++- src/lib/variableBinding.test.ts | 55 +++++++++++++++++++ src/lib/variableBinding.ts | 22 ++++++++ src/lib/zplGenerator.test.ts | 93 ++++++++++++++++++++++++++++++++- src/lib/zplGenerator.ts | 68 ++++++++++++++++++++++++ src/store/labelStore.ts | 9 +++- 8 files changed, 303 insertions(+), 7 deletions(-) diff --git a/src/components/AppShell.tsx b/src/components/AppShell.tsx index 6ab3fb20..27af2b71 100644 --- a/src/components/AppShell.tsx +++ b/src/components/AppShell.tsx @@ -101,6 +101,9 @@ export function AppShell() { printError, dismissPrintError, handleDownload, + handleExportBatch, + canBatchExport, + batchRowCount, handlePrint, } = useZplImportExport(); const outputPanel = useOutputPanel(OUTPUT_DEFAULT_H); @@ -209,6 +212,15 @@ export function AppShell() { > {t.app.exportZpl} + {canBatchExport && ( + /* i18n: locale key gets added in the end-of-branch sweep. */ + + Export batch ZPL ({batchRowCount} labels) + + )} 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/printPreview.ts b/src/lib/printPreview.ts index 2a439a63..d5621c0f 100644 --- a/src/lib/printPreview.ts +++ b/src/lib/printPreview.ts @@ -3,6 +3,7 @@ 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"; export function buildLoadingHtml(): string { return `