From ee7ebb2c74999647ef78f7f8fe9c12468011f853 Mon Sep 17 00:00:00 2001 From: u8array Date: Mon, 27 Jul 2026 21:45:23 +0200 Subject: [PATCH] feat(data): guided connect-data wizard CSV/Excel/SQLite/MySQL/Postgres sources behind one flow with transactional abort; replace confirm for fetched sources; wizard as primary entry point. --- src/components/AppShell.tsx | 53 +-- .../PrinterSettings/DataSourcesTab.tsx | 99 ++---- .../ConnectDataWizard.excel.test.tsx | 61 ++++ .../ConnectDataWizard.server.test.tsx | 235 ++++++++++++++ .../ConnectDataWizard.sqlite.test.tsx | 100 ++++++ .../Variables/ConnectDataWizard.test.tsx | 188 +++++++++++ .../Variables/ConnectDataWizard.tsx | 301 ++++++++++++++++++ src/components/Variables/ServerDbStep.tsx | 110 +++++++ ...xcelSheetModal.tsx => SourcePickModal.tsx} | 43 +-- src/components/Variables/VariablesPanel.tsx | 19 +- src/components/Variables/useWizardArm.ts | 28 ++ .../Variables/wizardTestFixtures.ts | 48 +++ src/components/db/NetworkDbFields.tsx | 92 ++++++ src/hooks/useCsvImportActions.ts | 12 +- src/hooks/useServerDbConnectActions.ts | 127 ++++++++ src/hooks/useSqliteConnectActions.ts | 111 +++++++ src/lib/db.ts | 6 + src/lib/menuModel.test.ts | 15 +- src/lib/menuModel.ts | 14 +- src/lib/menuSignature.test.ts | 2 +- src/locales/ar.ts | 16 +- src/locales/bg.ts | 16 +- src/locales/cs.ts | 16 +- src/locales/da.ts | 16 +- src/locales/de.ts | 16 +- src/locales/el.ts | 16 +- src/locales/en.ts | 16 +- src/locales/es.ts | 16 +- src/locales/et.ts | 16 +- src/locales/fa.ts | 16 +- src/locales/fi.ts | 16 +- src/locales/fr.ts | 16 +- src/locales/he.ts | 16 +- src/locales/hr.ts | 16 +- src/locales/hu.ts | 16 +- src/locales/it.ts | 16 +- src/locales/ja.ts | 16 +- src/locales/ko.ts | 16 +- src/locales/lt.ts | 16 +- src/locales/lv.ts | 16 +- src/locales/nl.ts | 16 +- src/locales/no.ts | 16 +- src/locales/pl.ts | 16 +- src/locales/pt.ts | 16 +- src/locales/ro.ts | 16 +- src/locales/sk.ts | 16 +- src/locales/sl.ts | 16 +- src/locales/sr.ts | 16 +- src/locales/sv.ts | 16 +- src/locales/tr.ts | 16 +- src/locales/zh-hans.ts | 16 +- src/locales/zh-hant.ts | 16 +- src/store/datasetActions.replace.test.ts | 84 +++++ src/store/datasetActions.ts | 41 ++- src/store/slices/dataSlice.ts | 38 +++ src/store/slices/labelConfigSlice.ts | 1 + 56 files changed, 2179 insertions(+), 161 deletions(-) create mode 100644 src/components/Variables/ConnectDataWizard.excel.test.tsx create mode 100644 src/components/Variables/ConnectDataWizard.server.test.tsx create mode 100644 src/components/Variables/ConnectDataWizard.sqlite.test.tsx create mode 100644 src/components/Variables/ConnectDataWizard.test.tsx create mode 100644 src/components/Variables/ConnectDataWizard.tsx create mode 100644 src/components/Variables/ServerDbStep.tsx rename src/components/Variables/{ExcelSheetModal.tsx => SourcePickModal.tsx} (67%) create mode 100644 src/components/Variables/useWizardArm.ts create mode 100644 src/components/Variables/wizardTestFixtures.ts create mode 100644 src/components/db/NetworkDbFields.tsx create mode 100644 src/hooks/useServerDbConnectActions.ts create mode 100644 src/hooks/useSqliteConnectActions.ts create mode 100644 src/store/datasetActions.replace.test.ts diff --git a/src/components/AppShell.tsx b/src/components/AppShell.tsx index 77e14380..da23ba77 100644 --- a/src/components/AppShell.tsx +++ b/src/components/AppShell.tsx @@ -11,8 +11,8 @@ import { HistoryDropdown } from "./History/HistoryDropdown"; import { ZPLOutput } from "./Output/ZPLOutput"; import { ZplImportModal } from "./Output/ZplImportModal"; import { VariableMappingModal } from "./Variables/VariableMappingModal"; +import { ConnectDataWizard } from "./Variables/ConnectDataWizard"; import { CsvImportConfirmDialog } from "./Variables/CsvImportConfirmDialog"; -import { ExcelSheetModal } from "./Variables/ExcelSheetModal"; import { PrintToZebraDialog } from "./Output/PrintToZebraDialog"; import { DropdownMenu, @@ -43,7 +43,8 @@ import { } from "@heroicons/react/16/solid"; import { useLabelStore, useHistory, selectLabelaryNoticeRequired, selectPreviewLocksEditor, selectBatchPrintCount } from "../store/labelStore"; import { datasetTimestamp } from "@zplab/core/types/DataSource"; -import { isCurrentDataContext } from "../store/datasetActions"; +import { isCurrentDataContext, settleDatasetReplace } from "../store/datasetActions"; +import { ConfirmDialog } from "./ui/ConfirmDialog"; import { formatTemplate } from "../lib/formatTemplate"; import { buildMenuModel, type MenuItemId } from "../lib/menuModel"; import { NativeMenuBridge } from "./NativeMenuBridge"; @@ -63,7 +64,6 @@ import { useGlobalShortcuts } from "../hooks/useGlobalShortcuts"; import { useDesignFileActions } from "../hooks/useDesignFileActions"; import { useMcpBridge } from "../hooks/useMcpBridge"; import { useCsvImportActions } from "../hooks/useCsvImportActions"; -import { useExcelImportActions } from "../hooks/useExcelImportActions"; import { useZplImportExport } from "../hooks/useZplImportExport"; import { useOutputPanel, OUTPUT_DEFAULT_H } from "../hooks/useOutputPanel"; import { useCollapsiblePanel } from "../hooks/useCollapsiblePanel"; @@ -107,7 +107,7 @@ const MENU_ICONS: Partial s.mappingModalOpen); + const connectWizardOpen = useLabelStore((s) => s.connectWizardOpen); + const openConnectWizard = useLabelStore((s) => s.openConnectWizard); + const pendingDatasetReplace = useLabelStore((s) => s.pendingDatasetReplace); + const datasetFetchToken = useLabelStore((s) => s.datasetFetchToken); + // A context bump while the replace confirm is open (doc swap, foreign load) + // supersedes it; drop actively so the deferred fetch promise settles instead + // of leaking behind the hidden dialog. + useEffect(() => { + if (pendingDatasetReplace && pendingDatasetReplace.token !== datasetFetchToken) { + settleDatasetReplace(false); + } + }, [pendingDatasetReplace, datasetFetchToken]); const closeMappingModal = useLabelStore((s) => s.closeMappingModal); // Remount the mapping modal when the dataset changes (e.g. importing from its // own empty state) so its open-time draft re-seeds with the new data. The @@ -212,7 +222,7 @@ export function AppShell() { canBatchExport, batchRowCount, batchPrintCount, - includeExcelImport: isDesktopShell, + connectDataWizard: isDesktopShell, labelaryEnabled, canUndo, canRedo, @@ -229,7 +239,7 @@ export function AppShell() { openDesign: handleOpen, saveDesign: handleSave, importCsv: openCsvPicker, - importExcel: openExcelPicker, + connectData: openConnectWizard, // Print routes through Labelary; clicking before the notice has been // acknowledged opens the disclosure first, then prints. print: () => (noticeRequired ? setShowPrintNotice(true) : void handlePrint()), @@ -520,25 +530,16 @@ export function AppShell() { {showZplImport && } - {mappingModalOpen && ( + {mappingModalOpen && !connectWizardOpen && ( )} + {connectWizardOpen && } {/* Token gate: a doc/context swap leaves the local pending state stale, so hide the dialog rather than let it hover over the new document. */} - {pendingExcel && isCurrentDataContext(pendingExcel.token) && ( - - )} {pendingImport && isCurrentDataContext(pendingImport.token) && ( )} + {/* Replace confirm for fetched sources (excel/db); same token gate as the + sibling pending dialogs, and settleDatasetReplace re-checks on click. */} + {pendingDatasetReplace && isCurrentDataContext(pendingDatasetReplace.token) && ( + settleDatasetReplace(true)} + onCancel={() => settleDatasetReplace(false)} + /> + )} {showZebraPrint && ( )} diff --git a/src/components/PrinterSettings/DataSourcesTab.tsx b/src/components/PrinterSettings/DataSourcesTab.tsx index e1104d8d..bbd7bcb4 100644 --- a/src/components/PrinterSettings/DataSourcesTab.tsx +++ b/src/components/PrinterSettings/DataSourcesTab.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from 'react'; -import { CircleStackIcon, PlusIcon, TrashIcon } from '@heroicons/react/16/solid'; +import { CircleStackIcon, PlusIcon, TableCellsIcon, TrashIcon } from '@heroicons/react/16/solid'; import { useLabelStore } from '../../store/labelStore'; import { useT } from '../../hooks/useT'; import { useDbConnectActions } from '../../hooks/useDbConnectActions'; @@ -8,6 +8,7 @@ import { dbPasswordCred, dbSetPassword, enqueueCredWrite, + grantedSqlitePaths, pickSqliteFile, revokeSqlitePath, } from '../../lib/db'; @@ -16,7 +17,8 @@ import { formatTemplate } from '../../lib/formatTemplate'; import { ConfirmDialog } from '../ui/ConfirmDialog'; import { Select } from '../ui/Select'; import { inputCls } from '../Properties/styles'; -import type { DbProfile, DbSslMode } from '../../lib/db'; +import { NetworkDbFields } from '../db/NetworkDbFields'; +import type { DbProfile } from '../../lib/db'; import { newId } from "@zplab/core/lib/ids"; type Driver = DbProfile['driver']; @@ -33,6 +35,7 @@ export function DataSourcesTab() { const removeDbProfile = useLabelStore((s) => s.removeDbProfile); const dataSourceRef = useLabelStore((s) => s.dataSourceRef); const setPrinterSettingsTab = useLabelStore((s) => s.setPrinterSettingsTab); + const openConnectWizard = useLabelStore((s) => s.openConnectWizard); const { loadFromDb } = useDbConnectActions(); const [selectedId, setSelectedId] = useState(() => { @@ -119,10 +122,7 @@ export function DataSourcesTab() { // Call AFTER the profile mutation so the keep list reflects the new state // (revokeSqlitePath documents the shared-grant semantics). const revokeOrphanedGrant = (path: string) => { - const keep = useLabelStore - .getState() - .dbProfiles.flatMap((p) => (p.driver === 'sqlite' && p.path ? [p.path] : [])); - void revokeSqlitePath(path, keep); + void revokeSqlitePath(path, grantedSqlitePaths(useLabelStore.getState().dbProfiles)); }; const handleDriverChange = (driver: Driver) => { @@ -220,6 +220,18 @@ export function DataSourcesTab() { return (
+ {/* Secondary entry into the guided flow; the wizard replaces the modal. */} + +
@@ -308,51 +320,17 @@ export function DataSourcesTab() {
) : ( - <> -
-
- - updateDbProfile({ ...profile, host: e.target.value })} - /> -
-
- - { - const n = parseInt(e.target.value, 10); - updateDbProfile({ - ...profile, - // Rust deserializes into u16; clamp instead of an - // opaque serde error at fetch time. - port: Number.isNaN(n) ? undefined : Math.min(65535, Math.max(1, n)), - }); - }} - /> -
-
-
- - updateDbProfile({ ...profile, database: e.target.value })} - /> -
-
- - updateDbProfile({ ...profile, user: e.target.value })} - /> -
+ updateDbProfile({ ...profile, ...patch })} + >
- -
- - - value={profile.sslMode ?? 'prefer'} - onChange={(sslMode) => updateDbProfile({ ...profile, sslMode })} - groups={[ - { - options: (['prefer', 'require', 'verify-full', 'disable'] as const).map( - (m) => ({ value: m, label: m }), - ), - }, - ]} - /> -
- +
)}
{tablesError ? ( -

+

{formatTemplate(tv.dbTablesErrorFmt, { error: tablesError })}

) : ( diff --git a/src/components/Variables/ConnectDataWizard.excel.test.tsx b/src/components/Variables/ConnectDataWizard.excel.test.tsx new file mode 100644 index 00000000..67337448 --- /dev/null +++ b/src/components/Variables/ConnectDataWizard.excel.test.tsx @@ -0,0 +1,61 @@ +// @vitest-environment jsdom +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { render, cleanup, fireEvent, act, waitFor } from '@testing-library/react'; +import { useLabelStore } from '../../store/labelStore'; +import { fallbackTranslations as en } from '../../locales'; +import { EXCEL_DATASET } from './wizardTestFixtures'; +import type * as platform from '../../lib/platform'; + +vi.mock('../../lib/platform', async (orig) => ({ + ...(await orig()), + isDesktopShell: true, +})); +vi.mock('../../lib/excel', () => ({ + pickExcelFile: vi.fn(async () => '/data/f.xlsx'), + excelListSheets: vi.fn(async () => ['Sheet1', 'Sheet2']), + excelFetchDataset: vi.fn(async (_path: string, filename: string, sheet: string) => ({ + headers: ['sku', 'price'], + rows: [['A1', '9.99']], + source: { kind: 'excel', filename, sheet, importedAt: '2026-01-01T00:00:00Z', rowCount: 1, truncated: false }, + })), +})); + +const { ConnectDataWizard } = await import('./ConnectDataWizard'); + + +afterEach(cleanup); + +describe('ConnectDataWizard Excel (desktop)', () => { + it('advances to mapping after picking a worksheet', async () => { + act(() => { + useLabelStore.setState({ dataset: null, variables: [], columnMapping: null } as never); + }); + const { getByText, findByText } = render(); + await act(async () => { + fireEvent.click(getByText(en.connectData.excel)); + }); + await findByText(en.variables.excelSheetLabel); + await act(async () => { + fireEvent.click(getByText(en.variables.dbLoad)); + }); + await findByText(en.connectData.finish); + }); + + it('cancelling the sheet modal keeps the old dataset and stays on source despite the token bump', async () => { + // cancelExcelImport bumps the token (invalidateDatasetFetches); the bump + // must not read as a wizard load against this pre-existing dataset. + act(() => { + useLabelStore.setState({ dataset: EXCEL_DATASET, variables: [], columnMapping: null } as never); + }); + const { getByText, queryByText } = render(); + await act(async () => { + fireEvent.click(getByText(en.connectData.excel)); + }); + await waitFor(() => getByText(en.variables.excelSheetLabel)); + await act(async () => { + fireEvent.click(getByText(en.variables.cancel)); + }); + getByText(en.connectData.chooseSource); + expect(queryByText(en.connectData.finish)).toBeNull(); + }); +}); diff --git a/src/components/Variables/ConnectDataWizard.server.test.tsx b/src/components/Variables/ConnectDataWizard.server.test.tsx new file mode 100644 index 00000000..65100f94 --- /dev/null +++ b/src/components/Variables/ConnectDataWizard.server.test.tsx @@ -0,0 +1,235 @@ +// @vitest-environment jsdom +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { render, cleanup, fireEvent, act, waitFor } from '@testing-library/react'; +import { useLabelStore } from '../../store/labelStore'; +import { fallbackTranslations as en } from '../../locales'; +import { dbFetched } from './wizardTestFixtures'; +import type * as platform from '../../lib/platform'; +import type * as db from '../../lib/db'; +import type * as credentialStore from '../../lib/credentialStore'; + +vi.mock('../../lib/platform', async (orig) => ({ + ...(await orig()), + isDesktopShell: true, +})); +const { dbSetPassword, deleteCredential } = vi.hoisted(() => ({ + dbSetPassword: vi.fn().mockResolvedValue(undefined), + deleteCredential: vi.fn().mockResolvedValue(undefined), +})); +vi.mock('../../lib/credentialStore', async (orig) => ({ + ...(await orig()), + deleteCredential, +})); +vi.mock('../../lib/db', async (orig) => ({ + ...(await orig()), + dbSetPassword, + dbListTables: vi.fn(async () => ['orders', 'items']), + dbFetchDataset: vi.fn(async (profile: db.DbProfile, table: string) => dbFetched(profile, table)), +})); + +const { ConnectDataWizard } = await import('./ConnectDataWizard'); + +afterEach(() => { + cleanup(); + dbSetPassword.mockClear(); + deleteCredential.mockClear(); +}); + +const openServerForm = async (label: string) => { + act(() => { + useLabelStore.setState({ dataset: null, variables: [], columnMapping: null, dbProfiles: [] } as never); + }); + const view = render(); + act(() => { + fireEvent.click(view.getByText(label)); + }); + // Field order in the form: host, port, database, user, password. + const fields = view.container.querySelectorAll('.fixed input:not([type="file"])'); + fireEvent.change(fields.item(0), { target: { value: 'db.local' } }); + fireEvent.change(fields.item(2), { target: { value: 'shop' } }); + fireEvent.change(fields.item(3), { target: { value: 'reader' } }); + fireEvent.change(fields.item(4), { target: { value: 'sekret' } }); + return view; +}; + +describe('ConnectDataWizard server DB (desktop)', () => { + it('connects, lists tables, loads, and persists the profile', async () => { + const { getByText, findByText } = await openServerForm(en.connectData.mysql); + await act(async () => { + fireEvent.click(getByText(en.connectData.connect)); + }); + await findByText(en.variables.dbTableLabel); + await act(async () => { + fireEvent.click(getByText(en.variables.dbLoad)); + }); + await findByText(en.connectData.finish); + // Password stored endpoint-bound before the listing connect. + expect(dbSetPassword).toHaveBeenCalledWith( + expect.objectContaining({ driver: 'mysql', host: 'db.local', database: 'shop', user: 'reader' }), + 'sekret', + ); + expect(useLabelStore.getState().dbProfiles).toMatchObject([ + { driver: 'mysql', host: 'db.local', sslMode: 'prefer' }, + ]); + expect(deleteCredential).not.toHaveBeenCalled(); + }); + + it('shows the listing error and stays on the form', async () => { + const { dbListTables } = await import('../../lib/db'); + vi.mocked(dbListTables).mockRejectedValueOnce(new Error('auth failed')); + const { getByText, findByText, queryByText } = await openServerForm(en.connectData.postgres); + await act(async () => { + fireEvent.click(getByText(en.connectData.connect)); + }); + await findByText(/auth failed/); + expect(queryByText(en.variables.dbTableLabel)).toBeNull(); + expect(useLabelStore.getState().dbProfiles).toHaveLength(0); + }); + + it('closing the wizard during a failing load still deletes the stored password', async () => { + const { dbFetchDataset } = await import('../../lib/db'); + let rejectFetch!: (e: Error) => void; + vi.mocked(dbFetchDataset).mockImplementationOnce( + () => new Promise((_res, rej) => (rejectFetch = rej)), + ); + const view = await openServerForm(en.connectData.mysql); + await act(async () => { + fireEvent.click(view.getByText(en.connectData.connect)); + }); + await view.findByText(en.variables.dbTableLabel); + await act(async () => { + fireEvent.click(view.getByText(en.variables.dbLoad)); + }); + view.unmount(); + await act(async () => { + rejectFetch(new Error('timeout')); + }); + // The cleanup chains on the load outcome: failure -> orphan deleted. + await waitFor(() => expect(deleteCredential).toHaveBeenCalled()); + expect(useLabelStore.getState().dbProfiles).toHaveLength(0); + }); + + it('closing the wizard during a successful load commits nothing and deletes the password', async () => { + const { dbFetchDataset } = await import('../../lib/db'); + let resolveFetch!: (v: unknown) => void; + vi.mocked(dbFetchDataset).mockImplementationOnce( + () => new Promise((res) => (resolveFetch = res as (v: unknown) => void)), + ); + const view = await openServerForm(en.connectData.mysql); + await act(async () => { + fireEvent.click(view.getByText(en.connectData.connect)); + }); + await view.findByText(en.variables.dbTableLabel); + await act(async () => { + fireEvent.click(view.getByText(en.variables.dbLoad)); + }); + view.unmount(); // close supersedes the fetch (token bump) + await act(async () => { + resolveFetch({ + headers: ['sku'], + rows: [['A1']], + source: { + kind: 'db', + profileId: 'x', + profileName: 'x', + table: 'orders', + fetchedAt: '2026-01-01T00:00:00Z', + rowCount: 1, + truncated: false, + }, + }); + }); + // Superseded: no dataset commit, no profile persist, password cleaned up. + expect(useLabelStore.getState().dataset).toBeNull(); + expect(useLabelStore.getState().dbProfiles).toHaveLength(0); + await waitFor(() => expect(deleteCredential).toHaveBeenCalled()); + }); + + it('closing the wizard while the connect is still storing the password cleans it up', async () => { + let resolveWrite!: () => void; + dbSetPassword.mockImplementationOnce( + () => new Promise((res) => (resolveWrite = res)), + ); + const view = await openServerForm(en.connectData.postgres); + await act(async () => { + fireEvent.click(view.getByText(en.connectData.connect)); + }); + view.unmount(); + await act(async () => { + resolveWrite(); + }); + // The cleanup chains on the connect too, so the just-stored password + // is deleted even though the close raced the keychain write. + await waitFor(() => expect(deleteCredential).toHaveBeenCalled()); + expect(useLabelStore.getState().dbProfiles).toHaveLength(0); + }); + + it('persists the profile that produced the dataset, not an in-flight edit', async () => { + const { dbFetchDataset } = await import('../../lib/db'); + let resolveFetch!: (v: unknown) => void; + const original = vi.mocked(dbFetchDataset).getMockImplementation(); + vi.mocked(dbFetchDataset).mockImplementationOnce( + (profile, table) => + new Promise((res) => { + resolveFetch = () => res(original!(profile, table)); + }), + ); + const view = await openServerForm(en.connectData.mysql); + await act(async () => { + fireEvent.click(view.getByText(en.connectData.connect)); + }); + await view.findByText(en.variables.dbTableLabel); + await act(async () => { + fireEvent.click(view.getByText(en.variables.dbLoad)); + }); + const fields = view.container.querySelectorAll( + '.fixed input:not([type="file"])', + ); + act(() => { + fireEvent.change(fields.item(0), { target: { value: 'other.host' } }); + }); + await act(async () => { + resolveFetch(undefined); + }); + await view.findByText(en.connectData.finish); + // The saved profile matches the fetch snapshot, not the edited draft. + expect(useLabelStore.getState().dbProfiles).toMatchObject([{ host: 'db.local' }]); + }); + + it('does not publish a table listing for an endpoint edited mid-connect', async () => { + const { dbListTables } = await import('../../lib/db'); + let resolveList!: (v: string[]) => void; + vi.mocked(dbListTables).mockImplementationOnce( + () => new Promise((res) => (resolveList = res)), + ); + const view = await openServerForm(en.connectData.mysql); + await act(async () => { + fireEvent.click(view.getByText(en.connectData.connect)); + }); + const fields = view.container.querySelectorAll( + '.fixed input:not([type="file"])', + ); + act(() => { + fireEvent.change(fields.item(0), { target: { value: 'other.host' } }); + }); + await act(async () => { + resolveList(['stale-table']); + }); + // Stale response must not pair connection A's tables with B's fields. + expect(view.queryByText(en.variables.dbTableLabel)).toBeNull(); + }); + + it('backing out after a connect deletes the stored password, saves no profile', async () => { + const { getByText, findByText } = await openServerForm(en.connectData.postgres); + await act(async () => { + fireEvent.click(getByText(en.connectData.connect)); + }); + await findByText(en.variables.dbTableLabel); + await act(async () => { + fireEvent.click(getByText(en.variables.cancel)); + }); + getByText(en.connectData.chooseSource); + await waitFor(() => expect(deleteCredential).toHaveBeenCalled()); + expect(useLabelStore.getState().dbProfiles).toHaveLength(0); + }); +}); diff --git a/src/components/Variables/ConnectDataWizard.sqlite.test.tsx b/src/components/Variables/ConnectDataWizard.sqlite.test.tsx new file mode 100644 index 00000000..0590b37e --- /dev/null +++ b/src/components/Variables/ConnectDataWizard.sqlite.test.tsx @@ -0,0 +1,100 @@ +// @vitest-environment jsdom +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { render, cleanup, fireEvent, act, waitFor } from '@testing-library/react'; +import { useLabelStore } from '../../store/labelStore'; +import { fallbackTranslations as en } from '../../locales'; +import { EXCEL_DATASET, dbFetched } from './wizardTestFixtures'; +import type * as platform from '../../lib/platform'; +import type * as db from '../../lib/db'; + +vi.mock('../../lib/platform', async (orig) => ({ + ...(await orig()), + isDesktopShell: true, +})); +const revokeSqlitePath = vi.fn().mockResolvedValue(undefined); +vi.mock('../../lib/db', async (orig) => ({ + ...(await orig()), + pickSqliteFile: vi.fn(async () => '/data/store.sqlite'), + dbListTables: vi.fn(async () => ['items', 'prices']), + revokeSqlitePath, + dbFetchDataset: vi.fn(async (profile: db.DbProfile, table: string) => dbFetched(profile, table)), +})); + +const { ConnectDataWizard } = await import('./ConnectDataWizard'); + +afterEach(() => { + cleanup(); + revokeSqlitePath.mockClear(); +}); + +describe('ConnectDataWizard SQLite (desktop)', () => { + it('loads a table, persists the profile, and advances to mapping', async () => { + act(() => { + useLabelStore.setState({ dataset: null, variables: [], columnMapping: null, dbProfiles: [] } as never); + }); + const { getByText, findByText } = render(); + await act(async () => { + fireEvent.click(getByText(en.connectData.sqlite)); + }); + await findByText(en.variables.dbTableLabel); + await act(async () => { + fireEvent.click(getByText(en.variables.dbLoad)); + }); + await findByText(en.connectData.finish); + // Profile persisted only after a successful load, so reconnect works later. + expect(useLabelStore.getState().dbProfiles).toHaveLength(1); + }); + + it('revokes the path grant when listing tables fails, leaving no orphan', async () => { + const { dbListTables } = await import('../../lib/db'); + vi.mocked(dbListTables).mockRejectedValueOnce(new Error('not a sqlite db')); + act(() => { + useLabelStore.setState({ dataset: null, variables: [], columnMapping: null, dbProfiles: [] } as never); + }); + const { getByText, queryByText } = render(); + await act(async () => { + fireEvent.click(getByText(en.connectData.sqlite)); + }); + await waitFor(() => + expect(revokeSqlitePath).toHaveBeenCalledWith('/data/store.sqlite', expect.any(Array)), + ); + expect(queryByText(en.variables.dbTableLabel)).toBeNull(); + expect(useLabelStore.getState().dbProfiles).toHaveLength(0); + }); + + it('closing the wizard with the table modal still open revokes the pending grant', async () => { + act(() => { + useLabelStore.setState({ dataset: null, variables: [], columnMapping: null, dbProfiles: [] } as never); + }); + const view = render(); + await act(async () => { + fireEvent.click(view.getByText(en.connectData.sqlite)); + }); + await waitFor(() => view.getByText(en.variables.dbTableLabel)); + act(() => { + view.unmount(); + }); + await waitFor(() => + expect(revokeSqlitePath).toHaveBeenCalledWith('/data/store.sqlite', expect.any(Array)), + ); + expect(useLabelStore.getState().dbProfiles).toHaveLength(0); + }); + + it('cancelling the table modal saves no profile, revokes the grant, and stays on source', async () => { + act(() => { + useLabelStore.setState({ dataset: EXCEL_DATASET, variables: [], columnMapping: null, dbProfiles: [] } as never); + }); + const { getByText, queryByText } = render(); + await act(async () => { + fireEvent.click(getByText(en.connectData.sqlite)); + }); + await waitFor(() => getByText(en.variables.dbTableLabel)); + await act(async () => { + fireEvent.click(getByText(en.variables.cancel)); + }); + getByText(en.connectData.chooseSource); + expect(queryByText(en.connectData.finish)).toBeNull(); + expect(useLabelStore.getState().dbProfiles).toHaveLength(0); + expect(revokeSqlitePath).toHaveBeenCalledWith('/data/store.sqlite', expect.any(Array)); + }); +}); diff --git a/src/components/Variables/ConnectDataWizard.test.tsx b/src/components/Variables/ConnectDataWizard.test.tsx new file mode 100644 index 00000000..bf748a77 --- /dev/null +++ b/src/components/Variables/ConnectDataWizard.test.tsx @@ -0,0 +1,188 @@ +// @vitest-environment jsdom +import { describe, it, expect, afterEach } from 'vitest'; +import { render, cleanup, fireEvent, act, type RenderResult } from '@testing-library/react'; +import { ConnectDataWizard } from './ConnectDataWizard'; +import { useLabelStore } from '../../store/labelStore'; +import { fallbackTranslations as en } from '../../locales'; +import { DB_DATASET, EXCEL_DATASET } from './wizardTestFixtures'; + +afterEach(cleanup); + +/** Land a CSV through the wizard's own hidden input (the real commit path). */ +const loadCsv = async (view: RenderResult, name = 'new.csv') => { + const input = view.container.querySelector('input[type="file"]') as HTMLInputElement; + const file = new File(['sku,price\nA1,9.99\n'], name, { type: 'text/csv' }); + await act(async () => { + fireEvent.change(input, { target: { files: [file] } }); + }); +}; + +const setStore = (extra: Record = {}) => { + act(() => { + useLabelStore.setState({ + dataset: null, + dataSourceRef: null, + columnMapping: null, + variables: [], + ...extra, + } as never); + }); +}; + +describe('ConnectDataWizard', () => { + it('starts on the source step with CSV enabled and the DB sources disabled', () => { + setStore(); + const { getByText } = render(); + getByText(en.connectData.chooseSource); + expect(getByText(en.connectData.csv).closest('button')?.disabled).toBe(false); + expect(getByText(en.connectData.postgres).closest('button')?.disabled).toBe(true); + }); + + it('stays on source when a dataset exists but no source was picked this session', () => { + setStore({ dataset: DB_DATASET }); + const { getByText, queryByText } = render(); + // A pre-existing dataset must not skip the picker into a silent remap. + getByText(en.connectData.chooseSource); + expect(queryByText(en.connectData.finish)).toBeNull(); + }); + + it('advances to mapping once its own CSV lands', async () => { + setStore(); + const view = render(); + act(() => { + fireEvent.click(view.getByText(en.connectData.csv)); + }); + expect(view.queryByText(en.connectData.finish)).toBeNull(); + await loadCsv(view); + view.getByText(en.connectData.finish); + view.getByText(en.variables.csvVariableHeader); + }); + + it('never advances on a foreign load, even after picking a source', () => { + setStore(); + const { getByText, queryByText } = render(); + act(() => { + fireEvent.click(getByText(en.connectData.csv)); + }); + // A foreign load (e.g. MCP push) while the picker is open is not the + // wizard's load: it must not open a mapping step for foreign data. + act(() => { + useLabelStore.getState().loadDataset(DB_DATASET); + }); + getByText(en.connectData.chooseSource); + expect(queryByText(en.connectData.finish)).toBeNull(); + }); + + it('back from mapping restores the pre-wizard state (fresh case: empty)', async () => { + setStore(); + const view = render(); + await loadCsv(view); + view.getByText(en.connectData.finish); + act(() => { + fireEvent.click(view.getByText(en.variables.cancel)); + }); + expect(useLabelStore.getState().dataset).toBeNull(); + view.getByText(en.connectData.chooseSource); + }); + + it('back from mapping after a replace restores the previous dataset, mapping and link', async () => { + const oldMapping = { bindings: {}, headerSnapshot: ['old'] }; + setStore({ dataset: EXCEL_DATASET, columnMapping: oldMapping }); + const view = render(); + await loadCsv(view); + await view.findByText(en.variables.csvReplaceCsvTitle); + await act(async () => { + fireEvent.click(view.getByText(en.variables.csvKeepAndRemap)); + }); + view.getByText(en.connectData.finish); + act(() => { + fireEvent.click(view.getByText(en.variables.cancel)); // back = abort the load + }); + const s = useLabelStore.getState(); + expect(s.dataset).toBe(EXCEL_DATASET); + expect(s.columnMapping).toBe(oldMapping); + expect(s.dataSourceRef).toBeNull(); + }); + + it('closing the wizard mid-mapping also restores the replaced dataset', async () => { + setStore({ dataset: EXCEL_DATASET }); + const view = render(); + await loadCsv(view); + await view.findByText(en.variables.csvReplaceCsvTitle); + await act(async () => { + fireEvent.click(view.getByText(en.variables.csvKeepAndRemap)); + }); + view.getByText(en.connectData.finish); + act(() => { + view.unmount(); + }); + expect(useLabelStore.getState().dataset).toBe(EXCEL_DATASET); + }); + + it('does not restore over a foreign load that superseded the wizard load', async () => { + setStore(); + const view = render(); + await loadCsv(view); + view.getByText(en.connectData.finish); + const foreign = { ...DB_DATASET, headers: ['foreign'], rows: [['f']] }; + act(() => { + useLabelStore.getState().loadDataset(foreign); + }); + act(() => { + view.unmount(); + }); + // The abort must keep the newer foreign state, not clobber it. + expect(useLabelStore.getState().dataset?.headers).toEqual(['foreign']); + }); + + it('a later foreign load leaves the mapping step instead of remapping foreign data', async () => { + setStore(); + const view = render(); + await loadCsv(view); + view.getByText(en.connectData.finish); + act(() => { + useLabelStore.getState().loadDataset(DB_DATASET); + }); + // Finish must not be able to apply the session to the foreign dataset. + expect(view.queryByText(en.connectData.finish)).toBeNull(); + view.getByText(en.connectData.chooseSource); + }); + + it('cancelling the CSV replace-confirm keeps the old data and never advances', async () => { + setStore({ dataset: DB_DATASET }); + const view = render(); + act(() => { + fireEvent.click(view.getByText(en.connectData.csv)); + }); + await loadCsv(view); + await view.findByText(en.variables.csvReplaceCsvTitle); + act(() => { + fireEvent.click(view.getByText(en.variables.cancel)); + }); + // Declined: a later foreign load must not advance either. + act(() => { + useLabelStore.getState().loadDataset(DB_DATASET); + }); + view.getByText(en.connectData.chooseSource); + expect(view.queryByText(en.connectData.finish)).toBeNull(); + }); + + it('loading a design closes the wizard', () => { + act(() => { + useLabelStore.getState().openConnectWizard(); + useLabelStore.getState().loadDesign({ widthMm: 50, heightMm: 30, dpmm: 8 }, []); + }); + expect(useLabelStore.getState().connectWizardOpen).toBe(false); + }); + + it('finish commits the mapping and reaches the done step', async () => { + setStore({ variables: [{ id: 'v1', name: 'sku', fnNumber: 1, defaultValue: '' }] }); + const view = render(); + await loadCsv(view); + await act(async () => { + fireEvent.click(view.getByText(en.connectData.finish)); + }); + expect(useLabelStore.getState().columnMapping?.bindings.v1).toBe('sku'); + view.getByText(en.connectData.doneTitle); + }); +}); diff --git a/src/components/Variables/ConnectDataWizard.tsx b/src/components/Variables/ConnectDataWizard.tsx new file mode 100644 index 00000000..f185124d --- /dev/null +++ b/src/components/Variables/ConnectDataWizard.tsx @@ -0,0 +1,301 @@ +import { useEffect, useRef, useState } from 'react'; +import { + ArrowLeftIcon, + CheckCircleIcon, + CircleStackIcon, + DocumentIcon, + TableCellsIcon, + XMarkIcon, +} from '@heroicons/react/16/solid'; +import { useLabelStore } from '../../store/labelStore'; +import { useT } from '../../hooks/useT'; +import { useCsvImportActions } from '../../hooks/useCsvImportActions'; +import { useExcelImportActions } from '../../hooks/useExcelImportActions'; +import { useSqliteConnectActions } from '../../hooks/useSqliteConnectActions'; +import { useDbConnectActions } from '../../hooks/useDbConnectActions'; +import { isCurrentDataContext, settleDatasetReplace } from '../../store/datasetActions'; +import { isDesktopShell } from '../../lib/platform'; +import { DialogShell } from '../ui/DialogShell'; +import { CsvImportConfirmDialog } from './CsvImportConfirmDialog'; +import { SourcePickModal } from './SourcePickModal'; +import { ServerDbStep } from './ServerDbStep'; +import { useWizardArm } from './useWizardArm'; +import { MappingEditor } from './MappingEditor'; +import { useMappingDraft } from './useMappingDraft'; + +/** Guided connect-data flow: pick a source, load it, map columns, done. A thin + * orchestrator over the existing import hooks and the mapping core; every step + * it drives stays reachable without it (DataSourcesTab, the import actions). */ +export function ConnectDataWizard() { + const t = useT(); + const tc = t.connectData; + const closeConnectWizard = useLabelStore((s) => s.closeConnectWizard); + const { loaded, ownLoadToken, markLoaded, reset, loadArmed } = useWizardArm(); + const { + csvInputRef, + handleCsvImport, + openCsvPicker, + pendingImport, + confirmPendingImport, + cancelPendingImport, + } = useCsvImportActions(markLoaded); + const { openExcelPicker, pendingExcel, loadSheet, cancelExcelImport } = useExcelImportActions(); + const { openSqlitePicker, pendingSqlite, loadTable, cancelSqliteImport } = useSqliteConnectActions(); + const { loadFromDb } = useDbConnectActions(); + const [serverDriver, setServerDriver] = useState<'postgres' | 'mysql' | null>(null); + const [done, setDone] = useState(false); + // The wizard is a transaction over the data context: only Finish commits, + // any abort restores this snapshot, guarded so a foreign load or document + // swap that became the latest mutation is never clobbered. + const [snapshot] = useState(() => { + const s = useLabelStore.getState(); + return { dataset: s.dataset, dataSourceRef: s.dataSourceRef, columnMapping: s.columnMapping }; + }); + // Mirrored for the unmount cleanup, which sees only the mount closure. + const abortRef = useRef({ ownLoadToken, done }); + useEffect(() => { + abortRef.current = { ownLoadToken, done }; + }, [ownLoadToken, done]); + const restoreOnAbort = () => { + const { ownLoadToken: token } = abortRef.current; + if (token !== null && isCurrentDataContext(token)) { + useLabelStore.getState().restoreDataSnapshot(snapshot); + } + reset(); + }; + useEffect( + () => () => { + // Closing dismisses the whole flow: drop a parked replace confirm, undo + // an uncommitted load, and supersede any fetch still in flight so it can + // neither commit a dataset nor persist a profile afterwards. + settleDatasetReplace(false); + if (!abortRef.current.done) restoreOnAbort(); + useLabelStore.getState().invalidateDatasetFetches(); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps -- unmount only + [], + ); + const step = done ? 'done' : loaded ? 'mapping' : serverDriver ? 'server' : 'source'; + + const backToSource = () => { + restoreOnAbort(); + setServerDriver(null); + }; + // Leaving the server step must also supersede a load still in flight, or a + // late success would advance and persist a flow the user already left. + const backFromServer = () => { + useLabelStore.getState().invalidateDatasetFetches(); + setServerDriver(null); + }; + + return ( + +
+ + {tc.title} + + +
+ + {/* This instance's own picker input; openCsvPicker clicks it on the web. */} + + + {step === 'source' && ( + + )} + {step === 'server' && serverDriver && ( + loadArmed(() => loadFromDb(profile, table))} + /> + )} + {step === 'mapping' && ( + setDone(true)} /> + )} + {step === 'done' && } + + {pendingImport && isCurrentDataContext(pendingImport.token) && ( + + )} + + {pendingExcel && isCurrentDataContext(pendingExcel.token) && ( + loadArmed(() => loadSheet(sheet))} + onCancel={cancelExcelImport} + /> + )} + + {pendingSqlite && isCurrentDataContext(pendingSqlite.token) && ( + loadArmed(() => loadTable(tbl))} + onCancel={cancelSqliteImport} + /> + )} +
+ ); +} + +function SourceStep({ + onPickCsv, + onPickExcel, + onPickSqlite, + onPickServer, +}: { + onPickCsv: () => void; + onPickExcel: () => void; + onPickSqlite: () => void; + onPickServer: (driver: 'postgres' | 'mysql') => void; +}) { + const tc = useT().connectData; + return ( +
+

{tc.chooseSource}

+
+ } label={tc.csv} onClick={onPickCsv} /> + } + label={tc.excel} + onClick={isDesktopShell ? onPickExcel : undefined} + /> + } + label={tc.sqlite} + onClick={isDesktopShell ? onPickSqlite : undefined} + /> + } + label={tc.mysql} + onClick={isDesktopShell ? () => onPickServer('mysql') : undefined} + /> + } + label={tc.postgres} + onClick={isDesktopShell ? () => onPickServer('postgres') : undefined} + /> +
+
+ ); +} + +/** A card without a handler renders disabled with the desktop-only note (every + * source is implemented; only the web shell lacks the Rust side). */ +function SourceCard({ + icon, + label, + onClick, +}: { + icon: React.ReactNode; + label: string; + onClick?: () => void; +}) { + const tc = useT().connectData; + return ( + + ); +} + +/** Mounted only once a dataset is loaded, so useMappingDraft clones the draft + * from that dataset (its state seeds on mount). */ +function WizardMappingStep({ onBack, onDone }: { onBack: () => void; onDone: () => void }) { + const { connectData: tc, variables: tv } = useT(); + const draft = useMappingDraft(); + + const finish = () => { + draft.apply(); + onDone(); + }; + + return ( + <> +
+ +
+
+ + +
+ + ); +} + +function DoneStep({ onClose }: { onClose: () => void }) { + const tc = useT().connectData; + return ( + <> +
+ +

{tc.doneTitle}

+

{tc.doneBody}

+
+
+ +
+ + ); +} diff --git a/src/components/Variables/ServerDbStep.tsx b/src/components/Variables/ServerDbStep.tsx new file mode 100644 index 00000000..76d2160e --- /dev/null +++ b/src/components/Variables/ServerDbStep.tsx @@ -0,0 +1,110 @@ +import { useState } from 'react'; +import { ArrowLeftIcon, CircleStackIcon } from '@heroicons/react/16/solid'; +import { useT } from '../../hooks/useT'; +import { useServerDbConnectActions } from '../../hooks/useServerDbConnectActions'; +import { formatTemplate } from '../../lib/formatTemplate'; +import { inputCls } from '../Properties/styles'; +import { Select } from '../ui/Select'; +import { NetworkDbFields } from '../db/NetworkDbFields'; +import type { NetworkDbProfile } from '../../lib/db'; + +interface Props { + driver: 'postgres' | 'mysql'; + onBack: () => void; + /** Runs the actual fetch (the wizard marks a success as its own load); + * true once the dataset applied. */ + onLoad: (profile: NetworkDbProfile, table: string) => Promise; +} + +/** Renders the guided network-DB connect: endpoint form, Connect lists tables, + * pick one, Load. All connect/persist policy lives in the hook. */ +export function ServerDbStep({ driver, onBack, onLoad }: Props) { + const t = useT(); + const tv = t.variables; + const tc = t.connectData; + const { + fields, + editFields, + passwordDraft, + editPassword, + ready, + tables, + listError, + busy, + connect, + loadTable, + } = useServerDbConnectActions(driver); + const [table, setTable] = useState(''); + // A stale selection (listing changed underneath) falls back to the first entry. + const chosen = tables !== null && tables.includes(table) ? table : tables?.[0] ?? ''; + + return ( + <> +
+ +
+ + editPassword(e.target.value)} + autoComplete="off" + /> +

{tv.dbPasswordStoredHint}

+
+
+ + {listError !== null && ( +

+ {formatTemplate(tv.dbTablesErrorFmt, { error: listError })} +

+ )} + + {tables !== null && ( +
+ + + value={chosen} + onChange={setTable} + groups={[{ options: tables.map((name) => ({ value: name, label: name })) }]} + /> +
+ )} +
+ +
+ + {tables === null ? ( + + ) : ( + + )} +
+ + ); +} diff --git a/src/components/Variables/ExcelSheetModal.tsx b/src/components/Variables/SourcePickModal.tsx similarity index 67% rename from src/components/Variables/ExcelSheetModal.tsx rename to src/components/Variables/SourcePickModal.tsx index 40d96ac1..c4c28082 100644 --- a/src/components/Variables/ExcelSheetModal.tsx +++ b/src/components/Variables/SourcePickModal.tsx @@ -3,26 +3,29 @@ import { ArrowUpTrayIcon, XMarkIcon } from '@heroicons/react/16/solid'; import { useT } from '../../hooks/useT'; import { DialogShell } from '../ui/DialogShell'; import { Select } from '../ui/Select'; -import type { PendingExcelImport } from '../../hooks/useExcelImportActions'; interface Props { - pending: PendingExcelImport; - onLoad: (sheet: string) => Promise; + /** Header text (filename / profile name) and its full-value tooltip. */ + titleText: string; + titleAttr: string; + /** Field label for the single choice (worksheet / table). */ + label: string; + options: string[]; + onLoad: (value: string) => Promise; onCancel: () => void; } -/** Worksheet picker between file pick and dataset commit. Always shown so a - * dataset replace takes a deliberate Load click (parity with the CSV - * confirm and the DB modal). */ -export function ExcelSheetModal({ pending, onLoad, onCancel }: Props) { +/** Pick one entry from a source (excel worksheet, sqlite table) then Load. + * Always shown so a dataset replace takes a deliberate Load click. */ +export function SourcePickModal({ titleText, titleAttr, label, options, onLoad, onCancel }: Props) { const tv = useT().variables; - const [sheet, setSheet] = useState(() => pending.sheets[0] ?? ''); + const [value, setValue] = useState(() => options[0] ?? ''); const [loading, setLoading] = useState(false); const handleLoad = () => { - if (sheet === '') return; + if (value === '') return; setLoading(true); - void onLoad(sheet).then((ok) => { + void onLoad(value).then((ok) => { if (!ok) setLoading(false); }); }; @@ -30,16 +33,16 @@ export function ExcelSheetModal({ pending, onLoad, onCancel }: Props) { return (
- {pending.filename} + {titleText} {tv.emptyExample}

+
) : (
    diff --git a/src/components/Variables/useWizardArm.ts b/src/components/Variables/useWizardArm.ts new file mode 100644 index 00000000..d4d3d88c --- /dev/null +++ b/src/components/Variables/useWizardArm.ts @@ -0,0 +1,28 @@ +import { useState } from 'react'; +import { useLabelStore } from '../../store/labelStore'; + +/** The wizard's advance gate. `loaded` is true only after markLoaded (the + * wizard's own load), never from watching the shared dataset/token: a + * foreign load (MCP push, doc swap) can't advance the wizard or pass as its own on abort. */ +export function useWizardArm() { + const dataset = useLabelStore((s) => s.dataset); + const datasetFetchToken = useLabelStore((s) => s.datasetFetchToken); + // The data-context epoch of the wizard's own landed load; doubles as the + // abort guard (restore only while that load is still the latest mutation). + const [ownLoadToken, setOwnLoadToken] = useState(null); + // Bound to the epoch: a later foreign load leaves the mapping step instead + // of letting Finish apply the wizard's mapping session to foreign data. + const loaded = ownLoadToken !== null && !!dataset && datasetFetchToken === ownLoadToken; + + const markLoaded = () => setOwnLoadToken(useLabelStore.getState().datasetFetchToken); + const reset = () => setOwnLoadToken(null); + + /** Run a fetch and mark the load as the wizard's own on success. */ + const loadArmed = async (fetchIt: () => Promise): Promise => { + const ok = await fetchIt(); + if (ok) markLoaded(); + return ok; + }; + + return { loaded, ownLoadToken, markLoaded, reset, loadArmed }; +} diff --git a/src/components/Variables/wizardTestFixtures.ts b/src/components/Variables/wizardTestFixtures.ts new file mode 100644 index 00000000..5276316f --- /dev/null +++ b/src/components/Variables/wizardTestFixtures.ts @@ -0,0 +1,48 @@ +import type { DbProfile } from '../../lib/db'; + +/** Shared wizard-test fixtures: one source of truth for the DataSource shapes + * so the four test files cannot drift when the shape changes. */ + +export const DB_DATASET = { + headers: ['sku', 'price'], + rows: [['A1', '9.99']], + source: { + kind: 'db' as const, + profileId: 'p1', + profileName: 'Local', + table: 't', + fetchedAt: '2026-01-01T00:00:00Z', + truncated: false, + rowCount: 1, + }, + activeRowIndex: 0, +}; + +export const EXCEL_DATASET = { + headers: ['old'], + rows: [['x']], + source: { + kind: 'excel' as const, + filename: 'old.xlsx', + sheet: 'S', + importedAt: '2020-01-01T00:00:00Z', + rowCount: 1, + truncated: false, + }, + activeRowIndex: 0, +}; + +/** What a mocked dbFetchDataset resolves with. */ +export const dbFetched = (profile: DbProfile, table: string) => ({ + headers: ['sku', 'price'], + rows: [['A1', '9.99']], + source: { + kind: 'db' as const, + profileId: profile.id, + profileName: profile.name, + table, + fetchedAt: '2026-01-01T00:00:00Z', + rowCount: 1, + truncated: false, + }, +}); diff --git a/src/components/db/NetworkDbFields.tsx b/src/components/db/NetworkDbFields.tsx new file mode 100644 index 00000000..6d2980ec --- /dev/null +++ b/src/components/db/NetworkDbFields.tsx @@ -0,0 +1,92 @@ +import { inputCls } from '../Properties/styles'; +import { Select } from '../ui/Select'; +import { useT } from '../../hooks/useT'; +import type { DbSslMode } from '../../lib/db'; + +export interface NetworkDbFieldValues { + host: string; + port?: number; + database: string; + user: string; + sslMode: DbSslMode; +} + +interface Props { + driver: 'postgres' | 'mysql'; + value: NetworkDbFieldValues; + onChange: (patch: Partial) => void; + /** Consumer-specific password field, rendered between user and SSL (the + * settings tab commits on blur, the wizard holds a draft). */ + children?: React.ReactNode; +} + +const fieldLabel = 'text-[10px] text-muted uppercase tracking-wider'; + +/** The network-DB endpoint form shared by the settings tab and the wizard, so + * fields and validation cannot drift between the two. */ +export function NetworkDbFields({ driver, value, onChange, children }: Props) { + const tv = useT().variables; + return ( + <> +
    +
    + + onChange({ host: e.target.value })} + /> +
    +
    + + { + const n = parseInt(e.target.value, 10); + // Rust deserializes into u16; clamp instead of an opaque serde + // error at fetch time. + onChange({ port: Number.isNaN(n) ? undefined : Math.min(65535, Math.max(1, n)) }); + }} + /> +
    +
    +
    + + onChange({ database: e.target.value })} + /> +
    +
    + + onChange({ user: e.target.value })} + /> +
    + + {children} + +
    + + + value={value.sslMode} + onChange={(sslMode) => onChange({ sslMode })} + groups={[ + { + options: (['prefer', 'require', 'verify-full', 'disable'] as const).map((m) => ({ + value: m, + label: m, + })), + }, + ]} + /> +
    + + ); +} diff --git a/src/hooks/useCsvImportActions.ts b/src/hooks/useCsvImportActions.ts index 00f5f0fe..99e031b6 100644 --- a/src/hooks/useCsvImportActions.ts +++ b/src/hooks/useCsvImportActions.ts @@ -43,11 +43,11 @@ export interface PendingImport { previousColumnCount: number; } -/** File-picker hook for "Import CSV data" in the File menu. Owns the - * hidden ref (web), the native-dialog entry point (desktop), and - * the pending-import slot that gates a destructive replace behind a - * ConfirmDialog. Errors go to the shared user-error channel. */ -export function useCsvImportActions() { +/** File-picker hook for "Import CSV data": owns the hidden ref (web), + * the native-dialog entry point (desktop), and the pending-import slot that + * gates a destructive replace behind a ConfirmDialog. `onApplied` fires after + * THIS instance's import committed, so a caller can tell its own load from foreign ones. */ +export function useCsvImportActions(onApplied?: () => void) { const csvInputRef = useRef(null); const setUserError = useLabelStore((s) => s.setUserError); const [pendingImport, setPendingImport] = useState(null); @@ -90,6 +90,7 @@ export function useCsvImportActions() { // mapping) inside applyImport handles UX from there. if (!dataset) { applyImport(parsed, { keepMapping: true }); + onApplied?.(); return; } @@ -147,6 +148,7 @@ export function useCsvImportActions() { // it now would overwrite the new context the user moved on to. if (isCurrentDataContext(pendingImport.token)) { applyImport(pendingImport.parsed, opts); + onApplied?.(); } setPendingImport(null); }; diff --git a/src/hooks/useServerDbConnectActions.ts b/src/hooks/useServerDbConnectActions.ts new file mode 100644 index 00000000..5dfeae78 --- /dev/null +++ b/src/hooks/useServerDbConnectActions.ts @@ -0,0 +1,127 @@ +import { useEffect, useRef, useState } from 'react'; +import { newId } from '@zplab/core/lib/ids'; +import { useLabelStore } from '../store/labelStore'; +import { dbListTables, dbPasswordCred, dbSetPassword, enqueueCredWrite } from '../lib/db'; +import { deleteCredential } from '../lib/credentialStore'; +import type { DbSslMode, NetworkDbProfile } from '../lib/db'; + +/** The endpoint form draft: a network profile minus identity. sslMode is + * required here (the form always shows a concrete selection). */ +export type NetworkDbDraft = Omit & { + sslMode: DbSslMode; +}; + +/** Guided network-DB connect lifecycle: Connect stores a typed password + * endpoint-bound and lists tables; loadTable persists the profile only on + * a successful load, and a stored password is deleted on unmount otherwise. */ +export function useServerDbConnectActions(driver: 'postgres' | 'mysql') { + const addDbProfile = useLabelStore((s) => s.addDbProfile); + const [profileId] = useState(newId); + const [fields, setFields] = useState({ + host: '', + database: '', + user: '', + sslMode: 'prefer', + }); + const [passwordDraft, setPasswordDraft] = useState(''); + const [tables, setTables] = useState(null); + const [listError, setListError] = useState(null); + const [busy, setBusy] = useState(false); + const passwordStored = useRef(false); + const persisted = useRef(false); + const inFlight = useRef | null>(null); + // Bumped on every draft edit; an in-flight connect only publishes its table + // listing if the draft is still the one it connected with, so a stale + // response cannot pair connection A's tables with connection B's fields. + const draftEpoch = useRef(0); + + // Delete a stored password unless a load persisted the profile that owns it. + // The decision chains on the in-flight load so it uses the load's OUTCOME, + // not the unmount moment: closing the wizard mid-fetch must still clean up + // when that fetch later fails, and must not when it succeeds. + useEffect( + () => () => { + void (inFlight.current ?? Promise.resolve()).then(() => { + if (passwordStored.current && !persisted.current) { + void enqueueCredWrite(() => + deleteCredential(dbPasswordCred(profileId)).catch(() => undefined), + ); + } + }); + }, + [profileId], + ); + + const profile = (): NetworkDbProfile => ({ + id: profileId, + name: `${fields.database}@${fields.host}`, + driver, + ...fields, + }); + + const ready = fields.host !== '' && fields.database !== '' && fields.user !== ''; + + /** Any endpoint or password edit invalidates a previous listing. */ + const editFields = (patch: Partial) => { + draftEpoch.current++; + setFields((prev) => ({ ...prev, ...patch })); + setTables(null); + setListError(null); + }; + const editPassword = (value: string) => { + draftEpoch.current++; + setPasswordDraft(value); + setTables(null); + setListError(null); + }; + + const connect = () => { + if (!ready || busy) return; + setBusy(true); + setListError(null); + const epoch = draftEpoch.current; + // Tracked so the unmount cleanup also waits for a connect that may still + // store the password, not only for a load. + inFlight.current = (async () => { + try { + // The Rust connector reads the password from the keychain, so a typed + // one must land there before the listing connect. + if (passwordDraft !== '') { + await enqueueCredWrite(() => dbSetPassword(profile(), passwordDraft)); + passwordStored.current = true; + } + const listed = await dbListTables(profile()); + if (draftEpoch.current === epoch) setTables(listed); + } catch (e) { + if (draftEpoch.current === epoch) setListError(String(e)); + } finally { + setBusy(false); + } + })(); + }; + + /** Run a load attempt via `run` (the wizard marks a success as its own load) + * and persist the profile on success; the unmount cleanup chains on this + * promise, so the password's fate always follows the load's outcome. */ + const loadTable = ( + table: string, + run: (profile: NetworkDbProfile, table: string) => Promise, + ) => { + if (busy) return; + setBusy(true); + // ONE snapshot for fetch and persist: an in-flight field edit must not + // save a different profile than the one that produced the dataset. + const snap = profile(); + inFlight.current = run(snap, table).then((ok) => { + if (ok) { + // Persist only on success, so the loaded design can reconnect later. + persisted.current = true; + addDbProfile(snap); + } else { + setBusy(false); + } + }); + }; + + return { fields, editFields, passwordDraft, editPassword, ready, tables, listError, busy, connect, loadTable }; +} diff --git a/src/hooks/useSqliteConnectActions.ts b/src/hooks/useSqliteConnectActions.ts new file mode 100644 index 00000000..3e350f87 --- /dev/null +++ b/src/hooks/useSqliteConnectActions.ts @@ -0,0 +1,111 @@ +import { useEffect, useRef, useState } from 'react'; +import { newId } from '@zplab/core/lib/ids'; +import { useLabelStore } from '../store/labelStore'; +import { currentDataContext, isCurrentDataContext } from '../store/datasetActions'; +import { useDbConnectActions } from './useDbConnectActions'; +import { pickSqliteFile, dbListTables, revokeSqlitePath, grantedSqlitePaths } from '../lib/db'; +import { basename } from '../lib/fileDialogs'; +import { formatTemplate } from '../lib/formatTemplate'; +import { useT } from './useT'; +import type { SqliteProfile } from '../lib/db'; + +export interface PendingSqliteImport { + profile: SqliteProfile; + tables: string[]; + /** datasetFetchToken at pick time; a mismatch on Load means the data context + * changed underneath, so the table is not committed. */ + token: number; +} + +/** The just-picked path is only in the keep-set once a table loaded (profile + * saved), so revoking a cancelled/failed pick drops its grant. */ +function grantedPaths(): string[] { + return grantedSqlitePaths(useLabelStore.getState().dbProfiles); +} + +/** Desktop-only guided SQLite connect: pick a file, choose a table, load. The + * profile is persisted only once a table loads (so a cancelled pick leaves no + * saved connection); its path grant is revoked on cancel. */ +export function useSqliteConnectActions() { + const t = useT(); + const setUserError = useLabelStore((s) => s.setUserError); + const addDbProfile = useLabelStore((s) => s.addDbProfile); + const { loadFromDb } = useDbConnectActions(); + const [pendingSqlite, setPendingSqliteState] = useState(null); + // Ref mirror for the unmount cleanup: closing the wizard with a pick still + // pending (dialog open or token-gated hidden) must revoke its path grant, + // since nothing else will (the profile was never saved). + const pendingRef = useRef(null); + const setPendingSqlite = (v: PendingSqliteImport | null) => { + pendingRef.current = v; + setPendingSqliteState(v); + }; + useEffect( + () => () => { + if (pendingRef.current) { + void revokeSqlitePath(pendingRef.current.profile.path, grantedPaths()); + } + }, + [], + ); + + const fail = (e: unknown) => + setUserError(formatTemplate(t.variables.dbFetchErrorFmt, { error: String(e) })); + + const openSqlitePicker = () => { + void (async () => { + // Capture before the pick/list awaits so a document loaded meanwhile + // invalidates this pick instead of inheriting the new context's token. + const token = currentDataContext(); + let path: string | null = null; + try { + path = await pickSqliteFile(); + if (!path) return; + const profile: SqliteProfile = { id: newId(), name: basename(path), driver: 'sqlite', path }; + const tables = await dbListTables(profile); + if (!isCurrentDataContext(token)) { + void revokeSqlitePath(path, grantedPaths()); + return; + } + setPendingSqlite({ profile, tables, token }); + } catch (e) { + // pickSqliteFile already granted the path when dbListTables rejects; + // revoke so a failed listing (non-sqlite/locked file) leaves no orphan. + if (path) void revokeSqlitePath(path, grantedPaths()); + fail(e); + } + })(); + }; + + const loadTable = async (table: string): Promise => { + if (!pendingSqlite) return false; + // Dialog opened against an older context: drop it, don't load into the new one. + if (!isCurrentDataContext(pendingSqlite.token)) { + void revokeSqlitePath(pendingSqlite.profile.path, grantedPaths()); + setPendingSqlite(null); + return false; + } + try { + const ok = await loadFromDb(pendingSqlite.profile, table); + // Persist only on success, so the loaded design can reconnect by profileId. + if (ok) { + addDbProfile(pendingSqlite.profile); + setPendingSqlite(null); + } + return ok; + } catch (e) { + fail(e); + return false; + } + }; + + const cancelSqliteImport = () => { + // Supersede any in-flight fetch (parity with the excel cancel), then drop the + // orphaned path grant since no profile was ever saved for it. + useLabelStore.getState().invalidateDatasetFetches(); + if (pendingSqlite) void revokeSqlitePath(pendingSqlite.profile.path, grantedPaths()); + setPendingSqlite(null); + }; + + return { openSqlitePicker, pendingSqlite, loadTable, cancelSqliteImport }; +} diff --git a/src/lib/db.ts b/src/lib/db.ts index f0aa755a..f4fcef1d 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -82,6 +82,12 @@ export async function revokeSqlitePath(path: string, keep: string[]): Promise (p.driver === 'sqlite' && p.path ? [p.path] : [])); +} + /** Keychain account for a profile's password (mirrors Rust `password_cred`). */ export const dbPasswordCred = (profileId: string): string => `db-profile-${profileId}`; diff --git a/src/lib/menuModel.test.ts b/src/lib/menuModel.test.ts index 84c05b64..d774e51a 100644 --- a/src/lib/menuModel.test.ts +++ b/src/lib/menuModel.test.ts @@ -7,7 +7,7 @@ const FLAGS: MenuFlags = { canBatchExport: false, batchRowCount: 0, batchPrintCount: 0, - includeExcelImport: false, + connectDataWizard: false, labelaryEnabled: true, canUndo: true, canRedo: false, @@ -54,10 +54,15 @@ describe("buildMenuModel", () => { expect(byId(m, "exportBatch")?.label).toContain("7"); }); - it("shows the excel item only on the desktop shell", () => { - expect(byId(buildMenuModel(en, FLAGS), "importExcel")).toBeUndefined(); - const m = buildMenuModel(en, { ...FLAGS, includeExcelImport: true }); - expect(ids(m).indexOf("importExcel")).toBe(ids(m).indexOf("importCsv") + 1); + it("offers the direct CSV import on web and the connect-data wizard on desktop", () => { + const web = buildMenuModel(en, FLAGS); + expect(byId(web, "importCsv")).toBeDefined(); + expect(byId(web, "connectData")).toBeUndefined(); + const desktop = buildMenuModel(en, { ...FLAGS, connectDataWizard: true }); + expect(byId(desktop, "importCsv")).toBeUndefined(); + expect(byId(desktop, "connectData")).toBeDefined(); + // Replaces the CSV slot (after saveDesign), not an extra entry. + expect(ids(desktop).indexOf("connectData")).toBe(ids(desktop).indexOf("saveDesign") + 1); }); it("hides print entirely when the Labelary gate is off", () => { diff --git a/src/lib/menuModel.ts b/src/lib/menuModel.ts index b708f299..d67c9afd 100644 --- a/src/lib/menuModel.ts +++ b/src/lib/menuModel.ts @@ -19,7 +19,7 @@ export type MenuItemId = | 'openDesign' | 'saveDesign' | 'importCsv' - | 'importExcel' + | 'connectData' | 'print' | 'sendToZebra' | 'undo' @@ -68,8 +68,9 @@ export interface MenuFlags { /** Physical labels a batch send produces: rows × per-label ^PQ quantity. * exportBatch keeps batchRowCount (it names file content, not printing). */ batchPrintCount: number; - /** The Excel reader needs the desktop shell's Rust side; web hides it. */ - includeExcelImport: boolean; + /** Desktop routes every data source through the connect-data wizard (one File + * entry); web keeps the direct CSV import as its only supported source. */ + connectDataWizard: boolean; /** Labelary gate off hides the print item entirely (matches the dropdown). */ labelaryEnabled: boolean; canUndo: boolean; @@ -99,10 +100,9 @@ export function buildMenuModel(t: Translations, f: MenuFlags): MenuModel { [ { id: 'openDesign', label: t.app.openDesign, enabled: true }, { id: 'saveDesign', label: t.app.saveDesign, enabled: f.hasObjects }, - { id: 'importCsv', label: t.app.importCsvData, enabled: true }, - ...(f.includeExcelImport - ? [{ id: 'importExcel' as const, label: t.app.importExcelData, enabled: true }] - : []), + f.connectDataWizard + ? { id: 'connectData' as const, label: t.connectData.title, enabled: true } + : { id: 'importCsv' as const, label: t.app.importCsvData, enabled: true }, ], [ ...(f.labelaryEnabled diff --git a/src/lib/menuSignature.test.ts b/src/lib/menuSignature.test.ts index 130d0151..92a68b45 100644 --- a/src/lib/menuSignature.test.ts +++ b/src/lib/menuSignature.test.ts @@ -9,7 +9,7 @@ const FLAGS: MenuFlags = { canBatchExport: false, batchRowCount: 0, batchPrintCount: 0, - includeExcelImport: false, + connectDataWizard: false, labelaryEnabled: true, canUndo: true, canRedo: false, diff --git a/src/locales/ar.ts b/src/locales/ar.ts index 91b7eceb..5a307021 100644 --- a/src/locales/ar.ts +++ b/src/locales/ar.ts @@ -763,7 +763,6 @@ const ar = { exportZpl: 'تصدير ZPL', menuInitError: 'تعذّر تحميل قائمة التطبيق. يُرجى إعادة تشغيل التطبيق.', importCsvData: 'استيراد بيانات CSV', - importExcelData: 'استيراد بيانات Excel…', sendToZebraBatchFmt: 'إرسال إلى Zebra ({n} ملصقات)', exportBatchZplFmt: 'تصدير ZPL دفعي ({n} ملصقات)', newDesign: 'تصميم جديد', @@ -1236,6 +1235,7 @@ const ar = { add: 'إضافة متغير', empty: 'لا توجد متغيرات بعد.', emptyExample: 'مفيدة لأرقام SKU المتسلسلة وأرقام الدفعات وأسماء العملاء.', + connectData: 'ربط البيانات', nameLabel: 'الاسم', fnLabel: 'الفتحة', defaultLabel: 'الافتراضي', @@ -1361,6 +1361,20 @@ const ar = { excelReadErrorFmt: 'تعذّرت قراءة ملف Excel: {error}', excelDiscardConfirm: 'تجاهل صفوف Excel المستوردة؟ يبقى التعيين محفوظًا.', }, + connectData: { + title: 'ربط البيانات', + chooseSource: 'كيف تريد ربط بياناتك؟', + csv: 'ملف CSV', + excel: 'ملف Excel', + sqlite: 'ملف SQLite', + mysql: 'MySQL / MariaDB', + postgres: 'PostgreSQL', + desktopOnly: 'يتطلب تطبيق سطح المكتب.', + connect: 'اتصال', + doneTitle: 'تم ربط البيانات', + doneBody: 'تم ربط أعمدتك بالمتغيرات. اربط متغيراً بحقل لعرض البيانات على ملصقك.', + finish: 'تم', + }, variableField: { groupVariables: 'المتغيرات', groupDateTime: 'التاريخ والوقت', diff --git a/src/locales/bg.ts b/src/locales/bg.ts index 8a3814af..c064efbc 100644 --- a/src/locales/bg.ts +++ b/src/locales/bg.ts @@ -763,7 +763,6 @@ const bg = { exportZpl: 'Export ZPL', menuInitError: 'Менюто на приложението не можа да се зареди. Рестартирайте приложението.', importCsvData: 'Импортиране на CSV данни', - importExcelData: 'Импортиране на данни от Excel…', sendToZebraBatchFmt: 'Изпрати към Zebra ({n} етикета)', exportBatchZplFmt: 'Експорт на пакетен ZPL ({n} етикета)', newDesign: 'Нов дизайн', @@ -1236,6 +1235,7 @@ const bg = { add: 'Добави променлива', empty: 'Все още няма променливи.', emptyExample: 'Полезно за поредни SKU, партидни номера или имена на клиенти.', + connectData: 'Свързване на данни', nameLabel: 'Име', fnLabel: 'Слот', defaultLabel: 'По подразбиране', @@ -1361,6 +1361,20 @@ const bg = { excelReadErrorFmt: 'Файлът на Excel не беше прочетен: {error}', excelDiscardConfirm: 'Да се отхвърлят ли импортираните редове от Excel? Съпоставянето остава запазено.', }, + connectData: { + title: 'Свързване на данни', + chooseSource: 'Как искате да свържете данните си?', + csv: 'CSV файл', + excel: 'Excel файл', + sqlite: 'SQLite файл', + mysql: 'MySQL / MariaDB', + postgres: 'PostgreSQL', + desktopOnly: 'Изисква настолното приложение.', + connect: 'Свързване', + doneTitle: 'Данните са свързани', + doneBody: 'Вашите колони са свързани с променливи. Свържете променлива с поле, за да покажете данните на етикета си.', + finish: 'Готово', + }, variableField: { groupVariables: 'Променливи', groupDateTime: 'Дата и час', diff --git a/src/locales/cs.ts b/src/locales/cs.ts index e8bf8294..cf1c0c47 100644 --- a/src/locales/cs.ts +++ b/src/locales/cs.ts @@ -763,7 +763,6 @@ const cs = { exportZpl: 'Export ZPL', menuInitError: 'Nabídku aplikace se nepodařilo načíst. Restartujte aplikaci.', importCsvData: 'Importovat data CSV', - importExcelData: 'Importovat data z Excelu…', sendToZebraBatchFmt: 'Odeslat na Zebra ({n} etiket)', exportBatchZplFmt: 'Exportovat dávkové ZPL ({n} etiket)', newDesign: 'Nový návrh', @@ -1236,6 +1235,7 @@ const cs = { add: 'Přidat proměnnou', empty: 'Zatím žádné proměnné.', emptyExample: 'Hodí se na sekvenční SKU, čísla šarží nebo jména zákazníků.', + connectData: 'Připojit data', nameLabel: 'Název', fnLabel: 'Slot', defaultLabel: 'Výchozí', @@ -1361,6 +1361,20 @@ const cs = { excelReadErrorFmt: 'Soubor Excel se nepodařilo přečíst: {error}', excelDiscardConfirm: 'Zahodit importované řádky z Excelu? Mapování zůstane uloženo.', }, + connectData: { + title: 'Připojit data', + chooseSource: 'Jak chcete připojit svá data?', + csv: 'Soubor CSV', + excel: 'Soubor Excel', + sqlite: 'Soubor SQLite', + mysql: 'MySQL / MariaDB', + postgres: 'PostgreSQL', + desktopOnly: 'Vyžaduje desktopovou aplikaci.', + connect: 'Připojit', + doneTitle: 'Data připojena', + doneBody: 'Vaše sloupce jsou namapovány na proměnné. Přiřaďte proměnnou k poli, aby se data zobrazila na štítku.', + finish: 'Hotovo', + }, variableField: { groupVariables: 'Proměnné', groupDateTime: 'Datum a čas', diff --git a/src/locales/da.ts b/src/locales/da.ts index b051e90b..ff91b217 100644 --- a/src/locales/da.ts +++ b/src/locales/da.ts @@ -763,7 +763,6 @@ const da = { exportZpl: 'Export ZPL', menuInitError: 'Programmets menu kunne ikke indlæses. Genstart appen.', importCsvData: 'Importer CSV-data', - importExcelData: 'Importer Excel-data…', sendToZebraBatchFmt: 'Send til Zebra ({n} etiketter)', exportBatchZplFmt: 'Eksportér batch-ZPL ({n} etiketter)', newDesign: 'Nyt design', @@ -1236,6 +1235,7 @@ const da = { add: 'Tilføj variabel', empty: 'Ingen variabler endnu.', emptyExample: 'Nyttig til sekventielle SKU\\\'er, batchnumre eller kundenavne.', + connectData: 'Forbind data', nameLabel: 'Navn', fnLabel: 'Slot', defaultLabel: 'Standard', @@ -1361,6 +1361,20 @@ const da = { excelReadErrorFmt: 'Kunne ikke læse Excel-filen: {error}', excelDiscardConfirm: 'Kasser de importerede Excel-rækker? Tilknytningen forbliver gemt.', }, + connectData: { + title: 'Forbind data', + chooseSource: 'Hvordan vil du forbinde dine data?', + csv: 'CSV-fil', + excel: 'Excel-fil', + sqlite: 'SQLite-fil', + mysql: 'MySQL / MariaDB', + postgres: 'PostgreSQL', + desktopOnly: 'Kræver desktopappen.', + connect: 'Forbind', + doneTitle: 'Data forbundet', + doneBody: 'Dine kolonner er knyttet til variabler. Bind en variabel til et felt for at vise dataene på din etiket.', + finish: 'Færdig', + }, variableField: { groupVariables: 'Variabler', groupDateTime: 'Dato og tid', diff --git a/src/locales/de.ts b/src/locales/de.ts index 7287ef43..54221dcb 100644 --- a/src/locales/de.ts +++ b/src/locales/de.ts @@ -763,7 +763,6 @@ const de = { exportZpl: 'Export ZPL', menuInitError: 'Das Anwendungsmenü konnte nicht geladen werden. Bitte starten Sie die App neu.', importCsvData: 'CSV-Daten importieren', - importExcelData: 'Excel-Daten importieren…', sendToZebraBatchFmt: 'An Zebra senden ({n} Etiketten)', exportBatchZplFmt: 'Batch-ZPL exportieren ({n} Etiketten)', newDesign: 'Neues Design', @@ -1266,6 +1265,7 @@ const de = { add: 'Variable hinzufügen', empty: 'Noch keine Variablen.', emptyExample: 'Praktisch für fortlaufende SKUs, Chargennummern oder Kundennamen.', + connectData: 'Daten verbinden', nameLabel: 'Name', fnLabel: 'Slot', defaultLabel: 'Standard', @@ -1391,6 +1391,20 @@ const de = { excelReadErrorFmt: 'Excel-Datei konnte nicht gelesen werden: {error}', excelDiscardConfirm: 'Importierte Excel-Zeilen verwerfen? Die Zuordnung bleibt gespeichert.', }, + connectData: { + title: 'Daten verbinden', + chooseSource: 'Wie möchten Sie Ihre Daten verbinden?', + csv: 'CSV-Datei', + excel: 'Excel-Datei', + sqlite: 'SQLite-Datei', + mysql: 'MySQL / MariaDB', + postgres: 'PostgreSQL', + desktopOnly: 'Nur in der Desktop-App verfügbar.', + connect: 'Verbinden', + doneTitle: 'Daten verbunden', + doneBody: 'Ihre Spalten sind Variablen zugeordnet. Verknüpfen Sie eine Variable mit einem Feld, um die Daten auf Ihrem Etikett anzuzeigen.', + finish: 'Fertig', + }, variableField: { groupVariables: 'Variablen', groupDateTime: 'Datum & Uhrzeit', diff --git a/src/locales/el.ts b/src/locales/el.ts index 955dc8fd..2acb8ea1 100644 --- a/src/locales/el.ts +++ b/src/locales/el.ts @@ -763,7 +763,6 @@ const el = { exportZpl: 'Export ZPL', menuInitError: 'Δεν ήταν δυνατή η φόρτωση του μενού της εφαρμογής. Επανεκκινήστε την εφαρμογή.', importCsvData: 'Εισαγωγή δεδομένων CSV', - importExcelData: 'Εισαγωγή δεδομένων Excel…', sendToZebraBatchFmt: 'Αποστολή σε Zebra ({n} ετικέτες)', exportBatchZplFmt: 'Εξαγωγή παρτίδας ZPL ({n} ετικέτες)', newDesign: 'Νέο σχέδιο', @@ -1236,6 +1235,7 @@ const el = { add: 'Προσθήκη μεταβλητής', empty: 'Δεν υπάρχουν ακόμη μεταβλητές.', emptyExample: 'Χρήσιμο για διαδοχικά SKU, αριθμούς παρτίδας ή ονόματα πελατών.', + connectData: 'Σύνδεση δεδομένων', nameLabel: 'Όνομα', fnLabel: 'Υποδοχή', defaultLabel: 'Προεπιλογή', @@ -1361,6 +1361,20 @@ const el = { excelReadErrorFmt: 'Δεν ήταν δυνατή η ανάγνωση του αρχείου Excel: {error}', excelDiscardConfirm: 'Απόρριψη των εισαγόμενων γραμμών Excel; Η αντιστοίχιση παραμένει αποθηκευμένη.', }, + connectData: { + title: 'Σύνδεση δεδομένων', + chooseSource: 'Πώς θέλετε να συνδέσετε τα δεδομένα σας;', + csv: 'Αρχείο CSV', + excel: 'Αρχείο Excel', + sqlite: 'Αρχείο SQLite', + mysql: 'MySQL / MariaDB', + postgres: 'PostgreSQL', + desktopOnly: 'Απαιτεί την εφαρμογή υπολογιστή.', + connect: 'Σύνδεση', + doneTitle: 'Τα δεδομένα συνδέθηκαν', + doneBody: 'Οι στήλες σας αντιστοιχίστηκαν σε μεταβλητές. Συνδέστε μια μεταβλητή σε ένα πεδίο για να εμφανιστούν τα δεδομένα στην ετικέτα σας.', + finish: 'Τέλος', + }, variableField: { groupVariables: 'Μεταβλητές', groupDateTime: 'Ημερομηνία & ώρα', diff --git a/src/locales/en.ts b/src/locales/en.ts index 68630f38..99f699da 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -763,7 +763,6 @@ const en = { exportZpl: 'Export ZPL', menuInitError: 'The application menu could not be loaded. Please restart the app.', importCsvData: 'Import CSV data', - importExcelData: 'Import Excel data…', sendToZebraBatchFmt: 'Send to Zebra ({n} labels)', exportBatchZplFmt: 'Export batch ZPL ({n} labels)', newDesign: 'New design', @@ -1266,6 +1265,7 @@ const en = { add: 'Add variable', empty: 'No variables yet.', emptyExample: 'Useful for sequential SKUs, batch numbers, or customer names.', + connectData: 'Connect data', nameLabel: 'Name', fnLabel: 'Slot', defaultLabel: 'Default', @@ -1391,6 +1391,20 @@ const en = { excelReadErrorFmt: 'Could not read the Excel file: {error}', excelDiscardConfirm: 'Discard the imported Excel rows? The mapping stays saved.', }, + connectData: { + title: 'Connect data', + chooseSource: 'How do you want to connect your data?', + csv: 'CSV file', + excel: 'Excel file', + sqlite: 'SQLite file', + mysql: 'MySQL / MariaDB', + postgres: 'PostgreSQL', + desktopOnly: 'Requires the desktop app.', + connect: 'Connect', + doneTitle: 'Data connected', + doneBody: 'Your columns are mapped to variables. Bind a variable to a field to show the data on your label.', + finish: 'Done', + }, variableField: { groupVariables: 'Variables', groupDateTime: 'Date & time', diff --git a/src/locales/es.ts b/src/locales/es.ts index 31940880..b454bb31 100644 --- a/src/locales/es.ts +++ b/src/locales/es.ts @@ -763,7 +763,6 @@ const es = { exportZpl: 'Export ZPL', menuInitError: 'No se pudo cargar el menú de la aplicación. Reinicia la aplicación.', importCsvData: 'Importar datos CSV', - importExcelData: 'Importar datos de Excel…', sendToZebraBatchFmt: 'Enviar a Zebra ({n} etiquetas)', exportBatchZplFmt: 'Exportar ZPL por lotes ({n} etiquetas)', newDesign: 'Nuevo diseño', @@ -1236,6 +1235,7 @@ const es = { add: 'Añadir variable', empty: 'Aún no hay variables.', emptyExample: 'Útil para SKU secuenciales, números de lote o nombres de clientes.', + connectData: 'Conectar datos', nameLabel: 'Nombre', fnLabel: 'Ranura', defaultLabel: 'Predeterminado', @@ -1361,6 +1361,20 @@ const es = { excelReadErrorFmt: 'No se pudo leer el archivo de Excel: {error}', excelDiscardConfirm: '¿Descartar las filas de Excel importadas? La asignación permanece guardada.', }, + connectData: { + title: 'Conectar datos', + chooseSource: '¿Cómo quieres conectar tus datos?', + csv: 'Archivo CSV', + excel: 'Archivo Excel', + sqlite: 'Archivo SQLite', + mysql: 'MySQL / MariaDB', + postgres: 'PostgreSQL', + desktopOnly: 'Requiere la aplicación de escritorio.', + connect: 'Conectar', + doneTitle: 'Datos conectados', + doneBody: 'Tus columnas están asignadas a variables. Vincula una variable a un campo para mostrar los datos en tu etiqueta.', + finish: 'Listo', + }, variableField: { groupVariables: 'Variables', groupDateTime: 'Fecha y hora', diff --git a/src/locales/et.ts b/src/locales/et.ts index 4156b1d1..c3c7fed2 100644 --- a/src/locales/et.ts +++ b/src/locales/et.ts @@ -763,7 +763,6 @@ const et = { exportZpl: 'Export ZPL', menuInitError: 'Rakenduse menüüd ei õnnestunud laadida. Käivitage rakendus uuesti.', importCsvData: 'Impordi CSV-andmed', - importExcelData: 'Impordi Exceli andmed…', sendToZebraBatchFmt: 'Saada Zebrale ({n} silti)', exportBatchZplFmt: 'Ekspordi partii-ZPL ({n} silti)', newDesign: 'Uus kujundus', @@ -1236,6 +1235,7 @@ const et = { add: 'Lisa muutuja', empty: 'Veel pole muutujaid.', emptyExample: 'Sobib järjestikuste SKU-de, partii numbrite või klientide nimede jaoks.', + connectData: 'Ühenda andmed', nameLabel: 'Nimi', fnLabel: 'Pesa', defaultLabel: 'Vaikimisi', @@ -1361,6 +1361,20 @@ const et = { excelReadErrorFmt: 'Exceli faili ei õnnestunud lugeda: {error}', excelDiscardConfirm: 'Kas loobuda imporditud Exceli ridadest? Vastendus jääb salvestatuks.', }, + connectData: { + title: 'Ühenda andmed', + chooseSource: 'Kuidas soovite oma andmed ühendada?', + csv: 'CSV-fail', + excel: 'Excel-fail', + sqlite: 'SQLite-fail', + mysql: 'MySQL / MariaDB', + postgres: 'PostgreSQL', + desktopOnly: 'Nõuab lauaarvutirakendust.', + connect: 'Ühenda', + doneTitle: 'Andmed ühendatud', + doneBody: 'Teie veerud on seotud muutujatega. Siduge muutuja väljaga, et andmed sildil kuvada.', + finish: 'Valmis', + }, variableField: { groupVariables: 'Muutujad', groupDateTime: 'Kuupäev ja kellaaeg', diff --git a/src/locales/fa.ts b/src/locales/fa.ts index 79142175..10ca0490 100644 --- a/src/locales/fa.ts +++ b/src/locales/fa.ts @@ -763,7 +763,6 @@ const fa = { exportZpl: 'خروجی ZPL', menuInitError: 'منوی برنامه بارگذاری نشد. لطفاً برنامه را دوباره اجرا کنید.', importCsvData: 'وارد کردن داده‌های CSV', - importExcelData: 'وارد کردن داده‌های Excel…', sendToZebraBatchFmt: 'ارسال به Zebra ({n} برچسب)', exportBatchZplFmt: 'خروجی ZPL دسته‌ای ({n} برچسب)', newDesign: 'طرح جدید', @@ -1236,6 +1235,7 @@ const fa = { add: 'افزودن متغیر', empty: 'هنوز متغیری وجود ندارد.', emptyExample: 'برای SKUهای ترتیبی، شماره‌های دسته یا نام مشتریان مفید است.', + connectData: 'اتصال داده', nameLabel: 'نام', fnLabel: 'اسلات', defaultLabel: 'پیش‌فرض', @@ -1361,6 +1361,20 @@ const fa = { excelReadErrorFmt: 'خواندن فایل Excel ممکن نشد: {error}', excelDiscardConfirm: 'ردیف‌های وارد‌شده از Excel حذف شود؟ نگاشت ذخیره‌شده باقی می‌ماند.', }, + connectData: { + title: 'اتصال داده', + chooseSource: 'چگونه می‌خواهید داده‌های خود را متصل کنید؟', + csv: 'فایل CSV', + excel: 'فایل Excel', + sqlite: 'فایل SQLite', + mysql: 'MySQL / MariaDB', + postgres: 'PostgreSQL', + desktopOnly: 'به برنامه دسکتاپ نیاز دارد.', + connect: 'اتصال', + doneTitle: 'داده متصل شد', + doneBody: 'ستون‌های شما به متغیرها نگاشت شده‌اند. یک متغیر را به یک فیلد متصل کنید تا داده روی برچسب شما نمایش داده شود.', + finish: 'انجام شد', + }, variableField: { groupVariables: 'متغیرها', groupDateTime: 'تاریخ و زمان', diff --git a/src/locales/fi.ts b/src/locales/fi.ts index 3998072f..2ac64122 100644 --- a/src/locales/fi.ts +++ b/src/locales/fi.ts @@ -763,7 +763,6 @@ const fi = { exportZpl: 'Export ZPL', menuInitError: 'Sovelluksen valikkoa ei voitu ladata. Käynnistä sovellus uudelleen.', importCsvData: 'Tuo CSV-tiedot', - importExcelData: 'Tuo Excel-tiedot…', sendToZebraBatchFmt: 'Lähetä Zebralle ({n} etikettiä)', exportBatchZplFmt: 'Vie erä-ZPL ({n} etikettiä)', newDesign: 'Uusi rakenne', @@ -1236,6 +1235,7 @@ const fi = { add: 'Lisää muuttuja', empty: 'Ei vielä muuttujia.', emptyExample: 'Hyödyllinen peräkkäisille SKU-koodeille, eränumeroille tai asiakasnimille.', + connectData: 'Yhdistä tiedot', nameLabel: 'Nimi', fnLabel: 'Paikka', defaultLabel: 'Oletus', @@ -1361,6 +1361,20 @@ const fi = { excelReadErrorFmt: 'Excel-tiedoston lukeminen epäonnistui: {error}', excelDiscardConfirm: 'Hylätäänkö tuodut Excel-rivit? Määritys pysyy tallennettuna.', }, + connectData: { + title: 'Yhdistä tiedot', + chooseSource: 'Miten haluat yhdistää tietosi?', + csv: 'CSV-tiedosto', + excel: 'Excel-tiedosto', + sqlite: 'SQLite-tiedosto', + mysql: 'MySQL / MariaDB', + postgres: 'PostgreSQL', + desktopOnly: 'Vaatii työpöytäsovelluksen.', + connect: 'Yhdistä', + doneTitle: 'Tiedot yhdistetty', + doneBody: 'Sarakkeesi on yhdistetty muuttujiin. Sido muuttuja kenttään näyttääksesi tiedot tarrassa.', + finish: 'Valmis', + }, variableField: { groupVariables: 'Muuttujat', groupDateTime: 'Päivä ja aika', diff --git a/src/locales/fr.ts b/src/locales/fr.ts index 5c7a9131..5efdd272 100644 --- a/src/locales/fr.ts +++ b/src/locales/fr.ts @@ -763,7 +763,6 @@ const fr = { exportZpl: 'Export ZPL', menuInitError: "Le menu de l'application n'a pas pu être chargé. Veuillez redémarrer l'application.", importCsvData: 'Importer des données CSV', - importExcelData: 'Importer des données Excel…', sendToZebraBatchFmt: 'Envoyer à Zebra ({n} étiquettes)', exportBatchZplFmt: 'Exporter ZPL en lot ({n} étiquettes)', newDesign: 'Nouveau design', @@ -1236,6 +1235,7 @@ const fr = { add: 'Ajouter une variable', empty: 'Aucune variable pour l\\\'instant.', emptyExample: 'Pratique pour des SKU séquentiels, numéros de lot ou noms de clients.', + connectData: 'Connecter des données', nameLabel: 'Nom', fnLabel: 'Emplacement', defaultLabel: 'Par défaut', @@ -1361,6 +1361,20 @@ const fr = { excelReadErrorFmt: 'Impossible de lire le fichier Excel : {error}', excelDiscardConfirm: 'Annuler les lignes Excel importées ? Le mappage reste enregistré.', }, + connectData: { + title: 'Connecter des données', + chooseSource: 'Comment voulez-vous connecter vos données ?', + csv: 'Fichier CSV', + excel: 'Fichier Excel', + sqlite: 'Fichier SQLite', + mysql: 'MySQL / MariaDB', + postgres: 'PostgreSQL', + desktopOnly: "Nécessite l'application de bureau.", + connect: 'Connecter', + doneTitle: 'Données connectées', + doneBody: 'Vos colonnes sont associées à des variables. Liez une variable à un champ pour afficher les données sur votre étiquette.', + finish: 'Terminé', + }, variableField: { groupVariables: 'Variables', groupDateTime: 'Date et heure', diff --git a/src/locales/he.ts b/src/locales/he.ts index ab1142cc..907db4b5 100644 --- a/src/locales/he.ts +++ b/src/locales/he.ts @@ -763,7 +763,6 @@ const he = { exportZpl: 'ייצוא ZPL', menuInitError: 'לא ניתן היה לטעון את תפריט היישום. יש להפעיל מחדש את היישום.', importCsvData: 'ייבוא נתוני CSV', - importExcelData: 'ייבוא נתוני Excel…', sendToZebraBatchFmt: 'שלח ל-Zebra ({n} תוויות)', exportBatchZplFmt: 'ייצוא ZPL באצווה ({n} תוויות)', newDesign: 'עיצוב חדש', @@ -1236,6 +1235,7 @@ const he = { add: 'הוסף משתנה', empty: 'אין משתנים עדיין.', emptyExample: 'שימושי ל-SKU עוקבים, מספרי אצווה או שמות לקוחות.', + connectData: 'חיבור נתונים', nameLabel: 'שם', fnLabel: 'חריץ', defaultLabel: 'ברירת מחדל', @@ -1361,6 +1361,20 @@ const he = { excelReadErrorFmt: 'לא ניתן לקרוא את קובץ ה-Excel: {error}', excelDiscardConfirm: 'לבטל את שורות ה-Excel שיובאו? המיפוי יישאר שמור.', }, + connectData: { + title: 'חיבור נתונים', + chooseSource: 'כיצד ברצונך לחבר את הנתונים שלך?', + csv: 'קובץ CSV', + excel: 'קובץ Excel', + sqlite: 'קובץ SQLite', + mysql: 'MySQL / MariaDB', + postgres: 'PostgreSQL', + desktopOnly: 'דורש את אפליקציית שולחן העבודה.', + connect: 'התחברות', + doneTitle: 'הנתונים חוברו', + doneBody: 'העמודות שלך ממופות למשתנים. קשר משתנה לשדה כדי להציג את הנתונים על התווית שלך.', + finish: 'סיום', + }, variableField: { groupVariables: 'משתנים', groupDateTime: 'תאריך ושעה', diff --git a/src/locales/hr.ts b/src/locales/hr.ts index 6d2b5adb..21d66152 100644 --- a/src/locales/hr.ts +++ b/src/locales/hr.ts @@ -763,7 +763,6 @@ const hr = { exportZpl: 'Export ZPL', menuInitError: 'Izbornik aplikacije nije se mogao učitati. Ponovno pokrenite aplikaciju.', importCsvData: 'Uvoz CSV podataka', - importExcelData: 'Uvoz Excel podataka…', sendToZebraBatchFmt: 'Pošalji na Zebra ({n} etiketa)', exportBatchZplFmt: 'Izvoz skupnog ZPL-a ({n} etiketa)', newDesign: 'Novi dizajn', @@ -1236,6 +1235,7 @@ const hr = { add: 'Dodaj varijablu', empty: 'Još nema varijabli.', emptyExample: 'Korisno za sekvencijalne SKU, brojeve serija ili imena kupaca.', + connectData: 'Poveži podatke', nameLabel: 'Naziv', fnLabel: 'Utor', defaultLabel: 'Zadano', @@ -1361,6 +1361,20 @@ const hr = { excelReadErrorFmt: 'Nije moguće pročitati Excel datoteku: {error}', excelDiscardConfirm: 'Odbaciti uvezene Excel retke? Mapiranje ostaje spremljeno.', }, + connectData: { + title: 'Poveži podatke', + chooseSource: 'Kako želite povezati svoje podatke?', + csv: 'CSV datoteka', + excel: 'Excel datoteka', + sqlite: 'SQLite datoteka', + mysql: 'MySQL / MariaDB', + postgres: 'PostgreSQL', + desktopOnly: 'Zahtijeva desktop aplikaciju.', + connect: 'Poveži', + doneTitle: 'Podaci povezani', + doneBody: 'Vaši stupci povezani su s varijablama. Povežite varijablu s poljem kako biste prikazali podatke na naljepnici.', + finish: 'Gotovo', + }, variableField: { groupVariables: 'Varijable', groupDateTime: 'Datum i vrijeme', diff --git a/src/locales/hu.ts b/src/locales/hu.ts index 63069ee4..b8cee368 100644 --- a/src/locales/hu.ts +++ b/src/locales/hu.ts @@ -763,7 +763,6 @@ const hu = { exportZpl: 'Export ZPL', menuInitError: 'Az alkalmazás menüje nem tölthető be. Indítsa újra az alkalmazást.', importCsvData: 'CSV-adatok importálása', - importExcelData: 'Excel-adatok importálása…', sendToZebraBatchFmt: 'Küldés Zebrára ({n} címke)', exportBatchZplFmt: 'Köteg ZPL exportálása ({n} címke)', newDesign: 'Új terv', @@ -1236,6 +1235,7 @@ const hu = { add: 'Változó hozzáadása', empty: 'Még nincsenek változók.', emptyExample: 'Sorszámozott SKU-khoz, gyártási tételszámokhoz vagy ügyfélnevekhez hasznos.', + connectData: 'Adatok csatlakoztatása', nameLabel: 'Név', fnLabel: 'Hely', defaultLabel: 'Alapérték', @@ -1361,6 +1361,20 @@ const hu = { excelReadErrorFmt: 'Az Excel-fájl beolvasása sikertelen: {error}', excelDiscardConfirm: 'Eldobod az importált Excel-sorokat? A hozzárendelés megmarad.', }, + connectData: { + title: 'Adatok csatlakoztatása', + chooseSource: 'Hogyan szeretné csatlakoztatni az adatait?', + csv: 'CSV-fájl', + excel: 'Excel-fájl', + sqlite: 'SQLite-fájl', + mysql: 'MySQL / MariaDB', + postgres: 'PostgreSQL', + desktopOnly: 'Az asztali alkalmazás szükséges hozzá.', + connect: 'Csatlakozás', + doneTitle: 'Adatok csatlakoztatva', + doneBody: 'Az oszlopai változókhoz vannak rendelve. Rendeljen egy változót egy mezőhöz, hogy az adatok megjelenjenek a címkén.', + finish: 'Kész', + }, variableField: { groupVariables: 'Változók', groupDateTime: 'Dátum és idő', diff --git a/src/locales/it.ts b/src/locales/it.ts index a70caa39..be0c23c4 100644 --- a/src/locales/it.ts +++ b/src/locales/it.ts @@ -763,7 +763,6 @@ const it = { exportZpl: 'Export ZPL', menuInitError: "Impossibile caricare il menu dell'applicazione. Riavvia l'app.", importCsvData: 'Importa dati CSV', - importExcelData: 'Importa dati Excel…', sendToZebraBatchFmt: 'Invia a Zebra ({n} etichette)', exportBatchZplFmt: 'Esporta ZPL in batch ({n} etichette)', newDesign: 'Nuovo design', @@ -1236,6 +1235,7 @@ const it = { add: 'Aggiungi variabile', empty: 'Nessuna variabile ancora.', emptyExample: 'Utile per SKU sequenziali, numeri di lotto o nomi cliente.', + connectData: 'Collega dati', nameLabel: 'Nome', fnLabel: 'Slot', defaultLabel: 'Predefinito', @@ -1361,6 +1361,20 @@ const it = { excelReadErrorFmt: 'Impossibile leggere il file Excel: {error}', excelDiscardConfirm: 'Eliminare le righe Excel importate? La mappatura resta salvata.', }, + connectData: { + title: 'Collega dati', + chooseSource: 'Come vuoi collegare i tuoi dati?', + csv: 'File CSV', + excel: 'File Excel', + sqlite: 'File SQLite', + mysql: 'MySQL / MariaDB', + postgres: 'PostgreSQL', + desktopOnly: "Richiede l'app desktop.", + connect: 'Connetti', + doneTitle: 'Dati collegati', + doneBody: "Le tue colonne sono mappate a variabili. Associa una variabile a un campo per mostrare i dati sull'etichetta.", + finish: 'Fatto', + }, variableField: { groupVariables: 'Variabili', groupDateTime: 'Data e ora', diff --git a/src/locales/ja.ts b/src/locales/ja.ts index dd8b7467..211c4ff9 100644 --- a/src/locales/ja.ts +++ b/src/locales/ja.ts @@ -763,7 +763,6 @@ const ja = { exportZpl: 'ZPL エクスポート', menuInitError: 'アプリケーションメニューを読み込めませんでした。アプリを再起動してください。', importCsvData: 'CSVデータをインポート', - importExcelData: 'Excelデータをインポート…', sendToZebraBatchFmt: 'Zebra へ送信 ({n} ラベル)', exportBatchZplFmt: 'バッチZPLをエクスポート ({n} ラベル)', newDesign: '新しいデザイン', @@ -1236,6 +1235,7 @@ const ja = { add: '変数を追加', empty: '変数はまだありません。', emptyExample: '連番のSKU、バッチ番号、顧客名などに便利です。', + connectData: 'データを接続', nameLabel: '名前', fnLabel: 'スロット', defaultLabel: 'デフォルト', @@ -1361,6 +1361,20 @@ const ja = { excelReadErrorFmt: 'Excelファイルを読み取れませんでした:{error}', excelDiscardConfirm: 'インポートしたExcelの行を破棄しますか?マッピングは保存されたままです。', }, + connectData: { + title: 'データを接続', + chooseSource: 'データをどのように接続しますか?', + csv: 'CSV ファイル', + excel: 'Excel ファイル', + sqlite: 'SQLite ファイル', + mysql: 'MySQL / MariaDB', + postgres: 'PostgreSQL', + desktopOnly: 'デスクトップアプリが必要です。', + connect: '接続', + doneTitle: 'データが接続されました', + doneBody: '列は変数にマッピングされています。変数をフィールドにバインドすると、ラベルにデータが表示されます。', + finish: '完了', + }, variableField: { groupVariables: '変数', groupDateTime: '日付と時刻', diff --git a/src/locales/ko.ts b/src/locales/ko.ts index 005a2efb..173b72e0 100644 --- a/src/locales/ko.ts +++ b/src/locales/ko.ts @@ -763,7 +763,6 @@ const ko = { exportZpl: 'ZPL 내보내기', menuInitError: '애플리케이션 메뉴를 불러올 수 없습니다. 앱을 다시 시작하세요.', importCsvData: 'CSV 데이터 가져오기', - importExcelData: 'Excel 데이터 가져오기…', sendToZebraBatchFmt: 'Zebra로 전송 ({n}개 라벨)', exportBatchZplFmt: '일괄 ZPL 내보내기 ({n}개 라벨)', newDesign: '새 디자인', @@ -1236,6 +1235,7 @@ const ko = { add: '변수 추가', empty: '아직 변수가 없습니다.', emptyExample: '순차 SKU, 배치 번호 또는 고객 이름에 유용합니다.', + connectData: '데이터 연결', nameLabel: '이름', fnLabel: '슬롯', defaultLabel: '기본값', @@ -1361,6 +1361,20 @@ const ko = { excelReadErrorFmt: 'Excel 파일을 읽을 수 없습니다: {error}', excelDiscardConfirm: '가져온 Excel 행을 폐기하시겠습니까? 매핑은 저장된 상태로 유지됩니다.', }, + connectData: { + title: '데이터 연결', + chooseSource: '데이터를 어떻게 연결하시겠습니까?', + csv: 'CSV 파일', + excel: 'Excel 파일', + sqlite: 'SQLite 파일', + mysql: 'MySQL / MariaDB', + postgres: 'PostgreSQL', + desktopOnly: '데스크톱 앱이 필요합니다.', + connect: '연결', + doneTitle: '데이터 연결됨', + doneBody: '열이 변수에 매핑되었습니다. 변수를 필드에 바인딩하면 라벨에 데이터가 표시됩니다.', + finish: '완료', + }, variableField: { groupVariables: '변수', groupDateTime: '날짜 및 시간', diff --git a/src/locales/lt.ts b/src/locales/lt.ts index 188f3e1a..1b9c346d 100644 --- a/src/locales/lt.ts +++ b/src/locales/lt.ts @@ -763,7 +763,6 @@ const lt = { exportZpl: 'Export ZPL', menuInitError: 'Nepavyko įkelti programos meniu. Iš naujo paleiskite programą.', importCsvData: 'Importuoti CSV duomenis', - importExcelData: 'Importuoti Excel duomenis…', sendToZebraBatchFmt: 'Siųsti į Zebra ({n} etikečių)', exportBatchZplFmt: 'Eksportuoti paketinį ZPL ({n} etikečių)', newDesign: 'Naujas dizainas', @@ -1236,6 +1235,7 @@ const lt = { add: 'Pridėti kintamąjį', empty: 'Kintamųjų dar nėra.', emptyExample: 'Naudinga nuosekliems SKU, partijų numeriams arba klientų vardams.', + connectData: 'Prijungti duomenis', nameLabel: 'Pavadinimas', fnLabel: 'Lizdas', defaultLabel: 'Numatytasis', @@ -1361,6 +1361,20 @@ const lt = { excelReadErrorFmt: 'Nepavyko nuskaityti Excel failo: {error}', excelDiscardConfirm: 'Atmesti importuotas Excel eilutes? Susiejimas išlieka išsaugotas.', }, + connectData: { + title: 'Prijungti duomenis', + chooseSource: 'Kaip norite prijungti savo duomenis?', + csv: 'CSV failas', + excel: 'Excel failas', + sqlite: 'SQLite failas', + mysql: 'MySQL / MariaDB', + postgres: 'PostgreSQL', + desktopOnly: 'Reikalinga darbalaukio programa.', + connect: 'Prisijungti', + doneTitle: 'Duomenys prijungti', + doneBody: 'Jūsų stulpeliai susieti su kintamaisiais. Susiekite kintamąjį su lauku, kad duomenys būtų rodomi etiketėje.', + finish: 'Atlikta', + }, variableField: { groupVariables: 'Kintamieji', groupDateTime: 'Data ir laikas', diff --git a/src/locales/lv.ts b/src/locales/lv.ts index 4c393f9e..c0ee099b 100644 --- a/src/locales/lv.ts +++ b/src/locales/lv.ts @@ -763,7 +763,6 @@ const lv = { exportZpl: 'Export ZPL', menuInitError: 'Neizdevās ielādēt lietotnes izvēlni. Lūdzu, restartējiet lietotni.', importCsvData: 'Importēt CSV datus', - importExcelData: 'Importēt Excel datus…', sendToZebraBatchFmt: 'Sūtīt uz Zebra ({n} etiķetes)', exportBatchZplFmt: 'Eksportēt pakešu ZPL ({n} etiķetes)', newDesign: 'Jauns dizains', @@ -1236,6 +1235,7 @@ const lv = { add: 'Pievienot mainīgo', empty: 'Vēl nav mainīgo.', emptyExample: 'Noderīgi secīgiem SKU, partijas numuriem vai klientu vārdiem.', + connectData: 'Savienot datus', nameLabel: 'Nosaukums', fnLabel: 'Slots', defaultLabel: 'Noklusējums', @@ -1361,6 +1361,20 @@ const lv = { excelReadErrorFmt: 'Neizdevās nolasīt Excel failu: {error}', excelDiscardConfirm: 'Atmest importētās Excel rindas? Kartēšana paliek saglabāta.', }, + connectData: { + title: 'Savienot datus', + chooseSource: 'Kā vēlaties savienot savus datus?', + csv: 'CSV fails', + excel: 'Excel fails', + sqlite: 'SQLite fails', + mysql: 'MySQL / MariaDB', + postgres: 'PostgreSQL', + desktopOnly: 'Nepieciešama galddatora lietotne.', + connect: 'Savienot', + doneTitle: 'Dati savienoti', + doneBody: 'Jūsu kolonnas ir piesaistītas mainīgajiem. Piesaistiet mainīgo laukam, lai dati tiktu parādīti uz etiķetes.', + finish: 'Gatavs', + }, variableField: { groupVariables: 'Mainīgie', groupDateTime: 'Datums un laiks', diff --git a/src/locales/nl.ts b/src/locales/nl.ts index ce2820e6..d9b26bc3 100644 --- a/src/locales/nl.ts +++ b/src/locales/nl.ts @@ -763,7 +763,6 @@ const nl = { exportZpl: 'Export ZPL', menuInitError: 'Het menu van de applicatie kon niet worden geladen. Start de app opnieuw.', importCsvData: 'CSV-gegevens importeren', - importExcelData: 'Excel-gegevens importeren…', sendToZebraBatchFmt: 'Verzenden naar Zebra ({n} etiketten)', exportBatchZplFmt: 'Batch-ZPL exporteren ({n} etiketten)', newDesign: 'Nieuw ontwerp', @@ -1236,6 +1235,7 @@ const nl = { add: 'Variabele toevoegen', empty: 'Nog geen variabelen.', emptyExample: 'Handig voor opvolgende SKU\\\'s, batchnummers of klantnamen.', + connectData: 'Gegevens verbinden', nameLabel: 'Naam', fnLabel: 'Slot', defaultLabel: 'Standaard', @@ -1361,6 +1361,20 @@ const nl = { excelReadErrorFmt: 'Kan het Excel-bestand niet lezen: {error}', excelDiscardConfirm: 'Geïmporteerde Excel-rijen verwerpen? De toewijzing blijft opgeslagen.', }, + connectData: { + title: 'Gegevens verbinden', + chooseSource: 'Hoe wilt u uw gegevens verbinden?', + csv: 'CSV-bestand', + excel: 'Excel-bestand', + sqlite: 'SQLite-bestand', + mysql: 'MySQL / MariaDB', + postgres: 'PostgreSQL', + desktopOnly: 'Vereist de desktop-app.', + connect: 'Verbinden', + doneTitle: 'Gegevens verbonden', + doneBody: 'Uw kolommen zijn toegewezen aan variabelen. Koppel een variabele aan een veld om de gegevens op uw label te tonen.', + finish: 'Klaar', + }, variableField: { groupVariables: 'Variabelen', groupDateTime: 'Datum en tijd', diff --git a/src/locales/no.ts b/src/locales/no.ts index af837ab8..ffc13ace 100644 --- a/src/locales/no.ts +++ b/src/locales/no.ts @@ -763,7 +763,6 @@ const no = { exportZpl: 'Export ZPL', menuInitError: 'Programmenyen kunne ikke lastes inn. Start appen på nytt.', importCsvData: 'Importer CSV-data', - importExcelData: 'Importer Excel-data…', sendToZebraBatchFmt: 'Send til Zebra ({n} etiketter)', exportBatchZplFmt: 'Eksporter batch-ZPL ({n} etiketter)', newDesign: 'Nytt design', @@ -1236,6 +1235,7 @@ const no = { add: 'Legg til variabel', empty: 'Ingen variabler ennå.', emptyExample: 'Nyttig for sekvensielle SKU-er, batchnummer eller kundenavn.', + connectData: 'Koble til data', nameLabel: 'Navn', fnLabel: 'Plass', defaultLabel: 'Standard', @@ -1361,6 +1361,20 @@ const no = { excelReadErrorFmt: 'Kunne ikke lese Excel-filen: {error}', excelDiscardConfirm: 'Forkast de importerte Excel-radene? Tilordningen forblir lagret.', }, + connectData: { + title: 'Koble til data', + chooseSource: 'Hvordan vil du koble til dataene dine?', + csv: 'CSV-fil', + excel: 'Excel-fil', + sqlite: 'SQLite-fil', + mysql: 'MySQL / MariaDB', + postgres: 'PostgreSQL', + desktopOnly: 'Krever skrivebordsappen.', + connect: 'Koble til', + doneTitle: 'Data tilkoblet', + doneBody: 'Kolonnene dine er knyttet til variabler. Bind en variabel til et felt for å vise dataene på etiketten.', + finish: 'Ferdig', + }, variableField: { groupVariables: 'Variabler', groupDateTime: 'Dato og klokkeslett', diff --git a/src/locales/pl.ts b/src/locales/pl.ts index 9334ef49..7a617c19 100644 --- a/src/locales/pl.ts +++ b/src/locales/pl.ts @@ -763,7 +763,6 @@ const pl = { exportZpl: 'Export ZPL', menuInitError: 'Nie udało się załadować menu aplikacji. Uruchom aplikację ponownie.', importCsvData: 'Importuj dane CSV', - importExcelData: 'Importuj dane Excel…', sendToZebraBatchFmt: 'Wyślij do Zebra ({n} etykiet)', exportBatchZplFmt: 'Eksportuj wsadowy ZPL ({n} etykiet)', newDesign: 'Nowy projekt', @@ -1236,6 +1235,7 @@ const pl = { add: 'Dodaj zmienną', empty: 'Brak zmiennych.', emptyExample: 'Przydatne dla kolejnych SKU, numerów partii lub nazw klientów.', + connectData: 'Połącz dane', nameLabel: 'Nazwa', fnLabel: 'Slot', defaultLabel: 'Domyślny', @@ -1361,6 +1361,20 @@ const pl = { excelReadErrorFmt: 'Nie udało się odczytać pliku Excel: {error}', excelDiscardConfirm: 'Odrzucić zaimportowane wiersze Excel? Mapowanie pozostaje zapisane.', }, + connectData: { + title: 'Połącz dane', + chooseSource: 'Jak chcesz połączyć swoje dane?', + csv: 'Plik CSV', + excel: 'Plik Excel', + sqlite: 'Plik SQLite', + mysql: 'MySQL / MariaDB', + postgres: 'PostgreSQL', + desktopOnly: 'Wymaga aplikacji desktopowej.', + connect: 'Połącz', + doneTitle: 'Dane połączone', + doneBody: 'Twoje kolumny są przypisane do zmiennych. Powiąż zmienną z polem, aby wyświetlić dane na etykiecie.', + finish: 'Gotowe', + }, variableField: { groupVariables: 'Zmienne', groupDateTime: 'Data i godzina', diff --git a/src/locales/pt.ts b/src/locales/pt.ts index 4253e601..fb6a3527 100644 --- a/src/locales/pt.ts +++ b/src/locales/pt.ts @@ -763,7 +763,6 @@ const pt = { exportZpl: 'Export ZPL', menuInitError: 'Não foi possível carregar o menu do aplicativo. Reinicie o aplicativo.', importCsvData: 'Importar dados CSV', - importExcelData: 'Importar dados do Excel…', sendToZebraBatchFmt: 'Enviar para Zebra ({n} etiquetas)', exportBatchZplFmt: 'Exportar ZPL em lote ({n} etiquetas)', newDesign: 'Novo design', @@ -1236,6 +1235,7 @@ const pt = { add: 'Adicionar variável', empty: 'Ainda não há variáveis.', emptyExample: 'Útil para SKUs sequenciais, números de lote ou nomes de clientes.', + connectData: 'Conectar dados', nameLabel: 'Nome', fnLabel: 'Slot', defaultLabel: 'Padrão', @@ -1361,6 +1361,20 @@ const pt = { excelReadErrorFmt: 'Não foi possível ler o arquivo do Excel: {error}', excelDiscardConfirm: 'Descartar as linhas do Excel importadas? O mapeamento permanece salvo.', }, + connectData: { + title: 'Conectar dados', + chooseSource: 'Como você quer conectar seus dados?', + csv: 'Arquivo CSV', + excel: 'Arquivo Excel', + sqlite: 'Arquivo SQLite', + mysql: 'MySQL / MariaDB', + postgres: 'PostgreSQL', + desktopOnly: 'Requer o aplicativo de desktop.', + connect: 'Conectar', + doneTitle: 'Dados conectados', + doneBody: 'Suas colunas estão mapeadas para variáveis. Vincule uma variável a um campo para mostrar os dados na sua etiqueta.', + finish: 'Concluído', + }, variableField: { groupVariables: 'Variáveis', groupDateTime: 'Data e hora', diff --git a/src/locales/ro.ts b/src/locales/ro.ts index 8bcef6ae..1911b085 100644 --- a/src/locales/ro.ts +++ b/src/locales/ro.ts @@ -763,7 +763,6 @@ const ro = { exportZpl: 'Export ZPL', menuInitError: 'Meniul aplicației nu a putut fi încărcat. Reporniți aplicația.', importCsvData: 'Importă date CSV', - importExcelData: 'Importă date Excel…', sendToZebraBatchFmt: 'Trimite la Zebra ({n} etichete)', exportBatchZplFmt: 'Exportă ZPL în lot ({n} etichete)', newDesign: 'Design nou', @@ -1236,6 +1235,7 @@ const ro = { add: 'Adaugă variabilă', empty: 'Încă nu există variabile.', emptyExample: 'Util pentru SKU-uri secvențiale, numere de lot sau nume de clienți.', + connectData: 'Conectează date', nameLabel: 'Nume', fnLabel: 'Slot', defaultLabel: 'Implicit', @@ -1361,6 +1361,20 @@ const ro = { excelReadErrorFmt: 'Fișierul Excel nu a putut fi citit: {error}', excelDiscardConfirm: 'Renunți la rândurile Excel importate? Maparea rămâne salvată.', }, + connectData: { + title: 'Conectează date', + chooseSource: 'Cum doriți să conectați datele?', + csv: 'Fișier CSV', + excel: 'Fișier Excel', + sqlite: 'Fișier SQLite', + mysql: 'MySQL / MariaDB', + postgres: 'PostgreSQL', + desktopOnly: 'Necesită aplicația desktop.', + connect: 'Conectare', + doneTitle: 'Date conectate', + doneBody: 'Coloanele dvs. sunt asociate cu variabile. Asociați o variabilă cu un câmp pentru a afișa datele pe etichetă.', + finish: 'Gata', + }, variableField: { groupVariables: 'Variabile', groupDateTime: 'Dată și oră', diff --git a/src/locales/sk.ts b/src/locales/sk.ts index 751188b6..7e7f7043 100644 --- a/src/locales/sk.ts +++ b/src/locales/sk.ts @@ -763,7 +763,6 @@ const sk = { exportZpl: 'Export ZPL', menuInitError: 'Ponuku aplikácie sa nepodarilo načítať. Reštartujte aplikáciu.', importCsvData: 'Importovať údaje CSV', - importExcelData: 'Importovať údaje z Excelu…', sendToZebraBatchFmt: 'Odoslať na Zebra ({n} etikiet)', exportBatchZplFmt: 'Exportovať dávkové ZPL ({n} etikiet)', newDesign: 'Nový návrh', @@ -1236,6 +1235,7 @@ const sk = { add: 'Pridať premennú', empty: 'Zatiaľ žiadne premenné.', emptyExample: 'Hodí sa na sekvenčné SKU, čísla šarží alebo mená zákazníkov.', + connectData: 'Pripojiť údaje', nameLabel: 'Názov', fnLabel: 'Slot', defaultLabel: 'Predvolené', @@ -1361,6 +1361,20 @@ const sk = { excelReadErrorFmt: 'Súbor Excel sa nepodarilo načítať: {error}', excelDiscardConfirm: 'Zahodiť importované riadky Excelu? Mapovanie zostáva uložené.', }, + connectData: { + title: 'Pripojiť údaje', + chooseSource: 'Ako chcete pripojiť svoje údaje?', + csv: 'Súbor CSV', + excel: 'Súbor Excel', + sqlite: 'Súbor SQLite', + mysql: 'MySQL / MariaDB', + postgres: 'PostgreSQL', + desktopOnly: 'Vyžaduje desktopovú aplikáciu.', + connect: 'Pripojiť', + doneTitle: 'Údaje pripojené', + doneBody: 'Vaše stĺpce sú namapované na premenné. Priraďte premennú k poľu, aby sa údaje zobrazili na štítku.', + finish: 'Hotovo', + }, variableField: { groupVariables: 'Premenné', groupDateTime: 'Dátum a čas', diff --git a/src/locales/sl.ts b/src/locales/sl.ts index 78f8e83c..d13ac257 100644 --- a/src/locales/sl.ts +++ b/src/locales/sl.ts @@ -763,7 +763,6 @@ const sl = { exportZpl: 'Export ZPL', menuInitError: 'Menija aplikacije ni bilo mogoče naložiti. Znova zaženite aplikacijo.', importCsvData: 'Uvozi podatke CSV', - importExcelData: 'Uvozi podatke Excel…', sendToZebraBatchFmt: 'Pošlji na Zebra ({n} etiket)', exportBatchZplFmt: 'Izvozi paketni ZPL ({n} etiket)', newDesign: 'Nov dizajn', @@ -1236,6 +1235,7 @@ const sl = { add: 'Dodaj spremenljivko', empty: 'Še ni spremenljivk.', emptyExample: 'Uporabno za zaporedne SKU, številke serij ali imena strank.', + connectData: 'Poveži podatke', nameLabel: 'Ime', fnLabel: 'Reža', defaultLabel: 'Privzeto', @@ -1361,6 +1361,20 @@ const sl = { excelReadErrorFmt: 'Datoteke Excel ni bilo mogoče prebrati: {error}', excelDiscardConfirm: 'Zavržem uvožene vrstice Excel? Preslikava ostane shranjena.', }, + connectData: { + title: 'Poveži podatke', + chooseSource: 'Kako želite povezati svoje podatke?', + csv: 'Datoteka CSV', + excel: 'Datoteka Excel', + sqlite: 'Datoteka SQLite', + mysql: 'MySQL / MariaDB', + postgres: 'PostgreSQL', + desktopOnly: 'Zahteva namizno aplikacijo.', + connect: 'Poveži', + doneTitle: 'Podatki povezani', + doneBody: 'Vaši stolpci so povezani s spremenljivkami. Povežite spremenljivko s poljem, da se podatki prikažejo na nalepki.', + finish: 'Končano', + }, variableField: { groupVariables: 'Spremenljivke', groupDateTime: 'Datum in čas', diff --git a/src/locales/sr.ts b/src/locales/sr.ts index 04376876..4bd4d473 100644 --- a/src/locales/sr.ts +++ b/src/locales/sr.ts @@ -763,7 +763,6 @@ const sr = { exportZpl: 'Export ZPL', menuInitError: 'Мени апликације није могао да се учита. Поново покрените апликацију.', importCsvData: 'Увоз CSV података', - importExcelData: 'Увоз Excel података…', sendToZebraBatchFmt: 'Пошаљи на Zebra ({n} етикета)', exportBatchZplFmt: 'Извоз групног ZPL-а ({n} етикета)', newDesign: 'Нови дизајн', @@ -1236,6 +1235,7 @@ const sr = { add: 'Додај променљиву', empty: 'Још нема променљивих.', emptyExample: 'Корисно за секвенцијалне SKU, бројеве серија или имена купаца.', + connectData: 'Повежи податке', nameLabel: 'Назив', fnLabel: 'Утор', defaultLabel: 'Подразумевано', @@ -1361,6 +1361,20 @@ const sr = { excelReadErrorFmt: 'Excel датотека није могла да се прочита: {error}', excelDiscardConfirm: 'Одбацити увезене Excel редове? Мапирање остаје сачувано.', }, + connectData: { + title: 'Повежи податке', + chooseSource: 'Како желите да повежете своје податке?', + csv: 'CSV датотека', + excel: 'Excel датотека', + sqlite: 'SQLite датотека', + mysql: 'MySQL / MariaDB', + postgres: 'PostgreSQL', + desktopOnly: 'Zahteva desktop aplikaciju.', + connect: 'Повежи', + doneTitle: 'Подаци повезани', + doneBody: 'Ваше колоне су мапиране на променљиве. Повежите променљиву са пољем да бисте приказали податке на етикети.', + finish: 'Готово', + }, variableField: { groupVariables: 'Променљиве', groupDateTime: 'Датум и време', diff --git a/src/locales/sv.ts b/src/locales/sv.ts index b7ead93e..caef377a 100644 --- a/src/locales/sv.ts +++ b/src/locales/sv.ts @@ -763,7 +763,6 @@ const sv = { exportZpl: 'Export ZPL', menuInitError: 'Programmets meny kunde inte laddas. Starta om appen.', importCsvData: 'Importera CSV-data', - importExcelData: 'Importera Excel-data…', sendToZebraBatchFmt: 'Skicka till Zebra ({n} etiketter)', exportBatchZplFmt: 'Exportera batch-ZPL ({n} etiketter)', newDesign: 'Nytt design', @@ -1236,6 +1235,7 @@ const sv = { add: 'Lägg till variabel', empty: 'Inga variabler än.', emptyExample: 'Användbart för sekventiella SKU:er, batchnummer eller kundnamn.', + connectData: 'Anslut data', nameLabel: 'Namn', fnLabel: 'Plats', defaultLabel: 'Standard', @@ -1361,6 +1361,20 @@ const sv = { excelReadErrorFmt: 'Kunde inte läsa Excel-filen: {error}', excelDiscardConfirm: 'Kasta de importerade Excel-raderna? Mappningen förblir sparad.', }, + connectData: { + title: 'Anslut data', + chooseSource: 'Hur vill du ansluta dina data?', + csv: 'CSV-fil', + excel: 'Excel-fil', + sqlite: 'SQLite-fil', + mysql: 'MySQL / MariaDB', + postgres: 'PostgreSQL', + desktopOnly: 'Kräver skrivbordsappen.', + connect: 'Anslut', + doneTitle: 'Data anslutna', + doneBody: 'Dina kolumner är kopplade till variabler. Bind en variabel till ett fält för att visa data på etiketten.', + finish: 'Klar', + }, variableField: { groupVariables: 'Variabler', groupDateTime: 'Datum och tid', diff --git a/src/locales/tr.ts b/src/locales/tr.ts index 318468d5..a1de91fa 100644 --- a/src/locales/tr.ts +++ b/src/locales/tr.ts @@ -763,7 +763,6 @@ const tr = { exportZpl: 'ZPL Dışa Aktar', menuInitError: 'Uygulama menüsü yüklenemedi. Lütfen uygulamayı yeniden başlatın.', importCsvData: 'CSV verisi içe aktar', - importExcelData: 'Excel verisi içe aktar…', sendToZebraBatchFmt: "Zebra'ya gönder ({n} etiket)", exportBatchZplFmt: 'Toplu ZPL dışa aktar ({n} etiket)', newDesign: 'Yeni Tasarım', @@ -1236,6 +1235,7 @@ const tr = { add: 'Değişken ekle', empty: 'Henüz değişken yok.', emptyExample: 'Sıralı SKU\\\'lar, parti numaraları veya müşteri adları için kullanışlıdır.', + connectData: 'Veri bağla', nameLabel: 'Ad', fnLabel: 'Yuva', defaultLabel: 'Varsayılan', @@ -1361,6 +1361,20 @@ const tr = { excelReadErrorFmt: 'Excel dosyası okunamadı: {error}', excelDiscardConfirm: 'İçe aktarılan Excel satırları atılsın mı? Eşleme kayıtlı kalır.', }, + connectData: { + title: 'Veri bağla', + chooseSource: 'Verilerinizi nasıl bağlamak istiyorsunuz?', + csv: 'CSV dosyası', + excel: 'Excel dosyası', + sqlite: 'SQLite dosyası', + mysql: 'MySQL / MariaDB', + postgres: 'PostgreSQL', + desktopOnly: 'Masaüstü uygulamasını gerektirir.', + connect: 'Bağlan', + doneTitle: 'Veri bağlandı', + doneBody: 'Sütunlarınız değişkenlere eşlendi. Verileri etiketinizde göstermek için bir değişkeni bir alana bağlayın.', + finish: 'Bitti', + }, variableField: { groupVariables: 'Değişkenler', groupDateTime: 'Tarih ve saat', diff --git a/src/locales/zh-hans.ts b/src/locales/zh-hans.ts index 7b27727c..4d91e81f 100644 --- a/src/locales/zh-hans.ts +++ b/src/locales/zh-hans.ts @@ -763,7 +763,6 @@ const zhHans = { exportZpl: '导出 ZPL', menuInitError: '无法加载应用程序菜单。请重新启动应用。', importCsvData: '导入 CSV 数据', - importExcelData: '导入 Excel 数据…', sendToZebraBatchFmt: '发送到 Zebra({n} 个标签)', exportBatchZplFmt: '导出批量 ZPL ({n} 个标签)', newDesign: '新建设计', @@ -1236,6 +1235,7 @@ const zhHans = { add: '添加变量', empty: '暂无变量。', emptyExample: '适合连续的 SKU、批次号或客户名称。', + connectData: '连接数据', nameLabel: '名称', fnLabel: '槽位', defaultLabel: '默认值', @@ -1361,6 +1361,20 @@ const zhHans = { excelReadErrorFmt: '无法读取 Excel 文件:{error}', excelDiscardConfirm: '放弃已导入的 Excel 行?映射仍会保留。', }, + connectData: { + title: '连接数据', + chooseSource: '您想如何连接数据?', + csv: 'CSV 文件', + excel: 'Excel 文件', + sqlite: 'SQLite 文件', + mysql: 'MySQL / MariaDB', + postgres: 'PostgreSQL', + desktopOnly: '需要桌面应用。', + connect: '连接', + doneTitle: '数据已连接', + doneBody: '您的列已映射到变量。将变量绑定到字段即可在标签上显示数据。', + finish: '完成', + }, variableField: { groupVariables: '变量', groupDateTime: '日期和时间', diff --git a/src/locales/zh-hant.ts b/src/locales/zh-hant.ts index 3143a6de..408eaa3d 100644 --- a/src/locales/zh-hant.ts +++ b/src/locales/zh-hant.ts @@ -763,7 +763,6 @@ const zhHant = { exportZpl: '匯出 ZPL', menuInitError: '無法載入應用程式選單。請重新啟動應用程式。', importCsvData: '匯入 CSV 資料', - importExcelData: '匯入 Excel 資料…', sendToZebraBatchFmt: '傳送至 Zebra({n} 個標籤)', exportBatchZplFmt: '匯出批次 ZPL ({n} 個標籤)', newDesign: '新增設計', @@ -1236,6 +1235,7 @@ const zhHant = { add: '新增變數', empty: '尚無變數。', emptyExample: '適合連續的 SKU、批次號或客戶名稱。', + connectData: '連接資料', nameLabel: '名稱', fnLabel: '槽位', defaultLabel: '預設值', @@ -1361,6 +1361,20 @@ const zhHant = { excelReadErrorFmt: '無法讀取 Excel 檔案:{error}', excelDiscardConfirm: '放棄已匯入的 Excel 列?對應仍會保留。', }, + connectData: { + title: '連接資料', + chooseSource: '您想如何連接資料?', + csv: 'CSV 檔案', + excel: 'Excel 檔案', + sqlite: 'SQLite 檔案', + mysql: 'MySQL / MariaDB', + postgres: 'PostgreSQL', + desktopOnly: '需要桌面應用程式。', + connect: '連接', + doneTitle: '資料已連接', + doneBody: '您的欄位已對應到變數。將變數繫結到欄位即可在標籤上顯示資料。', + finish: '完成', + }, variableField: { groupVariables: '變數', groupDateTime: '日期和時間', diff --git a/src/store/datasetActions.replace.test.ts b/src/store/datasetActions.replace.test.ts new file mode 100644 index 00000000..d81c7730 --- /dev/null +++ b/src/store/datasetActions.replace.test.ts @@ -0,0 +1,84 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { useLabelStore } from './labelStore'; +import { loadFetchedDataset, settleDatasetReplace } from './datasetActions'; +import type { DatasetInput } from '@zplab/core/types/DataSource'; + +const fetched = (name: string, headers: string[]): DatasetInput => ({ + headers, + rows: [headers.map((h) => `${h}-1`)], + source: { + kind: 'db', + profileId: 'p1', + profileName: name, + table: 't', + fetchedAt: '2026-01-01T00:00:00Z', + rowCount: 1, + truncated: false, + }, +}); + +beforeEach(() => { + useLabelStore.setState({ + dataset: null, + columnMapping: null, + variables: [], + pendingDatasetReplace: null, + } as never); +}); + +describe('loadFetchedDataset replace confirm', () => { + it('loads immediately when no dataset exists', async () => { + const ok = await loadFetchedDataset(async () => fetched('fresh', ['a'])); + expect(ok).toBe(true); + expect(useLabelStore.getState().dataset?.headers).toEqual(['a']); + expect(useLabelStore.getState().pendingDatasetReplace).toBeNull(); + }); + + it('replaces silently when the current mapping carries over', async () => { + useLabelStore.getState().loadDataset(fetched('old', ['sku'])); + useLabelStore.setState({ + variables: [{ id: 'v1', name: 'sku', fnNumber: 1, defaultValue: '' }], + columnMapping: { bindings: { v1: 'sku' }, headerSnapshot: ['sku'] }, + } as never); + const ok = await loadFetchedDataset(async () => fetched('new', ['sku'])); + expect(ok).toBe(true); + expect(useLabelStore.getState().pendingDatasetReplace).toBeNull(); + }); + + it('defers an incompatible replace behind the confirm and applies on confirm', async () => { + useLabelStore.getState().loadDataset(fetched('old', ['sku'])); + const promise = loadFetchedDataset(async () => fetched('new', ['other'])); + // Not applied yet: the confirm payload is up instead. + await Promise.resolve(); + expect(useLabelStore.getState().dataset?.source).toMatchObject({ profileName: 'old' }); + expect(useLabelStore.getState().pendingDatasetReplace).toMatchObject({ + oldName: expect.stringContaining('old'), + newName: expect.stringContaining('new'), + }); + settleDatasetReplace(true); + await expect(promise).resolves.toBe(true); + expect(useLabelStore.getState().dataset?.source).toMatchObject({ profileName: 'new' }); + expect(useLabelStore.getState().pendingDatasetReplace).toBeNull(); + }); + + it('keeps the old dataset when the confirm is declined', async () => { + useLabelStore.getState().loadDataset(fetched('old', ['sku'])); + const promise = loadFetchedDataset(async () => fetched('new', ['other'])); + await Promise.resolve(); + settleDatasetReplace(false); + await expect(promise).resolves.toBe(false); + expect(useLabelStore.getState().dataset?.source).toMatchObject({ profileName: 'old' }); + expect(useLabelStore.getState().pendingDatasetReplace).toBeNull(); + }); + + it('drops a confirmed replace whose data context went stale meanwhile', async () => { + useLabelStore.getState().loadDataset(fetched('old', ['sku'])); + const promise = loadFetchedDataset(async () => fetched('new', ['other'])); + await Promise.resolve(); + // Foreign load while the confirm is open supersedes the pending fetch. + useLabelStore.getState().loadDataset(fetched('foreign', ['x'])); + settleDatasetReplace(true); + await expect(promise).resolves.toBe(false); + expect(useLabelStore.getState().dataset?.source).toMatchObject({ profileName: 'foreign' }); + }); +}); diff --git a/src/store/datasetActions.ts b/src/store/datasetActions.ts index 7a6fbd1f..21aa14d1 100644 --- a/src/store/datasetActions.ts +++ b/src/store/datasetActions.ts @@ -1,4 +1,5 @@ import { isMappingCompatibleWith, type ColumnMapping } from '@zplab/core/types/Variable'; +import { datasetDisplayName } from '@zplab/core/types/DataSource'; import { useLabelStore } from './labelStore'; import type { DatasetInput } from '@zplab/core/types/DataSource'; @@ -38,15 +39,51 @@ function applyFetchedDataset(input: DatasetInput): void { if (headerless || needsMappingReview(columnMapping, input.headers)) openMappingModal(); } +// Fetched input + resolver held module-side while the replace confirm is open; +// the store carries only the render payload (names), keeping it serializable. +let pendingReplace: { + input: DatasetInput; + token: number; + resolve: (applied: boolean) => void; +} | null = null; + /** The one gated entry for async dataset fetches: commits only if the context - * is still `token` (default: sampled now, or pass a pre-sampled one for an op - * that began earlier), so a stale fetch can't clobber. False if superseded. */ + * is still `token` (false if superseded). Replacing a mapping-incompatible + * dataset defers behind a user confirm; the promise resolves with the decision. */ export async function loadFetchedDataset( producer: () => Promise, token: number = currentDataContext(), ): Promise { const input = await producer(); if (!isCurrentDataContext(token)) return false; + const { dataset, columnMapping } = useLabelStore.getState(); + const headerless = columnMapping?.parseOptions?.hasHeaderRow === false; + if (dataset && (headerless || needsMappingReview(columnMapping, input.headers))) { + return new Promise((resolve) => { + pendingReplace?.resolve(false); // a newer fetch supersedes an open confirm + pendingReplace = { input, token, resolve }; + useLabelStore.getState().setPendingDatasetReplace({ + oldName: datasetDisplayName(dataset.source), + newName: datasetDisplayName(input.source), + token, + }); + }); + } applyFetchedDataset(input); return true; } + +/** Commit or drop the fetch held behind the replace confirm. Applies only if + * the data context is still the one the fetch was sampled in. */ +export function settleDatasetReplace(confirmed: boolean): void { + const pending = pendingReplace; + pendingReplace = null; + useLabelStore.getState().setPendingDatasetReplace(null); + if (!pending) return; + if (confirmed && isCurrentDataContext(pending.token)) { + applyFetchedDataset(pending.input); + pending.resolve(true); + } else { + pending.resolve(false); + } +} diff --git a/src/store/slices/dataSlice.ts b/src/store/slices/dataSlice.ts index 5411fef0..6a8a3f40 100644 --- a/src/store/slices/dataSlice.ts +++ b/src/store/slices/dataSlice.ts @@ -41,10 +41,17 @@ export interface DataSlice { * auto-open trigger (after import, on header mismatch) and the * manual-open trigger share one flag without prop drilling. */ mappingModalOpen: boolean; + /** The guided connect-data wizard is open; while true AppShell suppresses the + * standalone mapping modal so the wizard owns the mapping step. */ + connectWizardOpen: boolean; /** Monotonic epoch of the current data context; bumped by every load/clear/ * cancel and by loadDesign. Async producers and pending dialogs capture it * and skip their commit if it changed. Session-only. */ datasetFetchToken: number; + /** Render payload for the fetched-dataset replace confirm; the deferred + * input itself stays module-side in datasetActions (not serializable). + * `token` = the data-context epoch it was raised in, for staleness gating. */ + pendingDatasetReplace: { oldName: string; newName: string; token: number } | null; /** Replace the entire dataset and reset the active row to 0. */ loadDataset: (result: DatasetInput) => void; @@ -68,6 +75,20 @@ export interface DataSlice { }) => void; openMappingModal: () => void; closeMappingModal: () => void; + openConnectWizard: () => void; + closeConnectWizard: () => void; + setPendingDatasetReplace: ( + payload: { oldName: string; newName: string; token: number } | null, + ) => void; + /** Put the data context back to a captured state (wizard abort): dataset, + * design link and mapping together, bumping the epoch like any load. */ + restoreDataSnapshot: (snap: DataSnapshot) => void; +} + +export interface DataSnapshot { + dataset: LabelState['dataset']; + dataSourceRef: DbSourceRef | null; + columnMapping: ColumnMapping | null; } export const createDataSlice: StateCreator = (set, get) => ({ @@ -75,7 +96,9 @@ export const createDataSlice: StateCreator = (set dataSourceRef: null, columnMapping: null, mappingModalOpen: false, + connectWizardOpen: false, datasetFetchToken: 0, + pendingDatasetReplace: null, // Replacing the dataset invalidates any active preview snapshot; tear the // preview down and apply, rather than no-op under the lock (which would let @@ -179,4 +202,19 @@ export const createDataSlice: StateCreator = (set openMappingModal: () => set({ mappingModalOpen: true }), closeMappingModal: () => set({ mappingModalOpen: false }), + // Clearing the flag on open dismisses a standalone review explicitly (the + // wizard's mapping step supersedes it); clearing on close keeps the flag the + // wizard's own loads set (applyImport -> openMappingModal) from popping after. + openConnectWizard: () => set({ connectWizardOpen: true, mappingModalOpen: false }), + closeConnectWizard: () => set({ connectWizardOpen: false, mappingModalOpen: false }), + setPendingDatasetReplace: (payload) => set({ pendingDatasetReplace: payload }), + restoreDataSnapshot: (snap) => { + get().exitPreviewMode(); + set((s) => ({ + dataset: snap.dataset, + dataSourceRef: snap.dataSourceRef, + columnMapping: snap.columnMapping, + datasetFetchToken: s.datasetFetchToken + 1, + })); + }, }); diff --git a/src/store/slices/labelConfigSlice.ts b/src/store/slices/labelConfigSlice.ts index ae4323ff..db8eb01f 100644 --- a/src/store/slices/labelConfigSlice.ts +++ b/src/store/slices/labelConfigSlice.ts @@ -98,6 +98,7 @@ export const createLabelConfigSlice: StateCreator