diff --git a/PRIVACY.md b/PRIVACY.md index bc7f88b0..9bb3f99b 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -12,7 +12,7 @@ ZPLab is a label designer that runs on your device. It has no user accounts, no ZPLab only connects to the following destinations: - **Printers and data sources you configure.** Labels go to the printer you choose, over the network or USB, or through the system print spooler or the Zebra Browser Print agent on your computer. Variable data is read from the files, databases or servers you connect. -- **Labelary rendering.** Labelary is the default renderer for previews, **Print as Image** and PDF export. ZPLab asks for your consent before the first request to the public service. After that, each of these actions sends the label's ZPL to `api.labelary.com`, including values filled in from connected data sources. Labelary is an independent third-party service with its own [terms and privacy practices](https://labelary.com/service.html). If you set your own endpoint, requests go there instead. You can withdraw your consent or disable Labelary entirely under **File → Settings… → App → Preview**. In the desktop app, a connected printer can render instead. +- **Labelary rendering.** Labelary is the default renderer for previews, the **Other printer** way under **File → Output** and PDF export. ZPLab asks for your consent before the first request to the public service. After that, each of these actions sends the label's ZPL to `api.labelary.com`, including values filled in from connected data sources. Labelary is an independent third-party service with its own [terms and privacy practices](https://labelary.com/service.html). If you set your own endpoint, requests go there instead. You can withdraw your consent or disable Labelary entirely under **File → Settings… → App → Preview**. In the desktop app, a connected printer can render instead. - **Updates.** A build downloaded from GitHub checks `github.com` for a new version at every start and downloads an update from GitHub's release hosts only when you choose to install it. The Store version and other packaged installs update through their package and skip this check. - **MCP server, optional.** When you start it, the server listens on `127.0.0.1` only and requires a token that the app generates. An assistant you connect can read and edit the open label, so its content goes to whatever service that assistant uses. - **The web version.** Your browser loads `app.zplab.org` from GitHub Pages, which is covered by [GitHub's privacy statement](https://docs.github.com/site-policy/privacy-policies/github-general-privacy-statement). diff --git a/README.md b/README.md index 07dafbe4..053270ee 100644 --- a/README.md +++ b/README.md @@ -81,9 +81,9 @@ Select several objects with Shift-click or a lasso. Position and size changes ap The **ZPL** panel at the bottom shows the generated ZPL, **Copy** puts it on the clipboard. On desktop the panel names the configured printer. A click shows its state and offers to read its configuration. The canvas is ZPLab's rendering of the label. A second rendering comes from the preview renderer under **File → Settings… → App → Preview**: the connected printer (desktop) or, with consent, the third-party service [Labelary](https://labelary.com/). **Preview** shows it for comparison. - **File → Export ZPL:** saves a `.zpl` file, or `.prn` where the save dialog offers file types -- **File → Send to Zebra Printer:** opens the send dialog. It sends over the network, through the Zebra Browser Print agent in the browser, or through the system spooler or USB on desktop. On desktop, **Check printer** asks the printer for its state over the network or USB. -- **File → Export PDF:** saves one PDF page per label page, rendered by the preview renderer. With the renderer off, ZPLab captures the canvas, so the PDF holds the current page only. A batch PDF needs the renderer and holds one page per data row. -- **File → Print as Image (browser):** renders the current page with the same renderer, then opens the browser print dialog +- **File → Output:** opens the output dialog with two destinations. + - **Print** sends the code to a Zebra over the network, through the Zebra Browser Print agent in the browser, or through the system spooler or USB on desktop. Its **Other printer** way renders the label and hands it to the operating system's print dialog, which reaches any printer with a driver. On desktop, **Check printer** asks the printer for its state over the network or USB. + - **File** writes a **PDF** with one page per label page, rendered by the preview renderer. With the renderer off, ZPLab captures the canvas, so the PDF holds the current page only. The **Scope** option **One label per data row** needs the renderer and holds one page per row. **PNG** saves the canvas drawing of the current label. ### 5. Save the design @@ -139,7 +139,7 @@ The **Fonts** tab uploads TrueType fonts and assigns their `^CW` alias. On the d ### Batch printing -**File → Import CSV data** (web) or **File → Connect data** (desktop) loads a CSV. Assign a column to each variable in the mapping dialog. **File → Export batch ZPL** or **File → Send to Zebra Printer** then produce one label per row. Export names the row count, the send item names the labels the printer will produce. +**File → Import CSV data** (web) or **File → Connect data** (desktop) loads a CSV. Assign a column to each variable in the mapping dialog. **File → Export batch ZPL** writes one label per row and names the row count. **File → Output** sends the same rows to the printer, and the dialog names the labels it will produce. The batch stores the page as a format on the printer and recalls it per row. A page that names a stored format keeps that name when the name itself is at most 8 characters. Longer names fall back to `R:LBL.ZPL`. @@ -151,11 +151,11 @@ On desktop, **Connect data** also reads an Excel worksheet. **File → Settings - The **Objects** group selects the fonts and graphics the **Setup Script** uploads. - It lists cached images and deletes the unused ones. -- On desktop it reads what the printer holds, so every row says where its file stands and a bar shows how full each drive is. Show a stored graphic or let the printer draw a font sample. Delete objects on the printer. +- On desktop it reads what the printer holds, so every row says where its file stands. A bar shows how full each drive is. Show a stored graphic or let the printer draw a font sample. Delete objects on the printer. - Saved designs and label exports exclude **Setup Script** values such as printer name and locale. - **Clear** resets the **Setup Script** values and keeps the uploads. -The **App → Printer** tab sets how labels are sent and to which printer. **Send to Zebra Printer** opens with that method selected. The **App → Preview** tab selects the renderer: the connected printer (desktop), Labelary, or off. It takes a premium Labelary endpoint and API key. The key is stored in the OS keychain on desktop and in browser storage on the web. +The **App → Printer** tab sets how labels are sent and to which printer. **File → Output** opens with that method selected. The **App → Preview** tab selects the renderer: the connected printer (desktop), Labelary, or off. It takes a premium Labelary endpoint and API key. The key is stored in the OS keychain on desktop and in browser storage on the web. ### MCP server diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index a22b174a..fa1d6569 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -7,6 +7,7 @@ ], "permissions": [ "core:default", + "core:webview:allow-print", "window-state:default", "updater:default", "process:allow-restart", diff --git a/src/components/AppShell.tsx b/src/components/AppShell.tsx index cd39f1f6..94df63dc 100644 --- a/src/components/AppShell.tsx +++ b/src/components/AppShell.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useRef, type ComponentType, type SVGProps } from "react"; +import { useEffect, useRef, type ComponentType, type SVGProps } from "react"; import { DndContext, PointerSensor, useSensor, useSensors } from "@dnd-kit/core"; import { makePaletteCollision } from "../dnd/collision"; import { ObjectPalette } from "./Palette/ObjectPalette"; @@ -14,7 +14,7 @@ import { ZplImportModal } from "./Output/ZplImportModal"; import { VariableMappingModal } from "./Variables/VariableMappingModal"; import { ConnectDataWizard } from "./Variables/ConnectDataWizard"; import { CsvImportConfirmDialog } from "./Variables/CsvImportConfirmDialog"; -import { PrintToZebraDialog } from "./Output/PrintToZebraDialog"; +import { OutputDialog } from "./Output/OutputDialog"; import { DropdownMenu, DropdownItem, @@ -34,24 +34,22 @@ import { TableCellsIcon, PrinterIcon, Cog6ToothIcon, - PaperAirplaneIcon, XMarkIcon, SunIcon, MoonIcon, GlobeAltIcon, } from "@heroicons/react/16/solid"; -import { useLabelStore, useHistory, selectLabelaryNoticeRequired, selectEffectivePreviewProvider, selectEditorFrozen, selectSourceEditing, selectSourceEditDirty, selectDocumentEmits, selectBatchPrintCount } from "../store/labelStore"; +import { useLabelStore, useHistory, selectEffectivePreviewProvider, selectEditorFrozen, selectSourceEditing, selectSourceEditDirty, selectDocumentEmits } from "../store/labelStore"; import { datasetTimestamp } from "@zplab/core/types/DataSource"; import { isCurrentDataContext, settleDatasetReplace } from "../store/datasetActions"; import { ConfirmDialog } from "./ui/ConfirmDialog"; import { formatTemplate } from "../lib/formatTemplate"; -import { buildMenuModel, type MenuItemId } from "../lib/menuModel"; +import { buildMenuModel, type MenuFlags, type MenuItemId } from "../lib/menuModel"; import { NativeMenuBridge } from "./NativeMenuBridge"; import type { MenuHandlers } from "../hooks/useNativeMenu"; import { isDesktopShell, isMacDesktop } from "../lib/platform"; import { acceptAttr, DESIGN_FILTER, CSV_FILTER } from "../lib/fileDialogs"; import { openExternal, REPO_URL } from "../lib/openExternal"; -import { LabelaryNoticeModal } from "./Output/LabelaryNoticeModal"; import { PdfExportDialog } from "./Output/PdfExportDialog"; import { usePdfExport } from "../hooks/usePdfExport"; import { PrinterSettingsModal } from "./PrinterSettings/PrinterSettingsModal"; @@ -80,14 +78,11 @@ const MENU_ICONS: Partial s.label); - const batchPrintCount = useLabelStore(selectBatchPrintCount); const pages = useLabelStore((s) => s.pages); const selectObject = useLabelStore((s) => s.selectObject); const addPage = useLabelStore((s) => s.addPage); @@ -111,12 +105,8 @@ export function AppShell() { const restoreReplacedDesign = useLabelStore((s) => s.restoreReplacedDesign); const dismissReplacedDesign = useLabelStore((s) => s.dismissReplacedDesign); const sourceEditDirty = useLabelStore(selectSourceEditDirty); - const noticeRequired = useLabelStore(selectLabelaryNoticeRequired); const renderer = useLabelStore(selectEffectivePreviewProvider); const pageCount = useLabelStore((s) => s.pages.length); - // The Labelary notice gates every Labelary render, so the action waits here until the user continues. - const [afterNotice, setAfterNotice] = useState<(() => void) | null>(null); - const withNotice = (action: () => void) => (renderer === "labelary" && noticeRequired ? setAfterNotice(() => action) : action()); const appUpdate = useLabelStore((s) => s.appUpdate); const checkForAppUpdate = useLabelStore((s) => s.checkForAppUpdate); const installAppUpdate = useLabelStore((s) => s.installAppUpdate); @@ -191,15 +181,16 @@ export function AppShell() { showZplImport, openZplImport, closeZplImport, - showZebraPrint, - openZebraPrint, - closeZebraPrint, + outputSource, + openOutput, + closeOutput, currentZpl, handleDownload, handleExportBatch, canBatchExport, batchRowCount, handlePrint, + handleExportPng, } = useZplImportExport(canvasRef); // The panel may grow until the main row has no height left. const mainRowRef = useRef(null); @@ -207,15 +198,14 @@ export function AppShell() { const leftPanel = useCollapsiblePanel("zpl-panel-left"); const rightPanel = useCollapsiblePanel("zpl-panel-right"); - // One menu model for both surfaces: the DOM dropdown (web header) and the - // native OS menu (desktop, where the header row is not rendered at all). - const menuModel = buildMenuModel(t, { + // One menu model for both surfaces: the DOM dropdown in the web header, and the native OS menu on + // the desktop, where the header row is not rendered at all. + const menuFlags: MenuFlags = { hasObjects, documentEmits, sourceEditing, canBatchExport, batchRowCount, - batchPrintCount, connectDataWizard: isDesktopShell, canBatchPdf: renderer !== "none", pdfCurrentPageOnly: renderer === "none" && pageCount > 1, @@ -223,7 +213,8 @@ export function AppShell() { canRedo, // On macOS quit lives in the app submenu (Cmd+Q), not the File section. includeQuit: isDesktopShell && !isMacDesktop, - }); + }; + const menuModel = buildMenuModel(t, menuFlags); const menuHandlers: MenuHandlers = { new: handleNew, addPage, @@ -231,14 +222,11 @@ export function AppShell() { settings: () => setPrinterSettingsTab("appSettings"), exportZpl: handleDownload, exportBatch: handleExportBatch, - exportPdf: () => withNotice(exportPdf), - exportBatchPdf: () => withNotice(exportBatchPdf), openDesign: handleOpen, saveDesign: handleSave, importCsv: openCsvPicker, connectData: openConnectWizard, - print: () => withNotice(() => void handlePrint()), - sendToZebra: openZebraPrint, + output: openOutput, undo: () => undo(), redo: () => redo(), github: () => openExternal(REPO_URL), @@ -565,16 +553,15 @@ export function AppShell() { onCancel={() => settleDatasetReplace(false)} /> )} - {showZebraPrint && ( - - )} - {afterNotice && ( - setAfterNotice(null)} - onContinue={() => { - setAfterNotice(null); - afterNotice(); - }} + {outputSource && ( + (scope === "batch" ? exportBatchPdf() : exportPdf())} + onExportPng={handleExportPng} /> )} {pdfProgress && } diff --git a/src/components/Canvas/LabelCanvas.tsx b/src/components/Canvas/LabelCanvas.tsx index 4ffaff4b..c95a4f8e 100644 --- a/src/components/Canvas/LabelCanvas.tsx +++ b/src/components/Canvas/LabelCanvas.tsx @@ -85,7 +85,7 @@ import { zplForSelection } from "../../lib/zplForSelection"; import { finishZplExport } from "../../lib/exportZpl"; import { generateMultiPageZPL } from "@zplab/core/lib/zplGenerator"; import { captureLabelBlob, captureRegionBlob, copyPngToClipboard, CAPTURE_CHROME } from "../../lib/canvasImage"; -import { saveFile, saveErrorMessage, PNG_FILTER } from "../../lib/fileDialogs"; +import { saveLabelImage } from "../../lib/saveLabelImage"; import { printerPreviewLayout } from "../../lib/printerPreview"; /** Object types offered by the context menu's "Add object here". */ @@ -156,6 +156,11 @@ interface Props { onViewRotationChange: (rotation: ViewRotation) => void; } +/** Nothing drawn yet is nothing to capture, so the context menu and the output dialog get a click that + * does nothing. */ +const labelCapture = (group: Konva.Group | null, paper: Konva.Rect | null) => + group ? captureLabelBlob(group, paper) : Promise.resolve(null); + export interface LabelCanvasHandle { alignSelection: (op: AlignOp, ref: AlignRef) => void; distributeSelection: (axis: DistributeAxis) => void; @@ -163,6 +168,8 @@ export interface LabelCanvasHandle { convertObjectPositionType: (id: string, target: "FO" | "FT") => void; /** The label paper as drawn, at exactly the given dots, so a raster of it lines up with a printer render. */ captureLabelDots: (dots: { width: number; height: number }) => Promise; + /** The label as the canvas draws it, the capture behind the image export. */ + captureLabel: () => Promise; } export const LabelCanvas = forwardRef(function LabelCanvas({ @@ -257,8 +264,6 @@ export const LabelCanvas = forwardRef(function LabelCa clipboard, variables, pages, - setUserError, - clearUserError, } = useLabelStore(); // Everything on canvas is drawn in the current page's dot scale; // designLabel stays the MODEL's design scope for the whole-document emit. @@ -955,6 +960,7 @@ export const LabelCanvas = forwardRef(function LabelCa const group = rotationGroupRef.current; return group ? captureLabelBlob(group, labelPaperRef.current, { pixelRatio: label.dpmm / scale, dots }) : Promise.resolve(null); }, + captureLabel: () => labelCapture(rotationGroupRef.current, labelPaperRef.current), }; }, [updateObjects, updateObject, label.dpmm, scale], @@ -1260,7 +1266,7 @@ export const LabelCanvas = forwardRef(function LabelCa const switchTypeLocked = !!singleForSwitch && (!!singleForSwitch.locked || hasLockedAncestor(objects, singleForSwitch.id)); - const captureLabel = () => (rotationGroupRef.current ? captureLabelBlob(rotationGroupRef.current, labelPaperRef.current) : Promise.resolve(null)); + const captureLabel = () => labelCapture(rotationGroupRef.current, labelPaperRef.current); // The region comes from the model like the selection frame: groups have no node, a sample barcode's // node is chrome, and a line's node is its hit stroke. Frame px are stage px once the view rotation is 0. const captureSelection = () => { @@ -1279,13 +1285,6 @@ export const LabelCanvas = forwardRef(function LabelCa }); void copyPngToClipboard(blob).catch(() => undefined); }; - const exportImage = async (pending: Promise, filename: string) => { - const blob = await pending; - if (!blob) return; - await saveFile(blob, { filename, filters: [PNG_FILTER] }) - .then((wrote) => wrote && clearUserError()) - .catch(() => setUserError(saveErrorMessage)); - }; const dispatch = { copy: copySelectedObjects, cut: () => { @@ -1307,9 +1306,9 @@ export const LabelCanvas = forwardRef(function LabelCa void copyText(finishZplExport(generateMultiPageZPL(designLabel, pages, variables))); }, copyImageSelected: () => copyImage(captureSelection()), - exportImageSelected: () => exportImage(captureSelection(), `${(sel.length === 1 ? findObjectById(objects, sel[0] ?? "")?.type : undefined) ?? "selection"}.png`), + exportImageSelected: () => void saveLabelImage(captureSelection(), `${(sel.length === 1 ? findObjectById(objects, sel[0] ?? "")?.type : undefined) ?? "selection"}.png`), copyImageLabel: () => copyImage(captureLabel()), - exportImageLabel: () => exportImage(captureLabel(), "label.png"), + exportImageLabel: () => void saveLabelImage(captureLabel(), "label.png"), selectAll: () => selectObjects(objects.map((o) => o.id)), switchType: (type: string) => { if (singleForSwitch) { diff --git a/src/components/Fonts/AddFontDialog.tsx b/src/components/Fonts/AddFontDialog.tsx index 633a4814..e8ba925f 100644 --- a/src/components/Fonts/AddFontDialog.tsx +++ b/src/components/Fonts/AddFontDialog.tsx @@ -12,8 +12,8 @@ import { ariaDisabledCls, inputCls, labelCls } from '../ui/formStyles'; import { DialogShell } from '../ui/DialogShell'; interface AddFontDialogProps { - /** Undefined when the dialog closes without adding. The caller caches, so it can still refuse a - * name this dialog has no way to know is taken. */ + /** The picked bytes, undefined when the dialog closes without adding. The caller caches them, so it + * can still refuse a name this dialog has no way to know is taken. */ onDone: (picked?: { path: string; bytes: Uint8Array }) => void; /** The file a stale profile entry waits for. Its name is fixed, because any other name repairs nothing. */ repairPath?: string; diff --git a/src/components/Fonts/FontManager.tsx b/src/components/Fonts/FontManager.tsx index ea96cb0a..23b17843 100644 --- a/src/components/Fonts/FontManager.tsx +++ b/src/components/Fonts/FontManager.tsx @@ -118,6 +118,7 @@ export function FontManager() { }; // The alias has to meet a cache that already holds the file, or the row lands under the manual mappings. + // The alias is assigned here, so the user lands on a row already wired to ^CW and the canvas. const addPicked = async ({ path, bytes }: { path: string; bytes: Uint8Array }) => { await loadFontBytes(bytes, path); const taken = (customFonts ?? []).map((m) => m.alias).filter(Boolean); @@ -177,11 +178,6 @@ export function FontManager() { {adding && ( { - // Auto-assign the next free alias when the upload succeeds. - // Closes the "what now?" gap between the upload finishing - // and the delivery select becoming usable: the user lands on - // a row that is already wired through to ^CW + canvas, with - // an editable alias if they want to override the default. if (picked) void addPicked(picked); setAdding(false); }} diff --git a/src/components/Output/DatasetNotice.tsx b/src/components/Output/DatasetNotice.tsx new file mode 100644 index 00000000..aaec3415 --- /dev/null +++ b/src/components/Output/DatasetNotice.tsx @@ -0,0 +1,37 @@ +import type { ReactNode } from "react"; +import { useT } from "../../hooks/useT"; +import { formatTemplate } from "../../lib/formatTemplate"; +import { useLabelStore, selectBatchInputs, selectBatchPrintCount } from "../../store/labelStore"; + +const NoticeLine = ({ children }: { children: ReactNode }) => ( +

{children}

+); + +export function DatasetNotice() { + const t = useT(); + const rows = useLabelStore((s) => selectBatchInputs(s)?.dataset.rows.length ?? null); + // A per-label ^PQ multiplies every recall, so the honest count is rows times quantity. + const printQuantity = useLabelStore((s) => s.label.printQuantity ?? 1); + const batchCount = useLabelStore(selectBatchPrintCount); + if (rows === null) return null; + return ( + + {printQuantity > 1 + ? formatTemplate(t.zebraPrint.batchNoticeQtyFmt, { n: String(batchCount), rows: String(rows), q: String(printQuantity) }) + : formatTemplate(t.zebraPrint.batchNoticeFmt, { n: String(rows) })} + + ); +} + +/** A mapped dataset reaches the system way as the active row alone. */ +export function ActiveRowNotice() { + const t = useT(); + const rows = useLabelStore((s) => selectBatchInputs(s)?.dataset.rows.length ?? null); + const activeRowIndex = useLabelStore((s) => s.dataset?.activeRowIndex ?? 0); + if (rows === null) return null; + return ( + + {formatTemplate(t.zebraPrint.systemActiveRowOnlyFmt, { n: String(activeRowIndex + 1), rows: String(rows) })} + + ); +} diff --git a/src/components/Output/OutputDialog.notices.test.tsx b/src/components/Output/OutputDialog.notices.test.tsx new file mode 100644 index 00000000..1ddf85cc --- /dev/null +++ b/src/components/Output/OutputDialog.notices.test.tsx @@ -0,0 +1,104 @@ +// @vitest-environment jsdom +import { describe, it, expect, afterEach, beforeEach, vi } from "vitest"; +import { cleanup, act } from "@testing-library/react"; +import { useLabelStore } from "../../store/labelStore"; +import { formatTemplate } from "../../lib/formatTemplate"; +import { fallbackTranslations as en } from "../../locales"; +import { BASE_STATE, loc, showDialog } from "./OutputDialog.testkit"; + +vi.mock("../../lib/platform", async (importOriginal) => ({ ...(await importOriginal>()), isDesktopShell: true })); +vi.mock("../../lib/localPrint", async (importOriginal) => ({ + ...(await importOriginal>()), + listLocalPrinters: vi.fn().mockResolvedValue([]), +})); +vi.mock("../../lib/usbPrint", async (importOriginal) => ({ + ...(await importOriginal>()), + listUsbPrinters: vi.fn().mockResolvedValue([]), +})); + +afterEach(cleanup); + +const dataset = { + headers: ["sku"], + rows: [["A1"], ["B2"], ["C3"]], + source: { kind: "csv" as const, filename: "t.csv", importedAt: "", encoding: "utf-8", delimiter: ",", rowCount: 3 }, + activeRowIndex: 0, +}; +const variables = [{ id: "v1", name: "sku", fnNumber: 1, defaultValue: "" }]; +const columnMapping = { bindings: { v1: "sku" }, headerSnapshot: ["sku"] }; +const text = { id: "t", type: "text", x: 10, y: 10, rotation: 0, props: { content: "A", fontHeight: 30 } } as never; + +const batchNotice = () => formatTemplate(loc.batchNoticeFmt, { n: "3" }); + +beforeEach(() => { + act(() => { + useLabelStore.setState({ + ...BASE_STATE, + outputChoice: { kind: "print", printWay: null, fileFormat: "pdf", pdfScope: "design" }, + previewProvider: "printer", + pages: [{ objects: [text] }], + currentPageIndex: 0, + variables: [], + label: { widthMm: 50, heightMm: 30, dpmm: 8 }, + }); + }); +}); + +describe("the notices above the ways", () => { + it("counts the labels a mapped dataset prints", () => { + act(() => { + useLabelStore.setState({ dataset, columnMapping, variables }); + }); + expect(showDialog().container.textContent).toContain(batchNotice()); + }); + + it("multiplies by the per-label print quantity, because ^PQ rides every recall", () => { + act(() => { + useLabelStore.setState({ dataset, columnMapping, variables, label: { widthMm: 50, heightMm: 30, dpmm: 8, printQuantity: 4 } }); + }); + const expected = formatTemplate(loc.batchNoticeQtyFmt, { n: "12", rows: "3", q: "4" }); + + expect(showDialog().container.textContent).toContain(expected); + }); + + it("names the format a batch job stores, because the job carries its own template", () => { + act(() => { + useLabelStore.setState({ dataset, columnMapping, variables }); + }); + expect(showDialog().container.textContent).toContain( + formatTemplate(en.output.storesSomeFormatFmt, { path: "R:LBL.ZPL" }), + ); + }); + + it("says nothing about the dataset for a setup script", () => { + act(() => { + useLabelStore.setState({ dataset, columnMapping, variables }); + }); + expect(showDialog({ source: "setupScript" }).container.textContent).not.toContain(batchNotice()); + }); + + it("names what the document stores on the printer, from the document and not from the job" , () => { + act(() => { + useLabelStore.setState({ pages: [{ objects: [text], storedFormatPath: "R:LBL.ZPL" }] }); + }); + const r = showDialog({ zpl: () => "^XA^XZ" }); + + expect(r.container.textContent).toContain(formatTemplate(en.output.storesFormatFmt, { path: "R:LBL.ZPL" })); + }); + + it("names a page that only recalls its format", () => { + act(() => { + useLabelStore.setState({ pages: [{ objects: [text], storedFormatPath: "E:JOB.ZPL", storedFormatDelivery: "printer" }] }); + }); + expect(showDialog().container.textContent).toContain(formatTemplate(en.output.recallOnlyPrinterFmt, { path: "E:JOB.ZPL" })); + }); + + it("leaves the printer's own settings unremarked", () => { + act(() => { + useLabelStore.setState({ pages: [{ objects: [text], storedFormatPath: "R:LBL.ZPL" }] }); + }); + const r = showDialog({ source: "setupScript" }); + + expect(r.container.textContent).not.toContain(formatTemplate(en.output.storesFormatFmt, { path: "R:LBL.ZPL" })); + }); +}); diff --git a/src/components/Output/OutputDialog.test.tsx b/src/components/Output/OutputDialog.test.tsx new file mode 100644 index 00000000..aeb05182 --- /dev/null +++ b/src/components/Output/OutputDialog.test.tsx @@ -0,0 +1,317 @@ +// @vitest-environment jsdom +import { describe, it, expect, afterEach, beforeEach, vi } from "vitest"; +import { cleanup, act, fireEvent } from "@testing-library/react"; +import { useLabelStore } from "../../store/labelStore"; +import { fallbackTranslations as en } from "../../locales"; +import { formatTemplate } from "../../lib/formatTemplate"; +import { BASE_STATE, batchScope, everything, loc, printButton, showDialog } from "./OutputDialog.testkit"; + +// The printer ways and the printer render only exist in the desktop shell. +vi.mock("../../lib/platform", async (importOriginal) => ({ ...(await importOriginal>()), isDesktopShell: true })); +vi.mock("../../lib/localPrint", async (importOriginal) => ({ + ...(await importOriginal>()), + listLocalPrinters: vi.fn().mockResolvedValue([]), +})); +vi.mock("../../lib/usbPrint", async (importOriginal) => ({ + ...(await importOriginal>()), + listUsbPrinters: vi.fn().mockResolvedValue([]), +})); + +afterEach(cleanup); + +beforeEach(() => { + act(() => { + useLabelStore.setState({ + ...BASE_STATE, + outputChoice: { kind: "print", printWay: null, fileFormat: "pdf", pdfScope: "design" }, + previewProvider: "printer", + }); + }); +}); + +describe("OutputDialog", () => { + it("opens on the ways to the printer and offers the file next to them", () => { + const r = showDialog(); + + expect(r.getByText(loc.kindFile)).toBeTruthy(); + expect(r.getByText(loc.tabSystem)).toBeTruthy(); + expect(r.getByDisplayValue("172.17.17.175")).toBeTruthy(); + }); + + it("marks the kind showing as the current one", () => { + const r = showDialog(); + + expect(r.getByText(loc.kindFile).getAttribute("aria-current")).toBeNull(); + expect(r.getAllByText(loc.kindPrint)[0]!.getAttribute("aria-current")).toBe("page"); + }); + + it("proposes the kind chosen last when it opens again", () => { + const first = showDialog(); + act(() => { + fireEvent.click(first.getByText(loc.kindFile)); + }); + expect(useLabelStore.getState().outputChoice.kind).toBe("file"); + + cleanup(); + expect(showDialog().getByText(en.app.exportPdf)).toBeTruthy(); + }); + + it("puts a setup script on the printer and leaves nothing to choose", () => { + const r = showDialog({ source: "setupScript" }); + + expect(r.queryByText(loc.kindFile)).toBeNull(); + // A setup script draws nothing, so the way that renders is not on offer either. + expect(r.queryByText(loc.tabSystem)).toBeNull(); + expect(r.getByText(loc.heading)).toBeTruthy(); + }); + + it("titles itself as the menu entry names it", () => { + expect(showDialog().getByText(loc.outputHeading)).toBeTruthy(); + }); + + it("hides what needs drawing for a document with nothing to draw", () => { + const r = showDialog({ facts: { ...everything, hasObjects: false } }); + + expect(r.queryByText(loc.kindFile)).toBeNull(); + expect(r.queryByText(loc.tabSystem)).toBeNull(); + expect(r.getByDisplayValue("172.17.17.175")).toBeTruthy(); + }); + + it("offers the batch only where a dataset and a renderer meet", () => { + act(() => { + useLabelStore.getState().setOutputChoice({ kind: "file" }); + }); + expect(showDialog().getByText(batchScope(3))).toBeTruthy(); + + cleanup(); + // Without a renderer the batch export returns without a file, so it is not on offer. + const alone = showDialog({ facts: { ...everything, canBatchPdf: false } }); + expect(alone.queryByText(batchScope(3))).toBeNull(); + // One scope is no choice, so nothing asks for it. + expect(alone.queryByText(loc.scopeHeading)).toBeNull(); + expect(alone.container.querySelector("input[type=radio]")).toBeNull(); + }); + + it("keeps a batch it cannot offer, so a dataset brings the choice back", () => { + act(() => { + useLabelStore.getState().setOutputChoice({ kind: "file", pdfScope: "batch" }); + }); + showDialog({ facts: { ...everything, canBatchExport: false } }); + + expect(useLabelStore.getState().outputChoice.pdfScope).toBe("batch"); + }); + + it("names on the button what the export covers", () => { + act(() => { + useLabelStore.getState().setOutputChoice({ kind: "file" }); + }); + expect(showDialog({ facts: { ...everything, pdfCurrentPageOnly: true } }).getByText(en.app.exportPdfCurrentPage)).toBeTruthy(); + }); + + it("names the ten thousand pages on the scope, not on the button", () => { + act(() => { + useLabelStore.getState().setOutputChoice({ kind: "file", pdfScope: "batch" }); + }); + const r = showDialog({ facts: { ...everything, batchRowCount: 10_000 } }); + + // A ten thousand page render may not start without saying so. + expect(r.getByText(batchScope(10_000))).toBeTruthy(); + expect(r.getByText(en.app.exportPdf)).toBeTruthy(); + }); + + it("asks for consent before the third-party render and stays open meanwhile", async () => { + act(() => { + useLabelStore.setState({ previewProvider: "labelary", labelaryNoticeAcknowledged: false }); + }); + const r = showDialog(); + act(() => { + fireEvent.click(r.getByText(loc.tabSystem)); + }); + act(() => { + fireEvent.click(printButton(r.container)); + }); + + // The waiting state must be the same on the first print as on every later one. + expect(r.getByText(en.output.previewNoticeTitle)).toBeTruthy(); + expect(r.props.onClose).not.toHaveBeenCalled(); + expect(r.props.onPrintImage).not.toHaveBeenCalled(); + + await act(async () => { + fireEvent.click(r.getByText(en.output.previewNoticeAcknowledge)); + }); + expect(r.props.onPrintImage).toHaveBeenCalled(); + expect(useLabelStore.getState().labelaryNoticeAcknowledged).toBe(true); + }); + + it("stays and says that the label is rendering", async () => { + let finish: () => void = () => undefined; + const onPrintImage = vi.fn(() => new Promise((resolve) => (finish = resolve))); + const r = showDialog({ onPrintImage }); + act(() => { + fireEvent.click(r.getByText(loc.tabSystem)); + }); + act(() => { + fireEvent.click(printButton(r.container)); + }); + + // Several seconds over the network, and a closed dialog would leave the click unanswered. + expect(r.props.onClose).not.toHaveBeenCalled(); + expect(printButton(r.container).disabled).toBe(true); + const line = r.getByText(loc.systemRendering); + expect(line.getAttribute("aria-live")).toBe("polite"); + expect(r.container.querySelector("[aria-busy]")).toBeTruthy(); + + await act(async () => { + finish(); + }); + expect(r.props.onClose).toHaveBeenCalled(); + }); + + it("locks the system way's print only while a read holds the channel the render needs", () => { + act(() => { + useLabelStore.setState({ printerReading: "~HI" }); + }); + const r = showDialog(); + act(() => { + fireEvent.click(r.getByText(loc.tabSystem)); + }); + expect(printButton(r.container).disabled).toBe(true); + + cleanup(); + // A Labelary render never touches the printer, so a read in flight is none of its business. + act(() => { + useLabelStore.setState({ previewProvider: "labelary", printerReading: "~HI" }); + }); + const web = showDialog(); + act(() => { + fireEvent.click(web.getByText(loc.tabSystem)); + }); + expect(printButton(web.container).disabled).toBe(false); + }); + + it("keeps the system way out of the address form it has no use for", () => { + const r = showDialog(); + act(() => { + fireEvent.click(r.getByText(loc.tabSystem)); + }); + + expect(r.queryByDisplayValue("172.17.17.175")).toBeNull(); + expect(r.getByText(loc.tabSystem).getAttribute("aria-current")).toBe("page"); + expect(useLabelStore.getState().printTarget.transport).toBe("network"); + }); + + it("writes the Zebra way to the print target and leaves it alone for the system way", () => { + // USB is not offered in this build, so any write the strip makes to the target would show. + act(() => { + useLabelStore.getState().setPrintTarget({ transport: "usb" }); + }); + const r = showDialog(); + act(() => { + fireEvent.click(r.getByText(loc.tabSystem)); + }); + expect(useLabelStore.getState().outputChoice.printWay).toBe("system"); + expect(useLabelStore.getState().printTarget.transport).toBe("usb"); + + act(() => { + fireEvent.click(r.getByText(loc.tabNetwork)); + }); + expect(useLabelStore.getState().outputChoice.printWay).toBeNull(); + expect(useLabelStore.getState().printTarget.transport).toBe("network"); + }); + + it("hands the PDF export the scope that is showing", () => { + act(() => { + useLabelStore.getState().setOutputChoice({ kind: "file" }); + }); + const r = showDialog(); + act(() => { + fireEvent.click(r.getByText(batchScope(3))); + }); + act(() => { + fireEvent.click(r.getByText(en.app.exportPdf)); + }); + + expect(r.props.onExportPdf).toHaveBeenCalledWith("batch"); + }); + + it("saves a PNG from the canvas and remembers the format", () => { + const r = showDialog(); + act(() => { + fireEvent.click(r.getByText(loc.kindFile)); + }); + act(() => { + fireEvent.click(r.getByText(loc.formatPng)); + }); + // The PDF scope is the PDF's own question, so the PNG body does not ask it. + expect(r.queryByText(batchScope(3))).toBeNull(); + + act(() => { + fireEvent.click(r.getByText(en.app.exportPng)); + }); + expect(r.props.onExportPng).toHaveBeenCalled(); + expect(r.props.onClose).toHaveBeenCalled(); + expect(useLabelStore.getState().outputChoice.fileFormat).toBe("png"); + }); + + it("says on the system way which row of a mapped dataset prints, and names no batch total", () => { + const activeRowLine = (n: string, rows: string) => formatTemplate(loc.systemActiveRowOnlyFmt, { n, rows }); + act(() => { + useLabelStore.getState().setOutputChoice({ printWay: "system" }); + }); + expect(showDialog().container.textContent).not.toContain(activeRowLine("1", "2")); + + cleanup(); + act(() => { + useLabelStore.setState({ + dataset: { + headers: ["sku"], + rows: [["A1"], ["B2"]], + source: { kind: "csv", filename: "t.csv", importedAt: "", encoding: "utf-8", delimiter: ",", rowCount: 2 }, + activeRowIndex: 1, + }, + columnMapping: { bindings: { v1: "sku" }, headerSnapshot: ["sku"] }, + variables: [{ id: "v1", name: "sku", fnNumber: 1, defaultValue: "" }], + }); + }); + const shown = showDialog().container.textContent; + + expect(shown).toContain(activeRowLine("2", "2")); + expect(shown).not.toContain(formatTemplate(loc.batchNoticeFmt, { n: "2" })); + }); + + it("keeps the batch total on a Zebra way, which sends every row", () => { + act(() => { + useLabelStore.setState({ + dataset: { + headers: ["sku"], + rows: [["A1"], ["B2"]], + source: { kind: "csv", filename: "t.csv", importedAt: "", encoding: "utf-8", delimiter: ",", rowCount: 2 }, + activeRowIndex: 0, + }, + columnMapping: { bindings: { v1: "sku" }, headerSnapshot: ["sku"] }, + variables: [{ id: "v1", name: "sku", fnNumber: 1, defaultValue: "" }], + }); + }); + expect(showDialog().container.textContent).toContain(formatTemplate(loc.batchNoticeFmt, { n: "2" })); + }); + + it("reads the code only where it is sent", () => { + const zpl = vi.fn(() => "^XA^XZ"); + const r = showDialog({ zpl }); + expect(zpl).not.toHaveBeenCalled(); + + act(() => { + fireEvent.click(r.getByText(loc.kindFile)); + }); + expect(zpl).not.toHaveBeenCalled(); + + cleanup(); + act(() => { + useLabelStore.getState().setOutputChoice({ kind: "print", printWay: "system" }); + }); + const system = vi.fn(() => "^XA^XZ"); + showDialog({ zpl: system }); + + expect(system).not.toHaveBeenCalled(); + }); +}); diff --git a/src/components/Output/OutputDialog.testkit.tsx b/src/components/Output/OutputDialog.testkit.tsx new file mode 100644 index 00000000..b5bf50bc --- /dev/null +++ b/src/components/Output/OutputDialog.testkit.tsx @@ -0,0 +1,56 @@ +import { render } from "@testing-library/react"; +import { vi } from "vitest"; +import { OutputDialog } from "./OutputDialog"; +import { type OutputFacts } from "../../lib/outputChoice"; +import { DEFAULT_PRINT_TARGET } from "../../lib/printTarget"; +import { fallbackTranslations as en } from "../../locales"; + +export const loc = en.zebraPrint; + +export const everything: OutputFacts = { + hasObjects: true, + documentEmits: true, + sourceEditing: false, + canBatchExport: true, + canBatchPdf: true, + pdfCurrentPageOnly: false, + batchRowCount: 3, +}; + +/** Everything the dialog needs to open on the printer ways, with an address to send to. */ +export const BASE_STATE = { + outputSource: "label" as const, + printTarget: { ...DEFAULT_PRINT_TARGET, host: "172.17.17.175" }, + printerReading: undefined, + thirdParty: { labelary: true }, + dataset: null, + columnMapping: null, + labelaryNoticeAcknowledged: true, +}; + +// The kind segment and the system way's button read the same word, so the body's is the later one. +export function printButton(container: HTMLElement): HTMLButtonElement { + const button = [...container.querySelectorAll("button")] + .filter((b) => b.textContent === loc.kindPrint) + .at(-1); + if (button === undefined) throw new Error("no print button in the dialog"); + return button; +} + +export const batchScope = (n: number) => + loc.scopeCountFmt.replace("{label}", loc.scopeBatch).replace("{n}", String(n)); + +export const showDialog = (over: Partial[0]> = {}) => { + const props = { + zpl: () => "^XA^XZ", + source: "label" as const, + facts: everything, + onClose: vi.fn(), + onPrintImage: vi.fn(() => Promise.resolve()), + onExportPdf: vi.fn(), + onExportPng: vi.fn(), + ...over, + }; + // The shell portals into the body, so the body is the container the assertions read. + return { ...render(), container: document.body, props }; +}; diff --git a/src/components/Output/OutputDialog.tsx b/src/components/Output/OutputDialog.tsx new file mode 100644 index 00000000..e025245e --- /dev/null +++ b/src/components/Output/OutputDialog.tsx @@ -0,0 +1,230 @@ +import { XMarkIcon } from "@heroicons/react/16/solid"; +import { useState } from "react"; +import { useT } from "../../hooks/useT"; +import { DialogShell } from "../ui/DialogShell"; +import { RadioOption } from "../ui/RadioOption"; +import { SegmentedStrip, type Segment } from "../ui/SegmentedStrip"; +import { primaryButtonCls, sectionHeadingCls } from "../ui/formStyles"; +import { useLabelStore, selectPrinterReading, selectEffectivePreviewProvider } from "../../store/labelStore"; +import { formatTemplate } from "../../lib/formatTemplate"; +import { isDesktopShell } from "../../lib/platform"; +import { needsRawChannel, type PrintWay } from "../../lib/printTarget"; +import { wayHint, wayLabel } from "../../lib/printWayText"; +import { + effectiveKind, + effectiveScope, + offeredKinds, + offeredScopes, + type FileFormat, + type OutputFacts, + type OutputKind, + type OutputSource, + type PdfScope, +} from "../../lib/outputChoice"; +import { useLabelaryConsent } from "../../hooks/useLabelaryConsent"; +import { usePrintWays } from "../../hooks/usePrintWays"; +import { useSendNotices } from "../../hooks/useSendNotices"; +import { ActiveRowNotice, DatasetNotice } from "./DatasetNotice"; +import { SystemPrintBody } from "./SystemPrintBody"; +import { ZebraWayPanel } from "./ZebraWayPanel"; + +interface Props { + /** Read at the click, because a mapped dataset regenerates the code for every data row. */ + zpl: () => string; + source: OutputSource; + facts: OutputFacts; + onClose: () => void; + onPrintImage: () => Promise; + onExportPdf: (scope: PdfScope) => void; + onExportPng: () => void; +} + +function ImpactNotices() { + const notices = useSendNotices(); + if (notices.length === 0) return null; + return ( +
+ {notices.map((n) => ( +

+ {n} +

+ ))} +
+ ); +} + +/** The one place the label leaves the app. The first level is where it goes, the second how, and + * the dialog proposes both again next time. */ +export function OutputDialog({ zpl, source, facts, onClose, onPrintImage, onExportPdf, onExportPng }: Props) { + const t = useT(); + const loc = t.zebraPrint; + const choice = useLabelStore((s) => s.outputChoice); + const setOutputChoice = useLabelStore((s) => s.setOutputChoice); + + const kinds = offeredKinds(source, facts); + const kind = effectiveKind(choice.kind, kinds); + // A setup script leaves one kind, and a first level with one entry is no choice to show. + const choosable = kinds.length > 1; + const scopes = offeredScopes(facts); + const scope = effectiveScope(choice.pdfScope, scopes); + const scopeChoosable = scopes.length > 1; + const kindText: Record> = { + print: { key: "print", label: loc.kindPrint, hint: loc.kindPrintHint }, + file: { key: "file", label: loc.kindFile, hint: loc.kindFileHint }, + }; + const formatSegments: Segment[] = [ + { key: "pdf", label: loc.formatPdf, hint: loc.formatPdfHint }, + { key: "png", label: loc.formatPng, hint: loc.formatPngHint }, + ]; + const scopeLabels: Record = { + design: loc.scopeDesign, + batch: formatTemplate(loc.scopeCountFmt, { label: loc.scopeBatch, n: String(facts.batchRowCount) }), + }; + + const ways = usePrintWays(source, facts); + const waySegments: Segment[] = ways.offered.map((key) => ({ + key, + label: wayLabel(loc, key), + hint: wayHint(loc, key, isDesktopShell), + })); + + // The render takes seconds over the network, and the dialog stays to show that it is working. + const [rendering, setRendering] = useState(false); + const checking = useLabelStore(selectPrinterReading); + const renderer = useLabelStore(selectEffectivePreviewProvider); + const { gate, notice } = useLabelaryConsent(); + + const printImage = () => + gate(() => { + setRendering(true); + void onPrintImage().then(onClose, onClose); + }); + // The PDF progress dialog takes over from here, and this modal would sit over it. + const exportPdf = () => + gate(() => { + onClose(); + onExportPdf(scope); + }); + // The save dialog takes over, and the canvas draws the PNG without asking a renderer. + const exportPng = () => { + onClose(); + onExportPng(); + }; + + return ( + <> + +
+ + {source === "setupScript" ? loc.heading : loc.outputHeading} + + +
+ + {choosable && ( +
+ kindText[key])} + current={kind} + onPick={(next) => setOutputChoice({ kind: next })} + /> +
+ )} + + {kind === "print" && ( +
+ {/* The ways strip stays out of the scroll, so only the body of the chosen way moves. */} +
+ {source === "label" && ( + <> + {ways.way === "system" ? : } + {/* These are the exact bytes sent, which the dialog may never have shown. */} + {ways.way !== "system" && } + + )} +
+ {loc.wayHeading} +
+ +
+
+
+ +
+ {ways.way === "system" ? ( + + ) : ( + + )} +
+
+ )} + + {kind === "file" && ( +
+
+ {loc.formatHeading} +
+ setOutputChoice({ fileFormat: next })} + /> +
+
+ + {choice.fileFormat === "pdf" ? ( + <> + {scopeChoosable && ( + // Flex on an inner box, because a legend inside a flex fieldset lays out per browser. +
+ {loc.scopeHeading} +
+ {scopes.map((value) => ( + setOutputChoice({ pdfScope: next })} + label={scopeLabels[value]} + /> + ))} +
+
+ )} +
+ +
+ + ) : ( +
+ +
+ )} +
+ )} +
+ + {notice} + + ); +} diff --git a/src/components/Output/OutputDialog.web.test.tsx b/src/components/Output/OutputDialog.web.test.tsx new file mode 100644 index 00000000..3b539aab --- /dev/null +++ b/src/components/Output/OutputDialog.web.test.tsx @@ -0,0 +1,43 @@ +// @vitest-environment jsdom +import { describe, it, expect, afterEach, beforeEach, vi } from "vitest"; +import { cleanup, act, fireEvent } from "@testing-library/react"; +import { useLabelStore } from "../../store/labelStore"; +import { defaultOutputChoice } from "../../lib/outputChoice"; +import { fallbackTranslations as en } from "../../locales"; +import { BASE_STATE, everything, loc, printButton, showDialog } from "./OutputDialog.testkit"; + +// A browser tab, where the system dialog is the only way to a printer without a Zebra channel. +vi.mock("../../lib/platform", async (importOriginal) => ({ ...(await importOriginal>()), isDesktopShell: false })); + +afterEach(cleanup); + +const webFacts = { ...everything, canBatchExport: false, batchRowCount: 0 }; + +beforeEach(() => { + act(() => { + useLabelStore.setState({ ...BASE_STATE, outputChoice: defaultOutputChoice(false), previewProvider: "labelary" }); + }); +}); + +describe("OutputDialog in the browser", () => { + it("opens on the printer and the system dialog, with nothing to address", () => { + const r = showDialog({ facts: webFacts }); + + // The ways strip belongs to the print kind, and the system way is the one marked on it. + expect(r.getByText(loc.tabSystem).getAttribute("aria-current")).toBe("page"); + expect(r.queryByText(en.app.exportPdf)).toBeNull(); + expect(r.queryByDisplayValue("172.17.17.175")).toBeNull(); + expect(printButton(r.container)).toBeTruthy(); + }); + + it("runs the print path the rendered label takes", async () => { + const r = showDialog({ facts: webFacts }); + + await act(async () => { + fireEvent.click(printButton(r.container)); + }); + + expect(r.props.onPrintImage).toHaveBeenCalled(); + expect(r.props.onClose).toHaveBeenCalled(); + }); +}); diff --git a/src/components/Output/PrintToZebraDialog.batchNotice.test.tsx b/src/components/Output/PrintToZebraDialog.batchNotice.test.tsx deleted file mode 100644 index 546c1372..00000000 --- a/src/components/Output/PrintToZebraDialog.batchNotice.test.tsx +++ /dev/null @@ -1,92 +0,0 @@ -// @vitest-environment jsdom -import { describe, it, expect, afterEach, vi } from 'vitest'; -import { render, cleanup, act } from '@testing-library/react'; -import { PrintToZebraDialog } from './PrintToZebraDialog'; -import { useLabelStore } from '../../store/labelStore'; -import { fallbackTranslations as en } from '../../locales'; - -afterEach(cleanup); - -const dataset = { - headers: ['sku'], - rows: [['A1'], ['B2'], ['C3']], - source: { - kind: 'csv' as const, - filename: 't.csv', - importedAt: '', - encoding: 'utf-8', - delimiter: ',', - rowCount: 3, - }, - activeRowIndex: 0, -}; -const variables = [{ id: 'v1', name: 'sku', fnNumber: 1, defaultValue: '' }]; -const mapping = { bindings: { v1: 'sku' }, headerSnapshot: ['sku'] }; - -const noticeText = () => en.zebraPrint.batchNoticeFmt.replace('{n}', '3'); - -describe('PrintToZebraDialog printer-impact notice', () => { - it('warns when the sent bytes carry printer-setup commands', () => { - act(() => { - useLabelStore.setState({ zebraPrintSource: 'label', dataset: null, columnMapping: null }); - }); - const { container } = render( - , - ); - expect(container.textContent).toContain('^JU'); - }); - - it('stays silent for the setup-script source', () => { - act(() => { - useLabelStore.setState({ zebraPrintSource: 'setupScript', dataset: null, columnMapping: null }); - }); - const { container } = render(); - expect(container.textContent).not.toContain('^JU'); - }); -}); - -// Regression: the batch send-notice guards against an unaware bulk send, so it -// must appear iff a mapped dataset drives the label source. -describe('PrintToZebraDialog batch notice', () => { - it('shows the row-count notice for a mapped dataset on the label source', () => { - act(() => { - useLabelStore.setState({ zebraPrintSource: 'label', dataset, columnMapping: mapping, variables }); - }); - const { container } = render(); - expect(container.textContent).toContain(noticeText()); - }); - - it('multiplies by the per-label print quantity (^PQ rides every recall)', () => { - act(() => { - useLabelStore.setState({ - zebraPrintSource: 'label', - dataset, - columnMapping: mapping, - variables, - label: { widthMm: 50, heightMm: 30, dpmm: 8, printQuantity: 4 }, - }); - }); - const { container } = render(); - const expected = en.zebraPrint.batchNoticeQtyFmt - .replace('{n}', '12') - .replace('{rows}', '3') - .replace('{q}', '4'); - expect(container.textContent).toContain(expected); - }); - - it('hides the notice for the setup-script source', () => { - act(() => { - useLabelStore.setState({ zebraPrintSource: 'setupScript', dataset, columnMapping: mapping }); - }); - const { container } = render(); - expect(container.textContent).not.toContain(noticeText()); - }); - - it('hides the notice without a mapped dataset', () => { - act(() => { - useLabelStore.setState({ zebraPrintSource: 'label', dataset: null, columnMapping: null }); - }); - const { container } = render(); - expect(container.textContent).not.toContain('one per data row'); - }); -}); diff --git a/src/components/Output/PrintToZebraDialog.target.test.tsx b/src/components/Output/PrintToZebraDialog.target.test.tsx deleted file mode 100644 index 69ae20f6..00000000 --- a/src/components/Output/PrintToZebraDialog.target.test.tsx +++ /dev/null @@ -1,56 +0,0 @@ -// @vitest-environment jsdom -import { describe, it, expect, afterEach, beforeEach, vi } from "vitest"; -import { render, cleanup, act, fireEvent } from "@testing-library/react"; -import { PrintToZebraDialog } from "./PrintToZebraDialog"; -import { useLabelStore } from "../../store/labelStore"; -import { DEFAULT_PRINT_TARGET } from "../../lib/printTarget"; -import { fallbackTranslations as en } from "../../locales"; - -afterEach(cleanup); - -beforeEach(() => { - act(() => { - useLabelStore.setState({ zebraPrintSource: "label", dataset: null, columnMapping: null, printTarget: { ...DEFAULT_PRINT_TARGET, host: "172.17.17.175", browserPrintUid: "bp-1" } }); - }); -}); - -describe("PrintToZebraDialog print target", () => { - it("opens on the way last used and keeps the choice when a tab is clicked", () => { - act(() => { - useLabelStore.getState().setPrintTarget({ transport: "browserprint" }); - }); - const r = render(); - expect(r.getByText(en.zebraPrint.discover)).toBeTruthy(); - act(() => { - fireEvent.click(r.getByText(en.zebraPrint.tabNetwork)); - }); - expect(useLabelStore.getState().printTarget.transport).toBe("network"); - expect((r.getByDisplayValue("172.17.17.175") as HTMLInputElement).value).toBe("172.17.17.175"); - }); - - it("shows the network tab for a way this build lacks without rewriting the choice", () => { - act(() => { - useLabelStore.getState().setPrintTarget({ transport: "local" }); - }); - const r = render(); - expect(r.getByDisplayValue("172.17.17.175")).toBeTruthy(); - expect(useLabelStore.getState().printTarget.transport).toBe("local"); - }); - - it("commits the host trimmed and a typed port at once and refuses a keystroke that leaves no port", () => { - const r = render(); - const host = r.getByDisplayValue("172.17.17.175"); - const port = r.getByDisplayValue("9100"); - act(() => { - fireEvent.change(host, { target: { value: " 10.0.0.9 " } }); - fireEvent.change(port, { target: { value: "6101" } }); - }); - expect(useLabelStore.getState().printTarget).toMatchObject({ host: "10.0.0.9", port: 6101 }); - act(() => { - fireEvent.change(port, { target: { value: "70000" } }); - }); - expect(useLabelStore.getState().printTarget.port).toBe(6101); - expect((port as HTMLInputElement).value).toBe("6101"); - expect((r.getByText(en.zebraPrint.send) as HTMLButtonElement).disabled).toBe(false); - }); -}); diff --git a/src/components/Output/PrinterChip.test.tsx b/src/components/Output/PrinterChip.test.tsx index 8b386949..26769a1a 100644 --- a/src/components/Output/PrinterChip.test.tsx +++ b/src/components/Output/PrinterChip.test.tsx @@ -3,7 +3,7 @@ import { describe, it, expect, afterEach, beforeEach, vi } from "vitest"; import { render, cleanup, fireEvent, act } from "@testing-library/react"; import { PrinterChip } from "./PrinterChip"; import { useLabelStore } from "../../store/labelStore"; -import { DEFAULT_PRINT_TARGET } from "../../lib/printTarget"; +import { DEFAULT_PRINT_TARGET, type PrintTarget } from "../../lib/printTarget"; import { fallbackTranslations as en } from "../../locales"; import type { PrinterStatusReport } from "../../lib/printerStatus"; @@ -74,6 +74,24 @@ describe("PrinterChip", () => { expect(dot(r)).toBe("blocked"); }); + it("notes that the check went over the address when the way is not the network", () => { + const target = (over: Partial) => + act(() => useLabelStore.setState({ printTarget: { ...DEFAULT_PRINT_TARGET, host: "172.17.17.175", ...over } })); + const r = render(); + fireEvent.click(r.getByText("172.17.17.175")); + expect(r.queryByText(en.zebraPrint.wayQueryAddress)).toBeNull(); + target({ transport: "local" }); + expect(r.getByText(en.zebraPrint.wayQueryAddress)).toBeTruthy(); + // A USB way without a device falls through to the address, so the note belongs there too. + target({ transport: "usb", usbId: "" }); + expect(r.getByText(en.zebraPrint.wayQueryAddress)).toBeTruthy(); + target({ transport: "usb", usbId: "dev1" }); + expect(r.queryByText(en.zebraPrint.wayQueryAddress)).toBeNull(); + // Without an address there is nothing to ask over, and the chip says that instead. + target({ transport: "local", host: "" }); + expect(r.queryByText(en.zebraPrint.wayQueryAddress)).toBeNull(); + }); + it("opens the printer's own storage, the shortest way there from the design", () => { const r = render(); fireEvent.click(r.getByText("172.17.17.175")); diff --git a/src/components/Output/PrinterChip.tsx b/src/components/Output/PrinterChip.tsx index 3513ec75..0e190c99 100644 --- a/src/components/Output/PrinterChip.tsx +++ b/src/components/Output/PrinterChip.tsx @@ -43,6 +43,8 @@ export function PrinterChip() { const resolved = resolveQueryTarget(printTarget); const name = "failure" in resolved ? t.zebraPrint.printer : resolved.target.kind === "usb" ? t.zebraPrint.tabUsb : resolved.target.host; + // The stored way, because the effective one would enumerate printers on every mount. + const askedOverAddress = !("failure" in resolved) && resolved.target.kind === "network" && printTarget.transport !== "network"; const view = reportView(loc, state.phase === "done" ? state.report : undefined); const reading = step !== undefined; const tone: ReadinessTone = state.phase === "failed" ? "blocked" : (view?.line.tone ?? "unknown"); @@ -74,6 +76,9 @@ export function PrinterChip() { {open && (
+ {askedOverAddress && ( +
{t.zebraPrint.wayQueryAddress}
+ )} {view?.report.flags && } {view?.report.identity && } diff --git a/src/components/Output/SystemPrintBody.tsx b/src/components/Output/SystemPrintBody.tsx new file mode 100644 index 00000000..3ae0c2a7 --- /dev/null +++ b/src/components/Output/SystemPrintBody.tsx @@ -0,0 +1,23 @@ +import { useT } from "../../hooks/useT"; +import { primaryButtonCls } from "../ui/formStyles"; +import { Tooltip } from "../ui/Tooltip"; + +/** The system way's body, with no device to pick and nothing to address. */ +export function SystemPrintBody({ onPrint, rendering, locked }: { onPrint: () => void; rendering: boolean; locked: boolean }) { + const t = useT(); + const loc = t.zebraPrint; + return ( +
+

+ {rendering ? loc.systemRendering : ""} +

+
+ + + +
+
+ ); +} diff --git a/src/components/Output/ZPLOutput.tsx b/src/components/Output/ZPLOutput.tsx index 87bb8235..57441d71 100644 --- a/src/components/Output/ZPLOutput.tsx +++ b/src/components/Output/ZPLOutput.tsx @@ -1,11 +1,11 @@ -import { useRef, useState } from 'react'; +import { useRef } from 'react'; import { CheckIcon, ClipboardDocumentIcon, ChevronDownIcon, ChevronUpIcon, EyeIcon } from '@heroicons/react/16/solid'; -import { useLabelStore, selectLabelaryNoticeRequired, selectEffectivePreviewProvider, selectPreviewLocksEditor } from '../../store/labelStore'; +import { useLabelStore, selectEffectivePreviewProvider, selectPreviewLocksEditor } from '../../store/labelStore'; import { useZplOutputView } from '../../hooks/useZplOutputView'; import { useCopyToClipboard } from '../../hooks/useCopyToClipboard'; import { finishZplExport } from '../../lib/exportZpl'; import { useT } from '../../hooks/useT'; -import { LabelaryNoticeModal } from './LabelaryNoticeModal'; +import { useLabelaryConsent } from '../../hooks/useLabelaryConsent'; import { ZplSourceEditor } from './ZplSourceEditor'; import { Tooltip } from '../ui/Tooltip'; import { isDesktopShell } from '../../lib/platform'; @@ -24,12 +24,11 @@ interface Props { /** The ZPL output panel with its always-mounted source pane. */ export function ZPLOutput({ collapsed, onCollapse, onExpand, onResizeMouseDown }: Props) { const t = useT(); - const noticeRequired = useLabelStore(selectLabelaryNoticeRequired); const effectiveProvider = useLabelStore(selectEffectivePreviewProvider); const previewActive = useLabelStore(selectPreviewLocksEditor); const enterPreviewMode = useLabelStore((s) => s.enterPreviewMode); const exitPreviewMode = useLabelStore((s) => s.exitPreviewMode); - const [showNotice, setShowNotice] = useState(false); + const { gate, notice: consentNotice } = useLabelaryConsent(); // The session's focus boundary: leaving this panel applies the buffer, so // header actions (copy) stay inside it and don't count as leaving. const panelRef = useRef(null); @@ -39,7 +38,6 @@ export function ZPLOutput({ collapsed, onCollapse, onExpand, onResizeMouseDown } // The body keeps its metadata (the apply reparses it); the button copies the export form. const { copy, copied } = useCopyToClipboard(() => finishZplExport(shownText)); - // The printer talks to the user's own device, so only Labelary needs the consent notice. const previewAvailable = effectiveProvider !== 'none'; const togglePreview = () => { @@ -47,11 +45,7 @@ export function ZPLOutput({ collapsed, onCollapse, onExpand, onResizeMouseDown } exitPreviewMode(); return; } - if (effectiveProvider === 'labelary' && noticeRequired) { - setShowNotice(true); - return; - } - void enterPreviewMode(); + gate(() => void enterPreviewMode()); }; // Collapsing the panel mid-edit would hide an unapplied buffer. @@ -134,15 +128,7 @@ export function ZPLOutput({ collapsed, onCollapse, onExpand, onResizeMouseDown } )}
- {showNotice && ( - setShowNotice(false)} - onContinue={() => { - setShowNotice(false); - void enterPreviewMode(); - }} - /> - )} + {consentNotice} ); } diff --git a/src/components/Output/PrintToZebraDialog.check.test.tsx b/src/components/Output/ZebraWayPanel.check.test.tsx similarity index 82% rename from src/components/Output/PrintToZebraDialog.check.test.tsx rename to src/components/Output/ZebraWayPanel.check.test.tsx index 00ae2049..4a89069a 100644 --- a/src/components/Output/PrintToZebraDialog.check.test.tsx +++ b/src/components/Output/ZebraWayPanel.check.test.tsx @@ -1,7 +1,7 @@ // @vitest-environment jsdom import { describe, it, expect, afterEach, beforeEach, vi } from "vitest"; import { render, cleanup, act, fireEvent, waitFor } from "@testing-library/react"; -import { PrintToZebraDialog } from "./PrintToZebraDialog"; +import { WayPanel } from "./ZebraWayPanel.testkit"; import { useLabelStore } from "../../store/labelStore"; import { DEFAULT_PRINT_TARGET } from "../../lib/printTarget"; import { fallbackTranslations as en } from "../../locales"; @@ -25,16 +25,16 @@ afterEach(cleanup); beforeEach(() => { readPrinterStatus.mockReset(); act(() => { - useLabelStore.setState({ zebraPrintSource: "label", dataset: null, columnMapping: null, printerState: { phase: "idle" } }); + useLabelStore.setState({ dataset: null, columnMapping: null, printerState: { phase: "idle" } }); }); }); -describe("PrintToZebraDialog printer check", () => { - it("asks over the tab shown, not a stored way the build does not offer, and locks Send meanwhile", async () => { +describe("ZebraWayPanel printer check", () => { + it("asks over the way shown, not the stored one, and locks Send meanwhile", async () => { let finish: (value: unknown) => void = () => undefined; readPrinterStatus.mockReturnValue(new Promise((resolve) => (finish = resolve))); act(() => useLabelStore.setState({ printTarget: { ...DEFAULT_PRINT_TARGET, transport: "usb", usbId: "unplugged", host: "172.17.17.175" } })); - const r = render(); + const r = render( "^XA^XZ"} />); await waitFor(() => expect(r.getByText(en.zebraPrint.checkPrinter)).toBeTruthy()); await act(async () => { fireEvent.click(r.getByText(en.zebraPrint.checkPrinter)); diff --git a/src/components/Output/ZebraWayPanel.target.test.tsx b/src/components/Output/ZebraWayPanel.target.test.tsx new file mode 100644 index 00000000..3aa60365 --- /dev/null +++ b/src/components/Output/ZebraWayPanel.target.test.tsx @@ -0,0 +1,41 @@ +// @vitest-environment jsdom +import { describe, it, expect, afterEach, beforeEach } from "vitest"; +import { render, cleanup, act, fireEvent } from "@testing-library/react"; +import { WayPanel } from "./ZebraWayPanel.testkit"; +import { useLabelStore } from "../../store/labelStore"; +import { DEFAULT_PRINT_TARGET } from "../../lib/printTarget"; +import { fallbackTranslations as en } from "../../locales"; + +afterEach(cleanup); + +beforeEach(() => { + act(() => { + useLabelStore.setState({ dataset: null, columnMapping: null, printTarget: { ...DEFAULT_PRINT_TARGET, host: "172.17.17.175", browserPrintUid: "bp-1" } }); + }); +}); + +describe("ZebraWayPanel print target", () => { + it("picks a device on the way shown", () => { + const r = render( "^XA^XZ"} />); + + expect(r.getByText(en.zebraPrint.discover)).toBeTruthy(); + expect(r.queryByDisplayValue("172.17.17.175")).toBeNull(); + }); + + it("commits the host trimmed and a typed port at once and refuses a keystroke that leaves no port", () => { + const r = render( "^XA^XZ"} />); + const host = r.getByDisplayValue("172.17.17.175"); + const port = r.getByDisplayValue("9100"); + act(() => { + fireEvent.change(host, { target: { value: " 10.0.0.9 " } }); + fireEvent.change(port, { target: { value: "6101" } }); + }); + expect(useLabelStore.getState().printTarget).toMatchObject({ host: "10.0.0.9", port: 6101 }); + act(() => { + fireEvent.change(port, { target: { value: "70000" } }); + }); + expect(useLabelStore.getState().printTarget.port).toBe(6101); + expect((port as HTMLInputElement).value).toBe("6101"); + expect((r.getByText(en.zebraPrint.send) as HTMLButtonElement).disabled).toBe(false); + }); +}); diff --git a/src/components/Output/ZebraWayPanel.testkit.tsx b/src/components/Output/ZebraWayPanel.testkit.tsx new file mode 100644 index 00000000..e2d2049a --- /dev/null +++ b/src/components/Output/ZebraWayPanel.testkit.tsx @@ -0,0 +1,20 @@ +import { usePrintWays } from "../../hooks/usePrintWays"; +import type { OutputFacts } from "../../lib/outputChoice"; +import type { PrintTransport } from "../../lib/printTarget"; +import { ZebraWayPanel } from "./ZebraWayPanel"; + +const FACTS: OutputFacts = { + hasObjects: true, + documentEmits: true, + sourceEditing: false, + canBatchExport: false, + canBatchPdf: true, + pdfCurrentPageOnly: false, + batchRowCount: 0, +}; + +/** The dialog owns the device lists, so the panel under test gets them from the same hook. */ +export function WayPanel({ way, zpl }: { way: PrintTransport; zpl: () => string }) { + const ways = usePrintWays("label", FACTS); + return ; +} diff --git a/src/components/Output/PrintToZebraDialog.tsx b/src/components/Output/ZebraWayPanel.tsx similarity index 51% rename from src/components/Output/PrintToZebraDialog.tsx rename to src/components/Output/ZebraWayPanel.tsx index cf7df576..99564f4f 100644 --- a/src/components/Output/PrintToZebraDialog.tsx +++ b/src/components/Output/ZebraWayPanel.tsx @@ -1,39 +1,35 @@ import { useState, type ReactNode } from "react"; import { errorMessage } from "../../lib/errorMessage"; -import { XMarkIcon } from "@heroicons/react/16/solid"; import { useT } from "../../hooks/useT"; -import { DialogShell } from "../ui/DialogShell"; import { Select } from "../ui/Select"; +import { primaryButtonCls } from "../ui/formStyles"; import { sendViaBrowserPrint, sendViaNetwork } from "../../lib/zebraPrint"; import { isDesktopShell } from "../../lib/platform"; -import { effectiveTransport, offeredTransports, type PrintTransport } from "../../lib/printTarget"; -import { useLabelStore, selectBatchInputs, selectCanBatchExport, selectBatchPrintCount, selectPrinterReading } from "../../store/labelStore"; -import { formatTemplate } from "../../lib/formatTemplate"; -import { deliveryNotices, exportPrinterImpact, printerImpactNotices } from "../../lib/exportImpact"; +import { needsRawChannel, resolveQueryTarget, type PrintTransport } from "../../lib/printTarget"; +import { useLabelStore, selectEffectivePreviewProvider, selectPrinterReading } from "../../store/labelStore"; import { sendZplLocal } from "../../lib/localPrint"; import { sendZplUsb, setupUsbAccess } from "../../lib/usbPrint"; -import { pickerOptions, useBrowserPrintDevices, useLocalPrinters, useUsbPrinters } from "../../hooks/usePrintDevices"; +import { pickerOptions } from "../../hooks/usePrintDevices"; +import type { PrintDeviceLists } from "../../hooks/usePrintWays"; import { PrinterAddressFields } from "../PrinterSettings/PrinterAddressFields"; import { PrinterCheck } from "./PrinterCheck"; -import { sectionHeadingCls } from "../ui/formStyles"; import type { PrinterQueryFailure } from "../../lib/printerQuery"; -type Tab = PrintTransport; interface Status { type: "idle" | "sending" | "success" | "error"; message?: string } function StatusMessage({ status }: { status: Status }) { if (status.type === "idle" || status.type === "sending") return null; + const success = status.type === "success"; return ( -

+

{status.message}

); } -// The list-based transports (browser print, OS spooler, direct USB) share this -// body; network keeps its own ip/port form and stays separate. +// Browser print, the OS spooler and USB share this body, because each picks from a device list. interface TransportView { - key: Tab; + key: PrintTransport; selected: string; onSelect: (value: string) => void; options: { value: string; label: string }[]; @@ -62,7 +58,7 @@ function TransportBody({ view, fieldLabel }: { view: TransportView; fieldLabel: @@ -72,62 +68,23 @@ function TransportBody({ view, fieldLabel }: { view: TransportView; fieldLabel: ); } -/** Synchronous on purpose: the banner must exist the moment the send button goes live. */ -function ImpactNotices({ zpl }: { zpl: string }) { +export function ZebraWayPanel({ zpl, way, devices }: { zpl: () => string; way: PrintTransport; devices: PrintDeviceLists }) { const t = useT(); - const pages = useLabelStore((s) => s.pages); - const currentPageIndex = useLabelStore((s) => s.currentPageIndex); - const batch = useLabelStore(selectCanBatchExport); - const printed = batch ? pages.slice(currentPageIndex, currentPageIndex + 1) : pages; - const notices = [...printerImpactNotices(exportPrinterImpact(zpl), t), ...deliveryNotices(printed, t)]; - if (notices.length === 0) return null; - return ( -
- {notices.map((n) => ( -

- {n} -

- ))} -
- ); -} - -interface Props { - zpl: string; - onClose: () => void; -} - -export function PrintToZebraDialog({ zpl, onClose }: Props) { - const t = useT(); - // The way last used is the tab the dialog opens on, so a repeat print is one click. - const transport = useLabelStore((s) => s.printTarget.transport); - const setPrintTarget = useLabelStore((s) => s.setPrintTarget); - // The label source silently switches to batch form when a dataset is - // mapped; surface the count so nobody sends 10k labels unaware. A per-label - // ^PQ rides the stored template and multiplies EVERY recall, so the honest - // number (selectBatchPrintCount) is rows × quantity. - const batchRows = useLabelStore((s) => - s.zebraPrintSource === "label" ? selectBatchInputs(s)?.dataset.rows.length ?? null : null, - ); - const printQuantity = useLabelStore((s) => s.label.printQuantity ?? 1); - const batchCount = useLabelStore(selectBatchPrintCount); - const printSource = useLabelStore((s) => s.zebraPrintSource); - - const host = useLabelStore((s) => s.printTarget.host); - // A check holds the raw channel, so the ways that share it cannot send meanwhile. + const printTarget = useLabelStore((s) => s.printTarget); + const host = printTarget.host; + const renderer = useLabelStore(selectEffectivePreviewProvider); const checking = useLabelStore(selectPrinterReading); + const locked = checking && needsRawChannel(way, renderer); const [netStatus, setNetStatus] = useState({ type: "idle" }); - const bp = useBrowserPrintDevices(); + const { bp, local, usb } = devices; const [bpStatus, setBpStatus] = useState({ type: "idle" }); - const local = useLocalPrinters(isDesktopShell); const [localStatus, setLocalStatus] = useState({ type: "idle" }); - const usb = useUsbPrinters(isDesktopShell); const [usbStatus, setUsbStatus] = useState({ type: "idle" }); // Tracks the last USB send being permission-denied, so the setup affordance // survives locale changes and a cancelled polkit prompt. const [usbNeedsSetup, setUsbNeedsSetup] = useState(false); - // Enumeration failures surface in the tab's status area, unless a send status is already showing. + // Enumeration failures surface in the way's status area, unless a send status is already showing. const withListError = (status: Status, error: string | null): Status => error && status.type === "idle" ? { type: "error", message: error } : status; const usbViewStatus = withListError(usbStatus, usb.error); @@ -137,14 +94,13 @@ export function PrintToZebraDialog({ zpl, onClose }: Props) { async function handleNetworkSend() { const target = useLabelStore.getState().printTarget; setNetStatus({ type: "sending" }); - const result = await sendViaNetwork(target.host, target.port, zpl); + const result = await sendViaNetwork(target.host, target.port, zpl()); switch (result.kind) { case "sent": setNetStatus({ type: "success", message: t.zebraPrint.success }); return; case "responded": - // 2xx only counts as success; print servers / proxies that respond - // with 4xx or 5xx must surface as an error rather than green-success. + // A print server or proxy answers with a status, and 4xx or 5xx must not read as success. if (result.status >= 200 && result.status < 300) { setNetStatus({ type: "success", message: t.zebraPrint.success }); } else { @@ -177,7 +133,7 @@ export function PrintToZebraDialog({ zpl, onClose }: Props) { if (!device) return; setBpStatus({ type: "sending" }); try { - await sendViaBrowserPrint(device, zpl); + await sendViaBrowserPrint(device, zpl()); setBpStatus({ type: "success", message: t.zebraPrint.success }); } catch (e) { setBpStatus({ @@ -190,7 +146,7 @@ export function PrintToZebraDialog({ zpl, onClose }: Props) { async function handleLocalSend() { if (!local.selectedId) return; setLocalStatus({ type: "sending" }); - const result = await sendZplLocal(local.selectedId, zpl); + const result = await sendZplLocal(local.selectedId, zpl()); if (result.kind === "sent") { setLocalStatus({ type: "success", message: t.zebraPrint.success }); } else { @@ -201,7 +157,7 @@ export function PrintToZebraDialog({ zpl, onClose }: Props) { async function handleUsbSend() { if (!usb.selectedId) return; setUsbStatus({ type: "sending" }); - const result = await sendZplUsb(usb.selectedId, zpl); + const result = await sendZplUsb(usb.selectedId, zpl()); switch (result.kind) { case "sent": setUsbNeedsSetup(false); @@ -229,7 +185,7 @@ export function PrintToZebraDialog({ zpl, onClose }: Props) { async function handleUsbSetup() { try { await setupUsbAccess(); - // Access granted: refresh (a hot-plugged printer may be new) and clear the prompt. + // Refresh after access is granted, because a printer plugged in meanwhile is not in the list. await usb.refresh(); setUsbNeedsSetup(false); setUsbStatus({ type: "idle" }); @@ -239,18 +195,8 @@ export function PrintToZebraDialog({ zpl, onClose }: Props) { } } - const tabClass = (active: boolean) => - `px-3 py-1.5 text-[10px] font-mono uppercase tracking-widest transition-colors ${ - active - ? "text-text border-b border-accent" - : "text-muted hover:text-text" - }`; - - const offered = offeredTransports(isDesktopShell, { local: local.present, usb: usb.present }); - const tabLabels: Record = { network: t.zebraPrint.tabNetwork, browserprint: t.zebraPrint.tabBrowserPrint, local: t.zebraPrint.tabLocal, usb: t.zebraPrint.tabUsb }; - const tab = effectiveTransport(transport, offered); const checkProps = (sending: boolean) => ({ - transport: tab, + transport: way, sendBusy: sending, onFailure: (failure: PrinterQueryFailure) => { if (failure.kind !== "permission_denied") return; @@ -259,6 +205,22 @@ export function PrintToZebraDialog({ zpl, onClose }: Props) { }, }); + // Every way reaches the same device, so every way may ask it. Only the desktop shell can read a + // reply, and a way that cannot be questioned itself falls back to the configured address. + const addressCheck = (sending: boolean) => { + if (!isDesktopShell) return null; + const resolved = resolveQueryTarget(printTarget, way); + if ("failure" in resolved) return null; + return ( +
+ + {resolved.target.kind === "network" && way !== "network" && ( +

{t.zebraPrint.wayQueryAddress}

+ )} +
+ ); + }; + const views: TransportView[] = [ { key: "browserprint", @@ -268,19 +230,22 @@ export function PrintToZebraDialog({ zpl, onClose }: Props) { selectDisabled: bp.options.length === 0, onSend: handleBrowserPrintSend, sendLabel: bpStatus.type === "sending" ? t.zebraPrint.sending : t.zebraPrint.send, - sendDisabled: !bp.selectedId || bp.options.length === 0 || bpStatus.type === "sending", + sendDisabled: !bp.selectedId || bp.options.length === 0 || bpStatus.type === "sending" || locked, status: bpViewStatus, extra: ( - +
+ + {addressCheck(bpStatus.type === "sending")} +
), }, { @@ -291,8 +256,9 @@ export function PrintToZebraDialog({ zpl, onClose }: Props) { selectDisabled: local.options.length === 0, onSend: handleLocalSend, sendLabel: localStatus.type === "sending" ? t.zebraPrint.sending : t.zebraPrint.send, - sendDisabled: !local.selectedId || local.options.length === 0 || local.loading || localStatus.type === "sending", + sendDisabled: !local.selectedId || local.options.length === 0 || local.loading || localStatus.type === "sending" || locked, status: localViewStatus, + extra: addressCheck(localStatus.type === "sending") ?? undefined, }, { key: "usb", @@ -302,7 +268,7 @@ export function PrintToZebraDialog({ zpl, onClose }: Props) { selectDisabled: usb.options.length === 0, onSend: handleUsbSend, sendLabel: usbStatus.type === "sending" ? t.zebraPrint.sending : t.zebraPrint.send, - sendDisabled: !usb.selectedId || usb.options.length === 0 || usb.loading || usbStatus.type === "sending" || checking, + sendDisabled: !usb.selectedId || usb.options.length === 0 || usb.loading || usbStatus.type === "sending" || locked, status: usbViewStatus, extra: usbNeedsSetup ? ( - - - {batchRows !== null && ( -

- {printQuantity > 1 - ? formatTemplate(t.zebraPrint.batchNoticeQtyFmt, { - n: String(batchCount), - rows: String(batchRows), - q: String(printQuantity), - }) - : formatTemplate(t.zebraPrint.batchNoticeFmt, { n: String(batchRows) })} -

- )} + const activeView = views.find((v) => v.key === way); - {/* These are the exact bytes sent (batch form included), which the panel may never - have shown. Source-gated: a setup script changing settings is its purpose. */} - {printSource !== "setupScript" && } + if (way === "network") { + return ( +
+ {window.location.protocol === "https:" && ( +

+ {t.zebraPrint.httpsWarning} +

+ )} + - {/* Tabs */} -
- {offered.map((key) => ( - - ))} -
- - {/* Network tab */} - {tab === "network" && ( -
- {window.location.protocol === "https:" && ( -

- {t.zebraPrint.httpsWarning} -

- )} - - -
- {/* The browser cannot read a reply, so the check is desktop only. */} - {isDesktopShell ? : } - -
- -
- )} - {/* List-based transports (browser print, OS spooler, direct USB) */} - {activeView && } - - ); + +
+ ); + } + + return activeView ? : null; } diff --git a/src/components/Output/PrintToZebraDialog.usb.test.tsx b/src/components/Output/ZebraWayPanel.usb.test.tsx similarity index 78% rename from src/components/Output/PrintToZebraDialog.usb.test.tsx rename to src/components/Output/ZebraWayPanel.usb.test.tsx index 4123eb5f..bf948826 100644 --- a/src/components/Output/PrintToZebraDialog.usb.test.tsx +++ b/src/components/Output/ZebraWayPanel.usb.test.tsx @@ -1,7 +1,7 @@ // @vitest-environment jsdom import { describe, it, expect, afterEach, beforeEach, vi } from "vitest"; import { render, cleanup, act, fireEvent, waitFor } from "@testing-library/react"; -import { PrintToZebraDialog } from "./PrintToZebraDialog"; +import { WayPanel } from "./ZebraWayPanel.testkit"; import { useLabelStore } from "../../store/labelStore"; import { DEFAULT_PRINT_TARGET } from "../../lib/printTarget"; import { fallbackTranslations as en } from "../../locales"; @@ -31,15 +31,15 @@ beforeEach(() => { setupUsbAccess.mockReset(); readPrinterStatus.mockReset(); act(() => { - useLabelStore.setState({ zebraPrintSource: "label", dataset: null, columnMapping: null, printTarget: { ...DEFAULT_PRINT_TARGET, transport: "usb" } }); + useLabelStore.setState({ dataset: null, columnMapping: null, printTarget: { ...DEFAULT_PRINT_TARGET, transport: "usb" } }); }); }); -describe("PrintToZebraDialog USB access", () => { +describe("ZebraWayPanel USB access", () => { it("offers the setup route with its message after a denied check, as after a denied send", async () => { readPrinterStatus.mockResolvedValue({ kind: "permission_denied" }); act(() => useLabelStore.setState({ printerState: { phase: "idle" }, printTarget: { ...DEFAULT_PRINT_TARGET, transport: "usb", usbId: "usb1" } })); - const r = render(); + const r = render( "^XA^XZ"} />); await waitFor(() => expect(r.getByText(en.zebraPrint.checkPrinter)).toBeTruthy()); await act(async () => { fireEvent.click(r.getByText(en.zebraPrint.checkPrinter)); @@ -50,7 +50,7 @@ describe("PrintToZebraDialog USB access", () => { it("shows the manual route when the sandbox refuses to install the udev rule", async () => { setupUsbAccess.mockRejectedValue("flatpak"); - const r = render(); + const r = render( "^XA^XZ"} />); await waitFor(() => expect(r.getByText(/Zebra ZD230/)).toBeTruthy()); await act(async () => { fireEvent.click(r.getByText(en.zebraPrint.send)); @@ -63,9 +63,21 @@ describe("PrintToZebraDialog USB access", () => { expect(r.getByText(en.zebraPrint.usbSetupAccess)).toBeTruthy(); }); + it("reads the code at the click and never in the render", async () => { + const zpl = vi.fn(() => "^XA^XZ"); + const r = render(); + await waitFor(() => expect(r.getByText(/Zebra ZD230/)).toBeTruthy()); + expect(zpl).not.toHaveBeenCalled(); + + await act(async () => { + fireEvent.click(r.getByText(en.zebraPrint.send)); + }); + expect(zpl).toHaveBeenCalledTimes(1); + }); + it("keeps the raw reason for any other failure", async () => { setupUsbAccess.mockRejectedValue("setup cancelled"); - const r = render(); + const r = render( "^XA^XZ"} />); await waitFor(() => expect(r.getByText(/Zebra ZD230/)).toBeTruthy()); await act(async () => { fireEvent.click(r.getByText(en.zebraPrint.send)); diff --git a/src/components/PrinterSettings/PrintTargetTab.test.tsx b/src/components/PrinterSettings/PrintTargetTab.test.tsx index fe8a6b55..ca716e6b 100644 --- a/src/components/PrinterSettings/PrintTargetTab.test.tsx +++ b/src/components/PrinterSettings/PrintTargetTab.test.tsx @@ -15,6 +15,13 @@ beforeEach(() => { }); describe("PrintTargetTab", () => { + // The test build is the browser one, so the network way shows its web wording. + it("says under each offered way what it does in this build", () => { + const r = render(); + expect(r.getByText(en.zebraPrint.wayNetworkWeb)).toBeTruthy(); + expect(r.getByText(en.zebraPrint.wayBrowserPrint)).toBeTruthy(); + }); + it("binds the way to the print target and keeps the address in reach for the preview", () => { const r = render(); act(() => { diff --git a/src/components/PrinterSettings/PrintTargetTab.tsx b/src/components/PrinterSettings/PrintTargetTab.tsx index 6f7197d0..d1f08e4d 100644 --- a/src/components/PrinterSettings/PrintTargetTab.tsx +++ b/src/components/PrinterSettings/PrintTargetTab.tsx @@ -2,6 +2,7 @@ import { useT } from "../../hooks/useT"; import { useLabelStore } from "../../store/labelStore"; import { isDesktopShell } from "../../lib/platform"; import { effectiveTransport, offeredTransports, type PrintTransport } from "../../lib/printTarget"; +import { wayHint, wayLabel } from "../../lib/printWayText"; import { pickerOptions, useBrowserPrintDevices, useLocalPrinters, useUsbPrinters, type DeviceListState } from "../../hooks/usePrintDevices"; import { buttonCls, sectionHeadingCls } from "../ui/formStyles"; import { RadioOption } from "../ui/RadioOption"; @@ -33,13 +34,13 @@ export function PrintTargetTab() { const offered = offeredTransports(isDesktopShell, { local: local.present, usb: usb.present }); const way = effectiveTransport(transport, offered); - // A device way without devices stays visible but disabled, so the user sees why it is not on offer. + // A device way without devices stays visible, and then its reason replaces the description. const deviceWayHint = (value: PrintTransport) => (offered.includes(value) ? undefined : t.zebraPrint.noPrinters); - const ways: { value: PrintTransport; label: string; desktopOnly?: boolean }[] = [ - { value: "network", label: t.zebraPrint.tabNetwork }, - { value: "browserprint", label: t.zebraPrint.tabBrowserPrint }, - { value: "local", label: t.zebraPrint.tabLocal, desktopOnly: true }, - { value: "usb", label: t.zebraPrint.tabUsb, desktopOnly: true }, + const ways: { value: PrintTransport; desktopOnly?: boolean }[] = [ + { value: "network" }, + { value: "browserprint" }, + { value: "local", desktopOnly: true }, + { value: "usb", desktopOnly: true }, ]; return ( @@ -55,8 +56,8 @@ export function PrintTargetTab() { value={w.value} current={way} onSelect={(next) => setPrintTarget({ transport: next })} - label={w.label} - hint={w.desktopOnly ? deviceWayHint(w.value) : undefined} + label={wayLabel(t.zebraPrint, w.value)} + hint={deviceWayHint(w.value) ?? wayHint(t.zebraPrint, w.value, isDesktopShell)} disabled={!offered.includes(w.value)} /> ))} diff --git a/src/components/PrinterSettings/PrinterSettingsModal.tsx b/src/components/PrinterSettings/PrinterSettingsModal.tsx index 7fcbd565..72385148 100644 --- a/src/components/PrinterSettings/PrinterSettingsModal.tsx +++ b/src/components/PrinterSettings/PrinterSettingsModal.tsx @@ -163,7 +163,7 @@ export function PrinterSettingsModal() { const hasPerLabelOverrides = useLabelStore(selectHasPerLabelOverrides); const mcpSidecarAvailable = useLabelStore((s) => s.mcpSidecarAvailable); const gateCtx: TabGateCtx = { mcpSidecarAvailable, isDesktop: isDesktopShell }; - const openZebraPrint = useLabelStore((s) => s.openZebraPrint); + const openOutput = useLabelStore((s) => s.openOutput); const label = useLabelStore((s) => s.label); const pages = useLabelStore((s) => s.pages); const variables = useLabelStore((s) => s.variables); @@ -292,7 +292,7 @@ export function PrinterSettingsModal() { onJump={setTab} onSend={() => { setTab(null); - openZebraPrint('setupScript'); + openOutput('setupScript'); }} /> )} diff --git a/src/components/PrinterSettings/StoredFontsTab.tsx b/src/components/PrinterSettings/StoredFontsTab.tsx index 1ab75803..e00314ca 100644 --- a/src/components/PrinterSettings/StoredFontsTab.tsx +++ b/src/components/PrinterSettings/StoredFontsTab.tsx @@ -29,7 +29,8 @@ function repairable(path: string): boolean { return own !== undefined && storageRefMatchesPath(own, path); } -/** Deleting cached fonts stays in the FontManager. */ +/** The printer's fonts and the profile's entries in one list. Deleting cached bytes stays in the + * FontManager. */ export function StoredFontsTab() { const t = useT(); const fonts = useCachedFonts(); @@ -44,7 +45,7 @@ export function StoredFontsTab() { // A cache row is named by its printer path, so profile and cache rows share one identity rule. const cachedPaths = fonts.map(cachedFontPath).filter((path) => listsStoredFont(path, setupFonts)); const cachedKeys = new Set(cachedPaths.map(storageKey)); - // A replayed upload carries its own bytes in the profile, so it needs no cache row behind it. + // A replayed upload needs no cache row behind it. const replayedPaths = (setupFonts ?? []).flatMap((f) => (f.download === undefined ? [] : [f.path])); const replayedKeys = new Set(replayedPaths.map(storageKey)); const rows = storedObjectOrigins({ @@ -63,7 +64,7 @@ export function StoredFontsTab() { const toggle = (path: string, on: boolean) => patchPrinterProfileWith((p) => ({ setupFonts: on ? withSetupEntry(p.setupFonts, { path }) : withoutSetupEntry(p.setupFonts, path) })); - // One upload path for the app, so the tab opens the font panel's dialog instead of keeping its own. + // One upload path for the app, so the tab opens the font panel's dialog. const [adding, setAdding] = useState<{ repairPath?: string }>(); const [uploadIssue, setUploadIssue] = useState<"replayedNameTaken" | "refused">(); @@ -75,7 +76,7 @@ export function StoredFontsTab() { await loadFontBytes(bytes, path); setUploadIssue(toggle(path, true) ? undefined : "refused"); }; - // A replayed upload lives in the profile only, so removing it is a delete, not a toggle. + // Removing a replayed upload is a delete, not a toggle. const [pendingRemove, setPendingRemove] = useState(); return ( diff --git a/src/components/ui/SegmentedStrip.tsx b/src/components/ui/SegmentedStrip.tsx new file mode 100644 index 00000000..110d6514 --- /dev/null +++ b/src/components/ui/SegmentedStrip.tsx @@ -0,0 +1,37 @@ +import { Tooltip } from "./Tooltip"; + +export interface Segment { + key: T; + label: string; + hint: string; +} + +export function SegmentedStrip({ + label, + segments, + current, + onPick, +}: { + label: string; + segments: Segment[]; + current: T; + onPick: (key: T) => void; +}) { + return ( +
+ {segments.map((segment, i) => ( + + + + ))} +
+ ); +} diff --git a/src/components/ui/formStyles.ts b/src/components/ui/formStyles.ts index 7152499e..bcb09d34 100644 --- a/src/components/ui/formStyles.ts +++ b/src/components/ui/formStyles.ts @@ -6,6 +6,8 @@ export const sectionHeadingCls = 'font-mono text-[10px] uppercase tracking-wides * surface-2 + border styling used by `inputCls` so buttons sit naturally * next to form fields without dominating the visual hierarchy. */ export const buttonCls = 'px-3 py-1.5 rounded text-xs font-mono bg-surface-2 border border-border text-text hover:bg-border transition-colors'; +/** The action a dialog exists for: the send, the print, the export. */ +export const primaryButtonCls = 'px-3 py-1.5 text-xs font-mono rounded bg-accent text-bg hover:opacity-90 disabled:opacity-40 disabled:cursor-not-allowed transition-opacity'; /** `buttonCls` hovers, so a disabled button needs its hover taken back too. */ export const disabledCls = 'disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:bg-surface-2'; /** The same look for a control that locks with `aria-disabled` and keeps focus. */ diff --git a/src/hooks/useLabelaryConsent.tsx b/src/hooks/useLabelaryConsent.tsx new file mode 100644 index 00000000..f87c6398 --- /dev/null +++ b/src/hooks/useLabelaryConsent.tsx @@ -0,0 +1,32 @@ +import { useState, type ReactNode } from "react"; +import { LabelaryNoticeModal } from "../components/Output/LabelaryNoticeModal"; +import { useLabelStore, selectEffectivePreviewProvider, selectLabelaryNoticeRequired } from "../store/labelStore"; + +/** The consent a third-party render needs before the first request. `gate` holds the action while the + * notice is open, so the caller's own waiting state covers the first render like every later one. */ +export function useLabelaryConsent(): { gate: (action: () => void) => void; notice: ReactNode } { + const renderer = useLabelStore(selectEffectivePreviewProvider); + const required = useLabelStore(selectLabelaryNoticeRequired); + const [pending, setPending] = useState<(() => void) | null>(null); + + const gate = (action: () => void) => { + if (renderer === "labelary" && required) { + setPending(() => action); + return; + } + action(); + }; + + return { + gate, + notice: pending ? ( + setPending(null)} + onContinue={() => { + setPending(null); + pending(); + }} + /> + ) : null, + }; +} diff --git a/src/hooks/useNativeMenu.rebuild.test.tsx b/src/hooks/useNativeMenu.rebuild.test.tsx index f05a30c4..5437aecc 100644 --- a/src/hooks/useNativeMenu.rebuild.test.tsx +++ b/src/hooks/useNativeMenu.rebuild.test.tsx @@ -57,7 +57,6 @@ const BASE_FLAGS: MenuFlags = { sourceEditing: false, canBatchExport: false, batchRowCount: 0, - batchPrintCount: 0, connectDataWizard: true, canBatchPdf: false, pdfCurrentPageOnly: false, @@ -65,7 +64,7 @@ const BASE_FLAGS: MenuFlags = { canRedo: false, includeQuit: true, }; -const BATCH_FLAGS: MenuFlags = { ...BASE_FLAGS, canBatchExport: true, batchRowCount: 3, batchPrintCount: 3 }; +const BATCH_FLAGS: MenuFlags = { ...BASE_FLAGS, canBatchExport: true, batchRowCount: 3 }; const LABELS: SubmenuLabels = { file: "File", edit: "Edit", help: "Help", quit: "Quit" }; const HISTORY: HistorySubmenu = { @@ -170,18 +169,18 @@ describe("useNativeMenu after a rebuild", () => { const { handlers } = await rebuild({ flags: BATCH_FLAGS }); click(en.app.newDesign); - click(en.app.sendToZebraBatchFmt.replace("{n}", "3")); + click(en.zebraPrint.outputHeading); click(en.app.exportBatchZplFmt.replace("{n}", "3")); expect(handlers.new).toHaveBeenCalledOnce(); - expect(handlers.sendToZebra).toHaveBeenCalledOnce(); + expect(handlers.output).toHaveBeenCalledOnce(); expect(handlers.exportBatch).toHaveBeenCalledOnce(); }); it("keeps the history submenu clickable too, the steps and the clear entry", async () => { const { onHistoryJump, onHistoryClear } = await rebuild({ flags: BATCH_FLAGS }); - // The steps have no id of their own either since the fix, so they belong in this guard. + // The steps have no id of their own either, so they belong in this guard. click("Move text"); click("Clear history"); @@ -193,9 +192,9 @@ describe("useNativeMenu after a rebuild", () => { const { handlers } = await rebuild({ dark: true }); click(en.app.newDesign); - click(en.app.sendToZebra); + click(en.zebraPrint.outputHeading); expect(handlers.new).toHaveBeenCalledOnce(); - expect(handlers.sendToZebra).toHaveBeenCalledOnce(); + expect(handlers.output).toHaveBeenCalledOnce(); }); }); diff --git a/src/hooks/usePrintWays.ts b/src/hooks/usePrintWays.ts new file mode 100644 index 00000000..a4745330 --- /dev/null +++ b/src/hooks/usePrintWays.ts @@ -0,0 +1,34 @@ +import { isDesktopShell } from "../lib/platform"; +import { effectivePrintWay, renderableOutput, type OutputFacts, type OutputSource } from "../lib/outputChoice"; +import { offeredWays, type PrintTransport, type PrintWay } from "../lib/printTarget"; +import { useLabelStore } from "../store/labelStore"; +import { useBrowserPrintDevices, useLocalPrinters, useUsbPrinters } from "./usePrintDevices"; + +/** Each list keeps its own enumeration state, so the strip and the body must read the same mount. */ +export interface PrintDeviceLists { + bp: ReturnType; + local: ReturnType; + usb: ReturnType; +} + +export interface PrintWays { + offered: PrintWay[]; + way: PrintWay; + /** The Zebra way behind the strip, which the system way leaves untouched. */ + tab: PrintTransport; + choose: (way: PrintWay) => void; + devices: PrintDeviceLists; +} + +/** The ways out of the output dialog and the one it shows. */ +export function usePrintWays(source: OutputSource, facts: OutputFacts): PrintWays { + const bp = useBrowserPrintDevices(); + const local = useLocalPrinters(isDesktopShell); + const usb = useUsbPrinters(isDesktopShell); + const transport = useLabelStore((s) => s.printTarget.transport); + const printWay = useLabelStore((s) => s.outputChoice.printWay); + const choose = useLabelStore((s) => s.chooseOutputWay); + + const offered = offeredWays(isDesktopShell, { local: local.present, usb: usb.present }, renderableOutput(source, facts)); + return { offered, ...effectivePrintWay(printWay, transport, offered), choose, devices: { bp, local, usb } }; +} diff --git a/src/hooks/useSendNotices.ts b/src/hooks/useSendNotices.ts new file mode 100644 index 00000000..6da48f9c --- /dev/null +++ b/src/hooks/useSendNotices.ts @@ -0,0 +1,18 @@ +import { deliveryNotices, exportPrinterImpact, printerImpactNotices } from "../lib/exportImpact"; +import { sendZpl } from "../lib/sendZpl"; +import { selectCanBatchExport, useLabelStore } from "../store/labelStore"; +import { useT } from "./useT"; + +/** What the bytes about to go out do to the printer, synchronously, because the banner must exist the + * moment the send button goes live. */ +export function useSendNotices(): readonly string[] { + const t = useT(); + const pages = useLabelStore((s) => s.pages); + const currentPageIndex = useLabelStore((s) => s.currentPageIndex); + const batch = useLabelStore(selectCanBatchExport); + const zpl = useLabelStore((s) => sendZpl(s, 1)); + + // A batch recalls one page, so only that page's delivery is at stake. + const printed = batch ? pages.slice(currentPageIndex, currentPageIndex + 1) : pages; + return [...printerImpactNotices(exportPrinterImpact(zpl), t), ...deliveryNotices(printed, t)]; +} diff --git a/src/hooks/useStoredObjectHover.ts b/src/hooks/useStoredObjectHover.ts index 966078ef..b8232eba 100644 --- a/src/hooks/useStoredObjectHover.ts +++ b/src/hooks/useStoredObjectHover.ts @@ -9,7 +9,7 @@ export interface StoredObjectHover { /** The lists and the drive bars sit in different subtrees, so one hover needs a shared slot. */ export const StoredObjectHoverContext = createContext(null); -/** Outside the provider a list still renders, it just reports its hover to nobody. */ +/** Outside the provider a list still renders and reports its hover to nobody. */ export function useStoredObjectHover(): StoredObjectHover { return useContext(StoredObjectHoverContext) ?? { hoveredKey: undefined, setHoveredKey: () => undefined }; } diff --git a/src/hooks/useZplImportExport.test.tsx b/src/hooks/useZplImportExport.test.tsx index dcab3aa4..9f06307d 100644 --- a/src/hooks/useZplImportExport.test.tsx +++ b/src/hooks/useZplImportExport.test.tsx @@ -26,7 +26,7 @@ beforeEach(() => { dataset: null, columnMapping: null, previewMode: { status: "idle" }, - zebraPrintSource: "label", + outputSource: "label", keepExportMetadata: false, }); }); diff --git a/src/hooks/useZplImportExport.ts b/src/hooks/useZplImportExport.ts index a3823939..d1d4e0c9 100644 --- a/src/hooks/useZplImportExport.ts +++ b/src/hooks/useZplImportExport.ts @@ -2,14 +2,15 @@ import { useState, type RefObject } from "react"; import { finishZplExport } from "../lib/exportZpl"; import { currentObjects, - selectBatchInputs, selectCanBatchExport, useLabelStore, } from "../store/labelStore"; -import { generateMultiPageZPL, generateBatchZpl } from "@zplab/core/lib/zplGenerator"; +import { generateMultiPageZPL } from "@zplab/core/lib/zplGenerator"; +import { sendZpl } from "../lib/sendZpl"; import { generateSetupScript, setupFormatBlocks } from "../lib/zplSetupScript"; -import { printLabel } from "../lib/printPreview"; +import { printErrorMessage, printLabel, printReplaced } from "../lib/printSheet"; import { saveTextFile, saveErrorMessage, ZPL_SAVE_FILTERS } from "../lib/fileDialogs"; +import { saveLabelImage } from "../lib/saveLabelImage"; import { labelaryErrorMessage } from "../lib/labelary"; import { currentPageLabel, selectEffectivePreviewProvider, selectLabelaryEndpoint } from "../store/labelStore.selectors"; import { buildActiveRow } from "@zplab/core/lib/variableBinding"; @@ -25,12 +26,10 @@ export function useZplImportExport(canvasRef: RefObject s.dataset?.rows.length ?? 0); - // Source-aware Zebra-print state lives in the store so the - // PrinterSettingsModal can trigger a Setup-Script send without - // prop-drilling through this hook. Treat `null` as closed. - const zebraPrintSource = useLabelStore((s) => s.zebraPrintSource); - const openZebraPrintStore = useLabelStore((s) => s.openZebraPrint); - const closeZebraPrintStore = useLabelStore((s) => s.closeZebraPrint); + // In the store, so PrinterSettingsModal can open the dialog on the setup script without prop-drilling. + const outputSource = useLabelStore((s) => s.outputSource); + const openOutputStore = useLabelStore((s) => s.openOutput); + const closeOutput = useLabelStore((s) => s.closeOutput); const setUserError = useLabelStore((s) => s.setUserError); const clearUserError = useLabelStore((s) => s.clearUserError); @@ -46,71 +45,68 @@ export function useZplImportExport(canvasRef: RefObject { const s = useLabelStore.getState(); - const batch = selectBatchInputs(s); - if (!batch) return; - const zpl = finishZplExport( - generateBatchZpl(currentPageLabel(s), currentObjects(s), s.variables, batch.dataset, batch.mapping), - ); - void saveTextFile(zpl, { filename: "label-batch.zpl", filters: ZPL_SAVE_FILTERS }) + if (!selectCanBatchExport(s)) return; + void saveTextFile(sendZpl(s), { filename: "label-batch.zpl", filters: ZPL_SAVE_FILTERS }) .then((wrote) => wrote && clearUserError()) .catch(() => setUserError(saveErrorMessage)); }; + // The canvas capture the context menu saves, so both image exports hold the same pixels. + const handleExportPng = () => { + void saveLabelImage(canvasRef.current?.captureLabel() ?? Promise.resolve(null), "label.png"); + }; + // Prints the current page as the preview renderer draws it, with the active row substituted. const handlePrint = async () => { const renderer = selectEffectivePreviewProvider(useLabelStore.getState()); - const image = (async () => { + // Two stages with two kinds of failure: the renderer may be out of reach, or no print may start. + let image: string; + try { // A premium host needs the keychain key before the request goes out. if (renderer === "labelary" && !useLabelStore.getState().labelaryApiKeyLoaded) { await useLabelStore.getState().hydrateLabelaryApiKey(); } const s = useLabelStore.getState(); const job = { label: currentPageLabel(s), objects: currentObjects(s), variables: s.variables, active: buildActiveRow(s.dataset, s.columnMapping) }; - return renderLabelImageUrl(renderer, job, { + image = await renderLabelImageUrl(renderer, job, { labelary: selectLabelaryEndpoint(s), printTarget: s.printTarget, captureCanvas: async (dots) => (await canvasRef.current?.captureLabelDots(dots)) ?? null, }); - })(); + } catch (e) { + setUserError(renderer === "labelary" ? labelaryErrorMessage(e) : errorMessage(e), { retryExport: renderer === "labelary" }); + return; + } try { await printLabel(image); clearUserError(); } catch (e) { - setUserError(renderer === "labelary" ? labelaryErrorMessage(e) : errorMessage(e), { retryExport: renderer === "labelary" }); + if (e !== printReplaced) setUserError(printErrorMessage); } }; - // ZPL surfaced to direct-print: branch on the active source. - // - 'setupScript': EEPROM-persistent printer config (live-clock - // safe; the generator captures `now` here, at click-time). - // - 'label' (default): batch form when a CSV is in play, otherwise - // the same template the editor displays. + // The setup script captures `now` at the click, so a live clock stays right. const currentZpl = () => { const s = useLabelStore.getState(); - if (zebraPrintSource === 'setupScript') { + if (outputSource === 'setupScript') { return generateSetupScript(s.printerProfile, setupFormatBlocks(s.label, s.pages, s.variables)); } - const batch = selectBatchInputs(s); - const zpl = batch - ? generateBatchZpl( - currentPageLabel(s), currentObjects(s), s.variables, batch.dataset, batch.mapping, - ) - : generateMultiPageZPL(s.label, s.pages, s.variables); - return finishZplExport(zpl); + return sendZpl(s); }; return { showZplImport, openZplImport: () => setShowZplImport(true), closeZplImport: () => setShowZplImport(false), - showZebraPrint: zebraPrintSource !== null, - openZebraPrint: () => openZebraPrintStore('label'), - closeZebraPrint: closeZebraPrintStore, + outputSource, + openOutput: () => openOutputStore('label'), + closeOutput, currentZpl, handleDownload, handleExportBatch, canBatchExport, batchRowCount, handlePrint, + handleExportPng, }; } diff --git a/src/index.css b/src/index.css index 40c08ab0..7ac44dd0 100644 --- a/src/index.css +++ b/src/index.css @@ -150,3 +150,43 @@ html, body, #root { background: var(--color-bg); color: var(--color-text); } + +/* The sheet these rules dress is built in printSheet.ts. */ +/* Off the screen rather than hidden, because the sheet stands in the page and must never flash into + view. */ +.print-sheet { + position: fixed; + left: -10000px; + top: 0; +} + +@media print { + /* No page margin, or the engine draws its own header and footer there and stamps the sheet with + the title and the date. */ + @page { + margin: 0; + } + + /* The page takes its ground from the root element, so the app's own must be reset there. */ + html.printing-label, + html.printing-label body { + background: #fff; + color: #000; + } + + html.printing-label body > *:not(.print-sheet) { + display: none !important; + } + + html.printing-label .print-sheet { + position: static; + } + + /* A 100x150 label is taller than a sheet, and without the cap its foot lands on a second page. + Block, because an inline image adds the baseline gap below it and spills that page anyway. */ + .print-sheet img { + display: block; + max-width: 100%; + max-height: 100vh; + } +} diff --git a/src/lib/menuModel.test.ts b/src/lib/menuModel.test.ts index 2757d025..e6d12a99 100644 --- a/src/lib/menuModel.test.ts +++ b/src/lib/menuModel.test.ts @@ -8,7 +8,6 @@ const FLAGS: MenuFlags = { sourceEditing: false, canBatchExport: false, batchRowCount: 0, - batchPrintCount: 0, connectDataWizard: false, canBatchPdf: true, pdfCurrentPageOnly: false, @@ -25,10 +24,10 @@ describe("buildMenuModel", () => { it("keeps the dropdown's item order and sections", () => { const m = buildMenuModel(en, FLAGS); expect(ids(m)).toEqual([ - "new", "addPage", "importZpl", "settings", "exportZpl", "exportPdf", - "openDesign", "saveDesign", "importCsv", "print", "sendToZebra", + "new", "addPage", "importZpl", "settings", "exportZpl", "output", + "openDesign", "saveDesign", "importCsv", ]); - expect(m.file).toHaveLength(5); + expect(m.file).toHaveLength(4); }); it("disables the import under a source session, like opening a file", () => { @@ -38,26 +37,23 @@ describe("buildMenuModel", () => { expect(byId(m, "settings")?.enabled).toBe(true); }); - it("gates object-dependent items on hasObjects and export/save on documentEmits", () => { + it("gates export, save and the output entry on documentEmits", () => { const m = buildMenuModel(en, { ...FLAGS, hasObjects: false, documentEmits: false }); - for (const id of ["exportZpl", "saveDesign", "print", "sendToZebra"]) { + for (const id of ["exportZpl", "saveDesign", "output"]) { expect(byId(m, id)?.enabled).toBe(false); } expect(byId(m, "new")?.enabled).toBe(true); - // Overlay pages emit without objects (config-only source apply): export - // and save stay reachable, the render-dependent items do not. + // An overlay page emits without objects, so export and save stay reachable. const emits = buildMenuModel(en, { ...FLAGS, hasObjects: false, documentEmits: true }); expect(byId(emits, "exportZpl")?.enabled).toBe(true); expect(byId(emits, "saveDesign")?.enabled).toBe(true); - // A config-only overlay stream is a legitimate setup job for the device; - // only the Labelary render stays objects-gated. - expect(byId(emits, "sendToZebra")?.enabled).toBe(true); - expect(byId(emits, "print")?.enabled).toBe(false); + // The dialog decides what the entry leads to, so the entry itself only asks whether anything emits. + expect(byId(emits, "output")?.enabled).toBe(true); }); it("disables document-replacing and emitting entries during a source-edit session", () => { const m = buildMenuModel(en, { ...FLAGS, sourceEditing: true }); - for (const id of ["new", "addPage", "importZpl", "openDesign", "saveDesign", "importCsv", "exportZpl", "print", "sendToZebra"]) { + for (const id of ["new", "addPage", "importZpl", "openDesign", "saveDesign", "importCsv", "exportZpl", "output"]) { expect(byId(m, id)?.enabled, id).toBe(false); } expect(byId(m, "settings")?.enabled).toBe(true); @@ -70,17 +66,22 @@ describe("buildMenuModel", () => { expect(item?.label).toContain("7"); }); - it("labels sendToZebra with the physical print count while a batch is mapped", () => { - expect(byId(buildMenuModel(en, FLAGS), "sendToZebra")?.label).toBe(en.app.sendToZebra); - // 7 rows × ^PQ 3: the send label counts printed labels, the export - // label counts file rows. - const m = buildMenuModel(en, { - ...FLAGS, canBatchExport: true, batchRowCount: 7, batchPrintCount: 21, - }); - expect(byId(m, "sendToZebra")?.label).toContain("21"); + it("names the output entry as the dialog titles itself, without a count", () => { + expect(byId(buildMenuModel(en, FLAGS), "output")?.label).toBe(en.zebraPrint.outputHeading); + // The counts belong to the entries that name what they produce, and to the dialog itself. + const m = buildMenuModel(en, { ...FLAGS, canBatchExport: true, batchRowCount: 7 }); + expect(byId(m, "output")?.label).toBe(en.zebraPrint.outputHeading); expect(byId(m, "exportBatch")?.label).toContain("7"); }); + it("leaves the PDF to the dialog instead of a menu entry of its own", () => { + const batch = { ...FLAGS, canBatchExport: true, batchRowCount: 3 }; + expect(byId(buildMenuModel(en, batch), "output")?.enabled).toBe(true); + for (const id of ["exportPdf", "exportBatchPdf"]) { + expect(ids(buildMenuModel(en, batch))).not.toContain(id); + } + }); + 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(); @@ -92,14 +93,6 @@ describe("buildMenuModel", () => { expect(ids(desktop).indexOf("connectData")).toBe(ids(desktop).indexOf("saveDesign") + 1); }); - it("offers the batch PDF only while a renderer can draw every row", () => { - const batch = { ...FLAGS, canBatchExport: true, batchRowCount: 3 }; - expect(byId(buildMenuModel(en, batch), "exportBatchPdf")?.label).toBe("Export batch PDF (3 labels)"); - expect(byId(buildMenuModel(en, { ...batch, canBatchPdf: false }), "exportBatchPdf")).toBeUndefined(); - expect(byId(buildMenuModel(en, batch), "print")?.enabled).toBe(true); - expect(byId(buildMenuModel(en, { ...FLAGS, pdfCurrentPageOnly: true }), "exportPdf")?.label).toBe("Export PDF (current page)"); - }); - it("appends the quit section only on desktop", () => { expect(byId(buildMenuModel(en, FLAGS), "quit")).toBeUndefined(); const m = buildMenuModel(en, { ...FLAGS, includeQuit: true }); diff --git a/src/lib/menuModel.ts b/src/lib/menuModel.ts index 08639f76..51b4e98f 100644 --- a/src/lib/menuModel.ts +++ b/src/lib/menuModel.ts @@ -1,4 +1,5 @@ import type { Translations } from '../locales'; +import { anyProducible, type OutputFacts } from './outputChoice'; import { formatTemplate } from './formatTemplate'; /** @@ -16,14 +17,11 @@ export type MenuItemId = | 'settings' | 'exportZpl' | 'exportBatch' - | 'exportPdf' - | 'exportBatchPdf' | 'openDesign' | 'saveDesign' | 'importCsv' | 'connectData' - | 'print' - | 'sendToZebra' + | 'output' | 'undo' | 'redo' | 'github' @@ -63,25 +61,11 @@ export interface HistorySubmenu { items: { index: number; label: string; current: boolean; enabled: boolean }[]; } -export interface MenuFlags { - hasObjects: boolean; - /** Overlay pages emit even with zero objects (config-only source apply), so - * export/save gate on this, not on hasObjects. */ - documentEmits: boolean; - /** Live source-edit session: entries that would replace or emit the document disable. */ - sourceEditing: boolean; - canBatchExport: boolean; - batchRowCount: number; - /** Physical labels a batch send produces: rows × per-label ^PQ quantity. - * exportBatch keeps batchRowCount (it names file content, not printing). */ - batchPrintCount: number; +/** `OutputFacts` plus what only a menu asks. */ +export interface MenuFlags extends OutputFacts { /** 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; - /** A batch PDF needs a renderer for every row. */ - canBatchPdf: boolean; - /** Without a renderer the PDF holds the current page only. */ - pdfCurrentPageOnly: boolean; canUndo: boolean; canRedo: boolean; /** Desktop convention only; a browser tab has no app-quit. */ @@ -106,14 +90,8 @@ export function buildMenuModel(t: Translations, f: MenuFlags): MenuModel { enabled: f.hasObjects && live, }] : []), - { id: 'exportPdf', label: f.pdfCurrentPageOnly ? t.app.exportPdfCurrentPage : t.app.exportPdf, enabled: f.hasObjects && live }, - ...(f.canBatchExport && f.canBatchPdf - ? [{ - id: 'exportBatchPdf' as const, - label: formatTemplate(t.app.exportBatchPdfFmt, { n: String(f.batchRowCount) }), - enabled: f.hasObjects && live, - }] - : []), + // The dialog carries the file exports too, so the menu names the one door to all of them. + { id: 'output', label: t.zebraPrint.outputHeading, enabled: anyProducible(f) }, ], [ { id: 'openDesign', label: t.app.openDesign, enabled: live }, @@ -122,19 +100,6 @@ export function buildMenuModel(t: Translations, f: MenuFlags): MenuModel { ? { id: 'connectData' as const, label: t.connectData.title, enabled: live } : { id: 'importCsv' as const, label: t.app.importCsvData, enabled: live }, ], - [ - { id: 'print', label: t.app.print, enabled: f.hasObjects && live }, - { - // Sends the full export (a config-only overlay stream is a legitimate - // setup job), so it follows documentEmits like exportZpl; the rendered - // print above stays hasObjects (rendering nothing has no value). - id: 'sendToZebra', - label: f.canBatchExport - ? formatTemplate(t.app.sendToZebraBatchFmt, { n: String(f.batchPrintCount) }) - : t.app.sendToZebra, - enabled: f.documentEmits && live, - }, - ], ...(f.includeQuit ? [[{ id: 'quit' as const, label: t.app.quitMenu, enabled: true }]] : []), diff --git a/src/lib/menuSignature.test.ts b/src/lib/menuSignature.test.ts index 986f94ce..82af7231 100644 --- a/src/lib/menuSignature.test.ts +++ b/src/lib/menuSignature.test.ts @@ -10,7 +10,6 @@ const FLAGS: MenuFlags = { sourceEditing: false, canBatchExport: false, batchRowCount: 0, - batchPrintCount: 0, connectDataWizard: false, canBatchPdf: true, pdfCurrentPageOnly: false, @@ -55,7 +54,6 @@ describe("menuStructureKey", () => { it("changes when the item set changes (add/remove forces a rebuild)", () => { const base = menuStructureKey(false, buildMenuModel(en, FLAGS)); expect(menuStructureKey(false, buildMenuModel(en, { ...FLAGS, canBatchExport: true, batchRowCount: 1 }))).not.toBe(base); - expect(menuStructureKey(false, buildMenuModel(en, { ...FLAGS, canBatchExport: true, batchRowCount: 1, canBatchPdf: false }))).not.toBe(menuStructureKey(false, buildMenuModel(en, { ...FLAGS, canBatchExport: true, batchRowCount: 1 }))); expect(menuStructureKey(false, buildMenuModel(en, { ...FLAGS, includeQuit: false }))).not.toBe(base); }); @@ -75,7 +73,7 @@ describe("menuContentSignature", () => { }); it("changes when an enabled flag changes", () => { - const other = buildMenuModel(en, { ...FLAGS, hasObjects: false }); + const other = buildMenuModel(en, { ...FLAGS, documentEmits: false }); expect(menuContentSignature(false, LABELS, other, HISTORY)).not.toBe(base); }); diff --git a/src/lib/outputChoice.test.ts b/src/lib/outputChoice.test.ts new file mode 100644 index 00000000..1a4f6f01 --- /dev/null +++ b/src/lib/outputChoice.test.ts @@ -0,0 +1,117 @@ +import { describe, it, expect } from "vitest"; +import { + anyProducible, + defaultOutputChoice, + effectiveKind, + effectivePrintWay, + effectiveScope, + offeredKinds, + offeredScopes, + renderableOutput, + type OutputFacts, +} from "./outputChoice"; +import { offeredWays } from "./printTarget"; + +const everything: OutputFacts = { + hasObjects: true, + documentEmits: true, + sourceEditing: false, + canBatchExport: true, + canBatchPdf: true, + pdfCurrentPageOnly: false, + batchRowCount: 3, +}; + +describe("anyProducible", () => { + it("opens the dialog for a config-only document", () => { + // An imported overlay with no objects is a legitimate setup job, but there is nothing to draw. + expect(anyProducible({ ...everything, hasObjects: false })).toBe(true); + }); + + it("stays shut while the source is being edited", () => { + // The editor is about to replace the document, so nothing of it may go out meanwhile. + expect(anyProducible({ ...everything, sourceEditing: true })).toBe(false); + }); + + it("stays shut for a document that emits nothing", () => { + expect(anyProducible({ ...everything, hasObjects: false, documentEmits: false })).toBe(false); + }); +}); + +describe("offeredKinds", () => { + it("puts a setup script on the printer and nowhere else", () => { + expect(offeredKinds("setupScript", everything)).toEqual(["print"]); + expect(offeredKinds("label", everything)).toEqual(["print", "file"]); + }); + + it("drops the file where rendering has nothing to draw", () => { + expect(offeredKinds("label", { ...everything, hasObjects: false })).toEqual(["print"]); + }); + + it("offers nothing of a document being replaced", () => { + expect(offeredKinds("label", { ...everything, sourceEditing: true })).toEqual([]); + }); + + it("asks nothing of the document for a setup script", () => { + const empty = { ...everything, hasObjects: false, documentEmits: false }; + + expect(offeredKinds("setupScript", empty)).toEqual(["print"]); + }); +}); + +describe("renderableOutput", () => { + it("needs objects of the label itself", () => { + expect(renderableOutput("label", everything)).toBe(true); + expect(renderableOutput("label", { ...everything, hasObjects: false })).toBe(false); + expect(renderableOutput("label", { ...everything, sourceEditing: true })).toBe(false); + expect(renderableOutput("setupScript", everything)).toBe(false); + }); +}); + +describe("offeredScopes", () => { + it("needs a dataset and a renderer for the batch", () => { + expect(offeredScopes(everything)).toEqual(["design", "batch"]); + expect(offeredScopes({ ...everything, canBatchExport: false })).toEqual(["design"]); + // Without a renderer the batch export draws nothing and returns without a file. + expect(offeredScopes({ ...everything, canBatchPdf: false })).toEqual(["design"]); + }); +}); + +describe("the remembered decision", () => { + it("holds where it still can", () => { + expect(effectiveKind("file", offeredKinds("label", everything))).toBe("file"); + expect(effectiveScope("batch", offeredScopes(everything))).toBe("batch"); + }); + + it("falls back to what is left, without rewriting the memory", () => { + expect(effectiveKind("file", offeredKinds("setupScript", everything))).toBe("print"); + expect(effectiveScope("batch", offeredScopes({ ...everything, canBatchPdf: false }))).toBe("design"); + }); +}); + +describe("effectivePrintWay", () => { + const webWays = offeredWays(false, { local: false, usb: false }, true); + + it("shows the stored Zebra way while nothing else is remembered", () => { + expect(effectivePrintWay(null, "browserprint", webWays)).toEqual({ way: "browserprint", tab: "browserprint" }); + expect(effectivePrintWay(null, "network", webWays)).toEqual({ way: "network", tab: "network" }); + }); + + it("holds the system dialog once it was chosen, over the Zebra way behind it", () => { + expect(effectivePrintWay("system", "browserprint", webWays)).toEqual({ way: "system", tab: "browserprint" }); + }); + + it("falls back to a Zebra way where nothing renders", () => { + const script = offeredWays(false, { local: false, usb: false }, false); + + expect(effectivePrintWay("system", "browserprint", script)).toEqual({ way: "browserprint", tab: "browserprint" }); + }); +}); + +describe("defaultOutputChoice", () => { + it("starts on the way the build reaches a printer by", () => { + // A browser tab has no channel to the device, so the rendered label goes through the system dialog. + expect(defaultOutputChoice(true)).toEqual({ kind: "print", printWay: null, fileFormat: "pdf", pdfScope: "design" }); + expect(defaultOutputChoice(false)).toEqual({ kind: "print", printWay: "system", fileFormat: "pdf", pdfScope: "design" }); + }); +}); diff --git a/src/lib/outputChoice.ts b/src/lib/outputChoice.ts new file mode 100644 index 00000000..3ff00162 --- /dev/null +++ b/src/lib/outputChoice.ts @@ -0,0 +1,94 @@ +// The two levels of the output dialog: where the label goes, and how, where there is a choice. +import { effectiveTransport, type PrintTransport, type PrintWay } from "./printTarget"; + +/** The bytes the dialog is about: the label itself, or a script that only configures the printer. */ +export type OutputSource = "label" | "setupScript"; + +export type OutputKind = "print" | "file"; + +export type FileFormat = "pdf" | "png"; + +/** The batch count is shown and not chosen, so the scope is all that is left to decide. */ +export type PdfScope = "design" | "batch"; + +/** What the dialog proposes again next time. */ +export interface OutputChoice { + kind: OutputKind; + /** Only the system dialog, because the four Zebra ways are the print target the printer settings own. */ + printWay: "system" | null; + fileFormat: FileFormat; + pdfScope: PdfScope; +} + +/** The desktop shell owns a printer channel, so the code is the shortest way out, and a browser tab has + * only the system dialog. */ +export const defaultOutputChoice = (desktop: boolean): OutputChoice => ({ + kind: "print", + printWay: desktop ? null : "system", + fileFormat: "pdf", + pdfScope: "design", +}); + +/** What this document can produce, in the terms the menu already works out. The menu entries and + * the dialog read the same answer, so they cannot disagree about what is possible. */ +export interface OutputFacts { + hasObjects: boolean; + /** An overlay page emits even with no objects, so emitting and saving gate on this, not on hasObjects. */ + documentEmits: boolean; + /** A live source-edit session may not emit the document it is about to replace. */ + sourceEditing: boolean; + canBatchExport: boolean; + /** A batch PDF needs a renderer for every row. */ + canBatchPdf: boolean; + /** Named on the batch action, so the dialog commits the user to as many pages as the menu does. */ + batchRowCount: number; + /** Without a renderer the PDF holds the current page only, which the action names. */ + pdfCurrentPageOnly: boolean; +} + +/** A config-only overlay stream is a legitimate setup job, so the printer ways stay open where nothing + * renders. A file always holds the drawn label and needs objects. */ +const producibleKinds = (f: OutputFacts): Record => { + const live = !f.sourceEditing; + return { + print: live && f.documentEmits, + file: live && f.hasObjects, + }; +}; + +export const anyProducible = (f: OutputFacts): boolean => Object.values(producibleKinds(f)).some(Boolean); + +/** A setup script asks nothing of the document. */ +export function offeredKinds(source: OutputSource, f: OutputFacts): OutputKind[] { + if (source === "setupScript") return ["print"]; + const can = producibleKinds(f); + return (["print", "file"] as const).filter((kind) => can[kind]); +} + +/** A rendered label is what the system dialog and a PNG carry, and a setup script draws nothing. */ +export const renderableOutput = (source: OutputSource, f: OutputFacts): boolean => + source === "label" && f.hasObjects && !f.sourceEditing; + +/** The batch needs a mapped dataset and a renderer that can draw every row. Without both there is + * nothing to choose, and the export covers the whole design. */ +export function offeredScopes(f: OutputFacts): PdfScope[] { + return f.canBatchExport && f.canBatchPdf ? ["design", "batch"] : ["design"]; +} + +/** Falls back to the choice that always holds, without touching what is remembered. Mapping a + * dataset again brings the batch back on its own. */ +export const effectiveKind = (kind: OutputKind, offered: OutputKind[]): OutputKind => + offered.includes(kind) ? kind : (offered[0] ?? "print"); + +export const effectiveScope = (scope: PdfScope, offered: PdfScope[]): PdfScope => (offered.includes(scope) ? scope : "design"); + +/** The way the dialog shows and the Zebra tab behind it. A remembered system dialog wins while it is on + * offer, and otherwise the stored Zebra way decides, so a change in the printer settings carries over. */ +export function effectivePrintWay( + remembered: "system" | null, + stored: PrintTransport, + offered: readonly PrintWay[], +): { way: PrintWay; tab: PrintTransport } { + const tab = effectiveTransport(stored, offered); + return { way: remembered === "system" && offered.includes("system") ? "system" : tab, tab }; +} diff --git a/src/lib/printPreview.ts b/src/lib/printPreview.ts index 29b22043..b33b7344 100644 --- a/src/lib/printPreview.ts +++ b/src/lib/printPreview.ts @@ -37,44 +37,3 @@ export function buildPreviewZpl( // and a render must print, so the format is not stored (^DF prints nothing, p.1704). return stripSidecarComments(generateZPL(withoutStoredFormat(label), previewed, [])); } - -export function buildLoadingHtml(): string { - return `Loading preview…`; -} - -export function buildPrintHtml(imageUrl: string): string { - return ` - - `; -} - -export async function printLabel(image: Promise): Promise { - // Open the window synchronously (inside the user-click call stack) so browsers - // don't treat it as a popup. Fill it in once the render arrives. - const win = window.open("", "_blank"); - if (!win) { - // The render is already under way. Settle it, so nothing rejects unhandled and no blob URL leaks. - void image.then((url) => URL.revokeObjectURL(url), () => undefined); - return; - } - win.document.write(buildLoadingHtml()); - win.document.close(); - - try { - const url = await image; - win.document.open(); - win.document.write(buildPrintHtml(url)); - win.document.close(); - setTimeout(() => URL.revokeObjectURL(url), 10_000); - } catch (e) { - win.close(); - throw e; - } -} diff --git a/src/lib/printSheet.test.ts b/src/lib/printSheet.test.ts new file mode 100644 index 00000000..fbe11b66 --- /dev/null +++ b/src/lib/printSheet.test.ts @@ -0,0 +1,269 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { printLabel, printReplaced } from "./printSheet"; + +const decodes = () => Object.defineProperty(HTMLImageElement.prototype, "decode", { value: () => Promise.resolve(), configurable: true }); + +describe("printLabel", () => { + let revoked: string[]; + let imgs: HTMLImageElement[]; + let sheetSrc: string | null; + let sheetsAtPrint = 0; + let print: ReturnType; + const armed = () => document.documentElement.classList.contains("printing-label"); + const sheets = () => document.querySelectorAll(".print-sheet").length; + /** The module's own image never enters the document before it is ready, so the test holds it instead. */ + const held = (nth = imgs.length - 1): HTMLImageElement => { + const img = imgs[nth]; + if (!img) throw new Error(`no image number ${nth} was built`); + return img; + }; + const seen = () => { + sheetSrc = document.querySelector(".print-sheet img")?.src ?? null; + sheetsAtPrint = sheets(); + }; + const announcesAfterprint = () => { + seen(); + window.dispatchEvent(new Event("beforeprint")); + window.dispatchEvent(new Event("afterprint")); + }; + const dialogStillOpen = () => { + seen(); + window.dispatchEvent(new Event("beforeprint")); + }; + const shellPromise = () => { + seen(); + return Promise.resolve(); + }; + + beforeEach(() => { + revoked = []; + imgs = []; + sheetSrc = null; + sheetsAtPrint = 0; + vi.useFakeTimers(); + vi.spyOn(URL, "revokeObjectURL").mockImplementation((url) => revoked.push(url)); + // jsdom loads no images and offers no decode, so the browser's is stood in for. + decodes(); + const create = document.createElement.bind(document); + vi.spyOn(document, "createElement").mockImplementation((tag: string) => { + const el = create(tag); + if (tag === "img") imgs.push(el as HTMLImageElement); + return el; + }); + print = vi.fn(announcesAfterprint); + vi.stubGlobal("print", print); + }); + + afterEach(async () => { + decodes(); + print.mockImplementation(announcesAfterprint); + await printLabel("blob:reset"); + vi.runOnlyPendingTimers(); + vi.useRealTimers(); + delete (HTMLImageElement.prototype as Partial).decode; + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + document.body.innerHTML = ""; + document.documentElement.classList.remove("printing-label"); + }); + + it("prints through this document and releases the label image afterwards", async () => { + await printLabel("blob:label"); + + expect(print).toHaveBeenCalledOnce(); + expect(sheetSrc).toBe("blob:label"); + expect(armed()).toBe(false); + expect(sheets()).toBe(0); + expect(revoked).toEqual(["blob:label"]); + }); + + it("leaves nothing listening once the job is through", async () => { + await printLabel("blob:over"); + + // A print the user starts themselves must show the app, not the label from before. + window.dispatchEvent(new Event("beforeprint")); + expect(armed()).toBe(false); + expect(sheets()).toBe(0); + }); + + it("keeps the sheet standing for as long as a dialog takes", async () => { + print.mockImplementation(dialogStillOpen); + await printLabel("blob:slow"); + + await vi.advanceTimersByTimeAsync(900_000); + // No deadline may cut a print short: the sheet is what the engine paints when the user confirms. + expect(armed()).toBe(true); + expect(sheets()).toBe(1); + expect(revoked).toEqual([]); + window.dispatchEvent(new Event("afterprint")); + expect(sheets()).toBe(0); + }); + + it("answers the caller as soon as the engine takes the call", async () => { + // An engine that opens a dialog and reports nothing until the user has answered it. + print.mockImplementation(() => { + seen(); + }); + + await printLabel("blob:quiet"); + + await vi.advanceTimersByTimeAsync(900_000); + expect(armed()).toBe(true); + expect(sheets()).toBe(1); + expect(revoked).toEqual([]); + }); + + it("hands a refused print to the caller instead of reporting success", async () => { + print.mockImplementation(() => { + throw new Error("printing is blocked"); + }); + + await expect(printLabel("blob:blocked")).rejects.toThrow("printing is blocked"); + expect(armed()).toBe(false); + expect(sheets()).toBe(0); + expect(revoked).toEqual(["blob:blocked"]); + }); + + it("still prints where the print media query has no event target", async () => { + // Safari 13 is such a build. + vi.stubGlobal("matchMedia", () => { + throw new TypeError("addEventListener is not a function"); + }); + + await printLabel("blob:old-safari"); + + expect(sheetSrc).toBe("blob:old-safari"); + expect(sheets()).toBe(0); + expect(revoked).toEqual(["blob:old-safari"]); + }); + + it("takes both edges of the print media query as word about the job", async () => { + let fire: (e: { matches: boolean }) => void = () => undefined; + const media = { + matches: false, + addEventListener: (_: string, cb: (e: { matches: boolean }) => void) => { + fire = cb; + }, + removeEventListener: () => undefined, + }; + vi.stubGlobal("matchMedia", () => media); + // An engine that reports no print event, as some WebKit builds do. + print.mockImplementation(() => { + seen(); + fire({ matches: true }); + }); + + await printLabel("blob:media"); + + await vi.advanceTimersByTimeAsync(2_000); + expect(sheetSrc).toBe("blob:media"); + expect(armed()).toBe(true); + expect(sheets()).toBe(1); + + fire({ matches: false }); + expect(armed()).toBe(false); + expect(sheets()).toBe(0); + expect(revoked).toEqual(["blob:media"]); + }); + + it("replaces the previous label instead of stacking a second one onto the page", async () => { + await printLabel("blob:first"); + await printLabel("blob:second"); + + expect(sheetSrc).toBe("blob:second"); + expect(sheetsAtPrint).toBe(1); + expect(revoked).toEqual(["blob:first", "blob:second"]); + }); + + describe("when the shell prints for us", () => { + it("takes the promise as the answer and keeps the sheet for the dialog", async () => { + print.mockImplementation(shellPromise); + await printLabel("blob:shell"); + + expect(sheetSrc).toBe("blob:shell"); + expect(armed()).toBe(true); + expect(sheets()).toBe(1); + await vi.advanceTimersByTimeAsync(900_000); + expect(sheets()).toBe(1); + expect(revoked).toEqual([]); + }); + + it("hands a late refusal to its own caller and leaves the sheet that replaced it", async () => { + let refuse: (e: Error) => void = () => undefined; + print.mockImplementation(() => new Promise((_, reject) => (refuse = reject))); + const first = expect(printLabel("blob:a")).rejects.toThrow("not allowed"); + await vi.advanceTimersByTimeAsync(0); + + print.mockImplementation(shellPromise); + await printLabel("blob:b"); + refuse(new Error("webview.print not allowed")); + await first; + + // The late refusal belongs to a print that is already gone and may not touch the one standing. + expect(sheets()).toBe(1); + expect(armed()).toBe(true); + expect(revoked).toEqual(["blob:a"]); + }); + + it("hands the shell's own refusal to the caller", async () => { + // A missing webview print permission is how Tauri rejects this call on macOS. + print.mockImplementation(() => Promise.reject(new Error("webview.print not allowed"))); + + await expect(printLabel("blob:denied")).rejects.toThrow("not allowed"); + expect(armed()).toBe(false); + expect(sheets()).toBe(0); + expect(revoked).toEqual(["blob:denied"]); + }); + }); + + describe("while the bytes are still coming", () => { + beforeEach(() => { + // No decode, so the load event is the only gate and the test holds it open. + delete (HTMLImageElement.prototype as Partial).decode; + }); + + it("has nothing in the document and nothing armed", async () => { + const printing = printLabel("blob:pending"); + await vi.advanceTimersByTimeAsync(0); + + expect(armed()).toBe(false); + expect(sheets()).toBe(0); + expect(print).not.toHaveBeenCalled(); + + held().dispatchEvent(new Event("load")); + await printing; + expect(sheetSrc).toBe("blob:pending"); + }); + + it("gives the later click the sheet, whatever order the renders come back in", async () => { + const first = expect(printLabel("blob:one")).rejects.toBe(printReplaced); + await vi.advanceTimersByTimeAsync(0); + const second = printLabel("blob:two"); + await vi.advanceTimersByTimeAsync(0); + expect(sheets()).toBe(0); + + held(0).dispatchEvent(new Event("load")); + await first; + expect(print).not.toHaveBeenCalled(); + expect(revoked).toEqual(["blob:one"]); + + held(1).dispatchEvent(new Event("load")); + await second; + expect(sheetSrc).toBe("blob:two"); + expect(sheetsAtPrint).toBe(1); + }); + + it("releases the image and leaves no sheet when it fails to load", async () => { + const printing = printLabel("blob:broken"); + await vi.advanceTimersByTimeAsync(0); + held().dispatchEvent(new Event("error")); + + await expect(printing).rejects.toThrow("could not be loaded"); + expect(print).not.toHaveBeenCalled(); + expect(armed()).toBe(false); + expect(sheets()).toBe(0); + expect(revoked).toEqual(["blob:broken"]); + }); + }); +}); diff --git a/src/lib/printSheet.ts b/src/lib/printSheet.ts new file mode 100644 index 00000000..13e906c4 --- /dev/null +++ b/src/lib/printSheet.ts @@ -0,0 +1,149 @@ +/** The sheet the print shows. One at a time, so the class needs no instance of its own. */ +const SHEET_CLASS = "print-sheet"; +/** Without this flag on the root the user's own Ctrl+P would hide the app and print a blank page. */ +const PRINTING_CLASS = "printing-label"; + +/** Shown when no print starts. No reason the engine gives leaves the caller anything to add. */ +export const printErrorMessage = "Could not start the print."; + +/** Not a failure to report: a newer print took this sheet over, which is what the user asked for. */ +export const printReplaced = new Error("the print was replaced"); + +let standing: Standing | undefined; + +/** Counts the clicks, so the newest print wins even when an older render answers after it. */ +let issued = 0; + +interface Standing { + sheet: HTMLDivElement; + url: string; + stop: () => void; + settle: (failure?: unknown) => void; +} + +/** Prints through this document because a Tauri webview opens no second window. */ +export async function printLabel(url: string): Promise { + const ticket = ++issued; + const img = document.createElement("img"); + img.alt = ""; + img.src = url; + try { + // Waiting happens before anything is installed, so a failure here leaves no trace to undo. + await imageReady(img); + } catch (e) { + URL.revokeObjectURL(url); + throw e; + } + if (ticket !== issued) { + URL.revokeObjectURL(url); + throw printReplaced; + } + return printNow(img, url); +} + +/** No await before the print, so a second print can never find a half-installed first one. */ +function printNow(img: HTMLImageElement, url: string): Promise { + const sheet = document.createElement("div"); + sheet.className = SHEET_CLASS; + sheet.append(img); + retire(standing?.sheet); + const { promise, settle } = deferred(); + // Tracked before it can be seen, or a throw below would orphan the sheet for the session. + standing = { sheet, url, settle, stop: () => undefined }; + document.body.append(sheet); + arm(true); + try { + // The watch stands before the print, because Chromium reports the job while the call blocks. + const stop = watchEpisode(sheet); + standing.stop = stop; + const answer: unknown = window.print(); + // This path reports no print event. + if (thenable(answer)) { + // The local one, because a print that reported itself during the call is already retired. + stop(); + if (standing?.sheet === sheet) standing.stop = () => undefined; + answer.then(() => settle(), refused(sheet, settle)); + } else { + // Waiting for a print event would cut off every engine that reports only after the dialog. + settle(); + } + } catch (e) { + settle(e); + retire(sheet); + } + return promise; +} + +/** Only a refusal clears the sheet, because it is the one answer that printed nothing. */ +const refused = (sheet: HTMLDivElement, settle: (failure?: unknown) => void) => (e: unknown) => { + settle(e); + retire(sheet); +}; + +/** The job's own brackets: the sheet goes when the episode ends. An engine that never reports an end + * leaves it to the next print, and in the shell no other print can reach the webview. */ +function watchEpisode(sheet: HTMLDivElement): () => void { + const onAfter = () => retire(sheet); + // The media query brackets the episode too, and some builds answer only there. + const stopMedia = watchMedia((e) => { + if (!e.matches) retire(sheet); + }); + window.addEventListener("afterprint", onAfter); + return () => { + stopMedia(); + window.removeEventListener("afterprint", onAfter); + }; +} + +/** Safari 13 has the print media query but not its event target, and there the print events are the + * only word. */ +function watchMedia(onChange: (e: MediaQueryListEvent) => void): () => void { + try { + const media = window.matchMedia("print"); + media.addEventListener("change", onChange); + return () => media.removeEventListener("change", onChange); + } catch { + return () => undefined; + } +} + +/** The shell answers with a promise, and a thenable from another realm is the same answer. */ +const thenable = (v: unknown): v is PromiseLike => + typeof v === "object" && v !== null && typeof (v as { then?: unknown }).then === "function"; + +/** Named per sheet, so an older print cannot retire the one that replaced it. */ +function retire(sheet: HTMLDivElement | undefined): void { + if (!standing || standing.sheet !== sheet) return; + const { url, stop } = standing; + standing = undefined; + stop(); + arm(false); + sheet.remove(); + URL.revokeObjectURL(url); +} + +const arm = (on: boolean): void => { + document.documentElement.classList.toggle(PRINTING_CLASS, on); +}; + +/** The print decides this outcome, not the call that starts it. Settling twice is how the paths + * overlap on purpose: the first reason wins and every later one is a no-op. */ +function deferred(): { promise: Promise; settle: (failure?: unknown) => void } { + let settle: (failure?: unknown) => void = () => undefined; + const promise = new Promise((resolve, reject) => { + settle = (failure) => (failure === undefined ? resolve() : reject(failure)); + }); + return { promise, settle }; +} + +/** Awaited before the print, or the sheet spools blank. No deadline, because the caller renders + * first and hands over local bytes. */ +async function imageReady(img: HTMLImageElement): Promise { + if (img.complete) return; + const loaded = new Promise((resolve, reject) => { + img.onload = () => resolve(); + img.onerror = () => reject(new Error("the label image could not be loaded")); + }); + if (!img.decode) return loaded; + return img.decode().catch(() => loaded); +} diff --git a/src/lib/printTarget.test.ts b/src/lib/printTarget.test.ts index 668708dd..eda823d9 100644 --- a/src/lib/printTarget.test.ts +++ b/src/lib/printTarget.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { DEFAULT_PRINT_TARGET, effectiveTransport, offeredTransports, parsePort, readLegacyPrintTarget, resolveQueryTarget } from "./printTarget"; +import { DEFAULT_PRINT_TARGET, effectiveTransport, needsRawChannel, offeredTransports, parsePort, readLegacyPrintTarget, resolveQueryTarget } from "./printTarget"; function storageOf(entries: Record) { const map = new Map(Object.entries(entries)); @@ -31,6 +31,17 @@ describe("printTarget", () => { expect(effectiveTransport("local", offeredTransports(false, all))).toBe("network"); }); + it("names the ways a status read blocks", () => { + expect(needsRawChannel("network", "none")).toBe(true); + expect(needsRawChannel("usb", "none")).toBe(true); + // The agent and the OS spooler own the device, so a read of our own cannot be in their way. + expect(needsRawChannel("browserprint", "printer")).toBe(false); + expect(needsRawChannel("local", "printer")).toBe(false); + // The system way sends no code, so only the render it needs first can contend. + expect(needsRawChannel("system", "printer")).toBe(true); + expect(needsRawChannel("system", "labelary")).toBe(false); + }); + it("takes the pre-0.7.0 browser keys over once and clears them", () => { const storage = storageOf({ zebra_print_ip: " 172.17.17.175 ", diff --git a/src/lib/printTarget.ts b/src/lib/printTarget.ts index 75bae2ba..522962f9 100644 --- a/src/lib/printTarget.ts +++ b/src/lib/printTarget.ts @@ -1,5 +1,11 @@ +import type { PreviewProvider } from "../store/slices/uiSlice"; + export type PrintTransport = "network" | "browserprint" | "local" | "usb"; +/** A way out of the output dialog. The system dialog hands a rendered label to the operating system, so it + * binds no device and answers nothing. */ +export type PrintWay = PrintTransport | "system"; + export type QueryTarget = | { kind: "network"; host: string; port: number } | { kind: "usb"; id: string }; @@ -46,8 +52,18 @@ export function offeredTransports(desktop: boolean, present: { local: boolean; u ]; } +/** The ways the output dialog offers. Only a label that can be drawn gates the system dialog. */ +export function offeredWays(desktop: boolean, present: { local: boolean; usb: boolean }, renderable: boolean): PrintWay[] { + return [...offeredTransports(desktop, present), ...(renderable ? ["system" as const] : [])]; +} + +/** The ways a status read blocks, plus the system dialog while the printer itself renders. */ +export function needsRawChannel(way: PrintWay, renderer: PreviewProvider): boolean { + return way === "network" || way === "usb" || (way === "system" && renderer === "printer"); +} + /** The stored way when the build offers it, else the network form, without rewriting the choice. */ -export function effectiveTransport(transport: PrintTransport, offered: PrintTransport[]): PrintTransport { +export function effectiveTransport(transport: PrintTransport, offered: readonly PrintWay[]): PrintTransport { return offered.includes(transport) ? transport : "network"; } diff --git a/src/lib/printWayText.test.ts b/src/lib/printWayText.test.ts new file mode 100644 index 00000000..8718548b --- /dev/null +++ b/src/lib/printWayText.test.ts @@ -0,0 +1,15 @@ +import { describe, it, expect } from "vitest"; +import en from "../locales/en"; +import { wayHint } from "./printWayText"; + +describe("wayHint", () => { + it("promises a reply only in the build that can read one", () => { + expect(wayHint(en.zebraPrint, "network", true)).toBe(en.zebraPrint.wayNetwork); + expect(wayHint(en.zebraPrint, "network", false)).toBe(en.zebraPrint.wayNetworkWeb); + }); + + it("keeps one wording for the ways that exist in one build only", () => { + expect(wayHint(en.zebraPrint, "usb", true)).toBe(en.zebraPrint.wayUsb); + expect(wayHint(en.zebraPrint, "browserprint", false)).toBe(en.zebraPrint.wayBrowserPrint); + }); +}); diff --git a/src/lib/printWayText.ts b/src/lib/printWayText.ts new file mode 100644 index 00000000..e85f926a --- /dev/null +++ b/src/lib/printWayText.ts @@ -0,0 +1,28 @@ +// One wording per way to the printer, shared by the output dialog and the printer settings. +import type { PrintWay } from "./printTarget"; +import type { Translations } from "../locales"; + +type Loc = Translations["zebraPrint"]; + +const WAY_KEY = { + network: "wayNetwork", + browserprint: "wayBrowserPrint", + local: "wayLocal", + usb: "wayUsb", + system: "waySystem", +} as const satisfies Record; + +const LABEL_KEY = { + network: "tabNetwork", + browserprint: "tabBrowserPrint", + local: "tabLocal", + usb: "tabUsb", + system: "tabSystem", +} as const satisfies Record; + +export const wayLabel = (loc: Loc, way: PrintWay): string => loc[LABEL_KEY[way]]; + +/** The network way is a raw TCP socket in the desktop shell and a plain POST in the browser, so its + * wording is the only one that depends on the build. */ +export const wayHint = (loc: Loc, way: PrintWay, desktop: boolean): string => + way === "network" && !desktop ? loc.wayNetworkWeb : loc[WAY_KEY[way]]; diff --git a/src/lib/saveLabelImage.ts b/src/lib/saveLabelImage.ts new file mode 100644 index 00000000..c4bf781a --- /dev/null +++ b/src/lib/saveLabelImage.ts @@ -0,0 +1,13 @@ +import { PNG_FILTER, saveErrorMessage, saveFile } from "./fileDialogs"; +import { useLabelStore } from "../store/labelStore"; + +/** Writes a captured canvas image to a file the user picks. Takes the pending capture so a click handler + * can call it without an await, which Safari and Firefox need to keep the user activation. */ +export async function saveLabelImage(pending: Promise, filename: string): Promise { + const blob = await pending; + if (!blob) return; + const { setUserError, clearUserError } = useLabelStore.getState(); + await saveFile(blob, { filename, filters: [PNG_FILTER] }) + .then((wrote) => wrote && clearUserError()) + .catch(() => setUserError(saveErrorMessage)); +} diff --git a/src/lib/sendZpl.test.ts b/src/lib/sendZpl.test.ts new file mode 100644 index 00000000..956e44fc --- /dev/null +++ b/src/lib/sendZpl.test.ts @@ -0,0 +1,51 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { exportPrinterImpact } from "./exportImpact"; +import { sendZpl } from "./sendZpl"; +import { useLabelStore } from "../store/labelStore"; +import type { Page } from "@zplab/core/types/Group"; + +const pages: Page[] = [ + { objects: [{ id: "t", type: "text", x: 10, y: 10, rotation: 0, props: { content: "[sku]", fontHeight: 30 } } as never] }, +]; +const dataset = { + headers: ["sku"], + rows: [["A1"], ["B2"], ["C3"]], + source: { kind: "csv" as const, filename: "t.csv", importedAt: "", encoding: "utf-8", delimiter: ",", rowCount: 3 }, + activeRowIndex: 0, +}; + +beforeEach(() => { + useLabelStore.setState({ + label: { widthMm: 50, heightMm: 30, dpmm: 8 }, + pages, + currentPageIndex: 0, + variables: [{ id: "v1", name: "sku", fnNumber: 1, defaultValue: "" }], + dataset: null, + columnMapping: null, + keepExportMetadata: false, + }); +}); + +describe("sendZpl", () => { + it("emits the document when no dataset is mapped", () => { + const zpl = sendZpl(useLabelStore.getState()); + expect(zpl).toContain("^FD[sku]"); + expect(zpl).not.toContain("^XF"); + }); + + it("emits the batch job once a dataset is mapped", () => { + useLabelStore.setState({ dataset, columnMapping: { bindings: { v1: "sku" }, headerSnapshot: ["sku"] } }); + const zpl = sendZpl(useLabelStore.getState()); + + expect(zpl).toContain("^DFR:LBL.ZPL"); + expect(zpl.match(/\^XFR:LBL\.ZPL/g)).toHaveLength(3); + }); + + it("keeps the printer impact of the whole job in a one-row sample", () => { + useLabelStore.setState({ dataset, columnMapping: { bindings: { v1: "sku" }, headerSnapshot: ["sku"] } }); + const s = useLabelStore.getState(); + + expect(sendZpl(s, 1).match(/\^XFR:LBL\.ZPL/g)).toHaveLength(1); + expect(exportPrinterImpact(sendZpl(s, 1))).toEqual(exportPrinterImpact(sendZpl(s))); + }); +}); diff --git a/src/lib/sendZpl.ts b/src/lib/sendZpl.ts new file mode 100644 index 00000000..597ace60 --- /dev/null +++ b/src/lib/sendZpl.ts @@ -0,0 +1,23 @@ +import { generateBatchZpl, generateMultiPageZPL } from "@zplab/core/lib/zplGenerator"; +import { zplForExport } from "@zplab/core/lib/zplLabelMeta"; +import { + currentObjects, + currentPageLabel, + selectBatchInputs, + selectKeepExportMetadata, + type LabelState, +} from "../store/labelStore"; + +/** The bytes a label send carries: batch form while a dataset is mapped, the document emit otherwise. + * The send and the notices above its button read this one derivation, so they cannot name different jobs. + * `sampleRows` caps the recall blocks for a caller that reads the commands and not the data. */ +export function sendZpl(s: LabelState, sampleRows?: number): string { + const batch = selectBatchInputs(s); + if (!batch) return zplForExport(generateMultiPageZPL(s.label, s.pages, s.variables), selectKeepExportMetadata(s)); + const dataset = + sampleRows === undefined ? batch.dataset : { ...batch.dataset, rows: batch.dataset.rows.slice(0, sampleRows) }; + return zplForExport( + generateBatchZpl(currentPageLabel(s), currentObjects(s), s.variables, dataset, batch.mapping), + selectKeepExportMetadata(s), + ); +} diff --git a/src/locales/ar.ts b/src/locales/ar.ts index 3385a3a6..1c6ca084 100644 --- a/src/locales/ar.ts +++ b/src/locales/ar.ts @@ -988,11 +988,10 @@ const ar = { exportZpl: 'تصدير ZPL', menuInitError: 'تعذّر تحميل قائمة التطبيق. يُرجى إعادة تشغيل التطبيق.', importCsvData: 'استيراد بيانات CSV', - sendToZebraBatchFmt: 'إرسال إلى Zebra ({n} ملصقات)', exportBatchZplFmt: 'تصدير ZPL دفعي ({n} ملصقات)', exportPdf: 'تصدير PDF', - exportBatchPdfFmt: 'تصدير PDF دفعي ({n} ملصقات)', exportPdfCurrentPage: 'تصدير PDF (الصفحة الحالية)', + exportPng: 'تصدير PNG', newDesign: 'تصميم جديد', addPage: 'إضافة صفحة', cancel: 'إلغاء', @@ -1011,8 +1010,6 @@ const ar = { deletePageConfirm: 'حذف الصفحة الحالية؟', openDesign: 'فتح تصميم', saveDesign: 'حفظ تصميم', - print: 'طباعة كصورة (المتصفح)', - sendToZebra: 'إرسال إلى طابعة Zebra', propertiesTab: 'الخصائص', layersTab: 'الطبقات', fontsTab: 'الخطوط', @@ -1839,11 +1836,27 @@ const ar = { }, zebraPrint: { heading: 'إرسال إلى طابعة Zebra', + outputHeading: 'الإخراج', + kindPrint: 'طباعة', + kindPrintHint: 'يرسل الملصق إلى طابعة.', + kindFile: 'ملف', + kindFileHint: 'يحفظ الملصق على هذا الكمبيوتر.', + formatHeading: 'تنسيق', + formatPdf: 'PDF', + formatPdfHint: 'يحفظ الملصق بصيغة PDF.', + formatPng: 'PNG', + formatPngHint: 'يحفظ الملصق كصورة. صف البيانات النشط فقط.', + scopeHeading: 'النطاق', + scopeBatch: 'ملصق واحد لكل صف بيانات', + scopeDesign: 'التصميم فقط', + scopeCountFmt: '{label} ({n})', + systemRendering: 'يجري تجهيز الملصق. بعد ذلك يُفتح مربع حوار الطباعة في النظام.', + systemActiveRowOnlyFmt: 'يُطبع صف البيانات {n} من {rows} فقط.', batchNoticeFmt: 'يطبع {n} ملصقات (واحد لكل صف بيانات).', batchNoticeQtyFmt: 'يطبع {n} ملصقات ({rows} صفوف × {q} لكل صف).', - tabNetwork: 'الشبكة (IP)', - tabBrowserPrint: 'Zebra Browser Print', - tabLocal: 'طابعة محلية', + tabNetwork: 'طابعة ZPL (شبكة)', + tabBrowserPrint: 'طابعة ZPL (Browser Print)', + tabLocal: 'طابعة ZPL (محلية)', ipAddress: 'عنوان IP', port: 'المنفذ', send: 'إرسال ZPL', @@ -1861,7 +1874,16 @@ const ar = { errorNoResponse: 'الطابعة لا تستجيب، تحقق من عنوان IP والشبكة', errorGeneric: 'فشل إرسال ZPL', httpsWarning: 'الصفحة على HTTPS، قد يحظر المتصفح الطباعة المباشرة عبر IP (محتوى مختلط).', - tabUsb: 'USB', + tabUsb: 'طابعة ZPL (USB)', + tabSystem: 'طابعة أخرى', + wayHeading: 'الطابعة', + wayNetwork: 'يرسل عبر TCP مباشرة إلى الطابعة، دون مشغّل أو قائمة انتظار. الطابعة تجيب، لذلك تعمل الحالة والمعاينة.', + wayNetworkWeb: 'يرسل الكود إلى منفذ الطابعة. لا يحصل المتصفح على رد، فلا يُقرأ أي شيء.', + wayBrowserPrint: 'يمر عبر Zebra Browser Print على الكمبيوتر الذي يشغّل المتصفح.', + wayLocal: 'يرسل عبر قائمة انتظار الطباعة في نظام التشغيل، والتي يمكن أن تشير إلى الطابعة نفسها. لا تجيب الطابعة بهذه الطريقة.', + wayUsb: 'يُرسل مباشرة إلى الجهاز المتصل ويقرأ الرد.', + waySystem: 'يرسم الملصق ويسلّمه إلى حوار الطباعة في نظام التشغيل، لأي طابعة لها برنامج تشغيل. يُطبع صف البيانات النشط فقط.', + wayQueryAddress: 'يذهب الفحص دائمًا إلى عنوان الطابعة.', usbPermissionDenied: 'لا يمكن الوصول إلى طابعة USB. انقر على «إعداد الوصول» لمنح الإذن.', usbSetupAccess: 'إعداد الوصول', usbSetupFlatpak: 'داخل Flatpak، لا يمكن للتطبيق تثبيت قاعدة udev بنفسه. ثبّت الملف 70-zplab.rules من مستودع ZPLab على النظام المضيف، ثم أعد توصيل الطابعة.', diff --git a/src/locales/bg.ts b/src/locales/bg.ts index 9abb43d0..7bd59eba 100644 --- a/src/locales/bg.ts +++ b/src/locales/bg.ts @@ -988,11 +988,10 @@ const bg = { exportZpl: 'Export ZPL', menuInitError: 'Менюто на приложението не можа да се зареди. Рестартирайте приложението.', importCsvData: 'Импортиране на CSV данни', - sendToZebraBatchFmt: 'Изпрати към Zebra ({n} етикета)', exportBatchZplFmt: 'Експорт на пакетен ZPL ({n} етикета)', exportPdf: 'Експорт на PDF', - exportBatchPdfFmt: 'Експорт на пакетен PDF ({n} етикета)', exportPdfCurrentPage: 'Експорт на PDF (текущата страница)', + exportPng: 'Експорт на PNG', newDesign: 'Нов дизайн', addPage: 'Добавяне на страница', cancel: 'Отказ', @@ -1011,8 +1010,6 @@ const bg = { deletePageConfirm: 'Изтриване на текущата страница?', openDesign: 'Отвори дизайн', saveDesign: 'Запази дизайн', - print: 'Печат като изображение (браузър)', - sendToZebra: 'Изпрати към принтер Zebra', propertiesTab: 'Свойства', layersTab: 'Слоеве', fontsTab: 'Шрифтове', @@ -1839,11 +1836,27 @@ const bg = { }, zebraPrint: { heading: 'Изпрати към принтер Zebra', + outputHeading: 'Печат и експорт', + kindPrint: 'Отпечатай', + kindPrintHint: 'Изпраща етикета към принтер.', + kindFile: 'Файл', + kindFileHint: 'Запазва етикета на този компютър.', + formatHeading: 'Формат', + formatPdf: 'PDF', + formatPdfHint: 'Запазва етикета като PDF.', + formatPng: 'PNG', + formatPngHint: 'Запазва етикета като изображение. Само активният ред с данни.', + scopeHeading: 'Обхват', + scopeBatch: 'По един етикет на ред с данни', + scopeDesign: 'Само дизайнът', + scopeCountFmt: '{label} ({n})', + systemRendering: 'Етикетът се изобразява. След това се отваря диалоговият прозорец за печат на системата.', + systemActiveRowOnlyFmt: 'Отпечатва се само ред с данни {n} от {rows}.', batchNoticeFmt: 'Отпечатва {n} етикета (по един за всеки ред с данни).', batchNoticeQtyFmt: 'Отпечатва {n} етикета ({rows} реда × {q} на ред).', - tabNetwork: 'Мрежа (IP)', - tabBrowserPrint: 'Zebra Browser Print', - tabLocal: 'Локален принтер', + tabNetwork: 'ZPL принтер (мрежа)', + tabBrowserPrint: 'ZPL принтер (Browser Print)', + tabLocal: 'ZPL принтер (локален)', ipAddress: 'IP адрес', port: 'Порт', send: 'Изпрати ZPL', @@ -1861,7 +1874,16 @@ const bg = { errorNoResponse: 'Принтерът не отговаря, проверете IP адреса и мрежата', errorGeneric: 'Неуспешно изпращане на ZPL', httpsWarning: 'Страницата е на HTTPS, директният печат по IP може да бъде блокиран от браузъра (смесено съдържание).', - tabUsb: 'USB', + tabUsb: 'ZPL принтер (USB)', + tabSystem: 'Друг принтер', + wayHeading: 'Принтер', + wayNetwork: 'Изпраща директно към принтера по TCP, без драйвер и опашка. Принтерът отговаря, затова състоянието и визуализацията работят.', + wayNetworkWeb: 'Изпраща кода до порта на принтера. Браузърът не получава отговор, затова нищо не се чете обратно.', + wayBrowserPrint: 'Минава през Zebra Browser Print на компютъра, на който работи браузърът.', + wayLocal: 'Изпраща през опашката за печат на операционната система, която може да сочи към същия принтер. По този път принтерът не отговаря.', + wayUsb: 'Изпраща директно до свързаното устройство и връща отговор.', + waySystem: 'Изчертава етикета и го подава на диалога за печат на операционната система, за всеки принтер с драйвер. Отпечатва се само активният ред с данни.', + wayQueryAddress: 'Проверката винаги отива на адреса на принтера.', usbPermissionDenied: 'Няма достъп до USB принтера. Щракнете върху Настройване на достъп, за да го предоставите.', usbSetupAccess: 'Настройване на достъп', usbSetupFlatpak: 'В Flatpak приложението не може само да инсталира правилото за udev. Инсталирайте 70-zplab.rules от хранилището на ZPLab на хост системата, след което включете принтера отново.', diff --git a/src/locales/cs.ts b/src/locales/cs.ts index ef423725..8cab6c26 100644 --- a/src/locales/cs.ts +++ b/src/locales/cs.ts @@ -988,11 +988,10 @@ const cs = { exportZpl: 'Export ZPL', menuInitError: 'Nabídku aplikace se nepodařilo načíst. Restartujte aplikaci.', importCsvData: 'Importovat data CSV', - sendToZebraBatchFmt: 'Odeslat na Zebra ({n} etiket)', exportBatchZplFmt: 'Exportovat dávkové ZPL ({n} etiket)', exportPdf: 'Exportovat PDF', - exportBatchPdfFmt: 'Exportovat dávkové PDF ({n} etiket)', exportPdfCurrentPage: 'Exportovat PDF (aktuální stránka)', + exportPng: 'Exportovat PNG', newDesign: 'Nový návrh', addPage: 'Přidat stránku', cancel: 'Zrušit', @@ -1011,8 +1010,6 @@ const cs = { deletePageConfirm: 'Smazat aktuální stránku?', openDesign: 'Otevřít návrh', saveDesign: 'Uložit návrh', - print: 'Tisk jako obrázek (prohlížeč)', - sendToZebra: 'Odeslat na tiskárnu Zebra', propertiesTab: 'Vlastnosti', layersTab: 'Vrstvy', fontsTab: 'Písma', @@ -1839,11 +1836,27 @@ const cs = { }, zebraPrint: { heading: 'Odeslat na tiskárnu Zebra', + outputHeading: 'Výstup', + kindPrint: 'Vytisknout', + kindPrintHint: 'Odešle štítek do tiskárny.', + kindFile: 'Soubor', + kindFileHint: 'Uloží štítek do tohoto počítače.', + formatHeading: 'Formát', + formatPdf: 'PDF', + formatPdfHint: 'Uloží štítek jako PDF.', + formatPng: 'PNG', + formatPngHint: 'Uloží štítek jako obrázek. Jen aktivní řádek dat.', + scopeHeading: 'Rozsah', + scopeBatch: 'Jeden štítek na řádek dat', + scopeDesign: 'Pouze návrh', + scopeCountFmt: '{label} ({n})', + systemRendering: 'Štítek se vykresluje. Poté se otevře dialog tisku systému.', + systemActiveRowOnlyFmt: 'Vytiskne se jen řádek dat {n} z {rows}.', batchNoticeFmt: 'Vytiskne {n} etiket (jednu na každý řádek dat).', batchNoticeQtyFmt: 'Vytiskne {n} etiket ({rows} řádků × {q} na řádek).', - tabNetwork: 'Síť (IP)', - tabBrowserPrint: 'Zebra Browser Print', - tabLocal: 'Místní tiskárna', + tabNetwork: 'ZPL tiskárna (síť)', + tabBrowserPrint: 'ZPL tiskárna (Browser Print)', + tabLocal: 'ZPL tiskárna (místní)', ipAddress: 'IP adresa', port: 'Port', send: 'Odeslat ZPL', @@ -1861,7 +1874,16 @@ const cs = { errorNoResponse: 'Tiskárna neodpovídá, zkontrolujte IP a síť', errorGeneric: 'Odeslání ZPL selhalo', httpsWarning: 'Stránka používá HTTPS, přímý tisk přes IP může být prohlížečem zablokován (smíšený obsah).', - tabUsb: 'USB', + tabUsb: 'ZPL tiskárna (USB)', + tabSystem: 'Jiná tiskárna', + wayHeading: 'Tiskárna', + wayNetwork: 'Posílá přes TCP přímo na tiskárnu, bez ovladače a tiskové fronty. Tiskárna odpovídá, proto funguje stav i náhled.', + wayNetworkWeb: 'Odesílá kód na port tiskárny. Prohlížeč nedostane odpověď, takže se nic nenačte zpět.', + wayBrowserPrint: 'Jde přes Zebra Browser Print na počítači, na kterém běží prohlížeč.', + wayLocal: 'Posílá přes tiskovou frontu operačního systému, která může mířit na tutéž tiskárnu. Touto cestou tiskárna neodpovídá.', + wayUsb: 'Odesílá přímo do připojeného zařízení a čte odpověď.', + waySystem: 'Vykreslí štítek a předá jej tiskovému dialogu operačního systému, pro každou tiskárnu s ovladačem. Vytiskne se jen aktivní řádek dat.', + wayQueryAddress: 'Kontrola jde vždy na adresu tiskárny.', usbPermissionDenied: 'Bez přístupu k tiskárně USB. Přístup udělíte kliknutím na Nastavit přístup.', usbSetupAccess: 'Nastavit přístup', usbSetupFlatpak: 'Uvnitř Flatpaku si aplikace nemůže pravidlo udev nainstalovat sama. Nainstalujte 70-zplab.rules z repozitáře ZPLab na hostitelský systém a tiskárnu znovu připojte.', diff --git a/src/locales/da.ts b/src/locales/da.ts index 2c27f6bc..1b96a489 100644 --- a/src/locales/da.ts +++ b/src/locales/da.ts @@ -988,11 +988,10 @@ const da = { exportZpl: 'Export ZPL', menuInitError: 'Programmets menu kunne ikke indlæses. Genstart appen.', importCsvData: 'Importer CSV-data', - sendToZebraBatchFmt: 'Send til Zebra ({n} etiketter)', exportBatchZplFmt: 'Eksportér batch-ZPL ({n} etiketter)', exportPdf: 'Eksportér PDF', - exportBatchPdfFmt: 'Eksportér batch-PDF ({n} etiketter)', exportPdfCurrentPage: 'Eksportér PDF (aktuel side)', + exportPng: 'Eksportér PNG', newDesign: 'Nyt design', addPage: 'Tilføj side', cancel: 'Annuller', @@ -1011,8 +1010,6 @@ const da = { deletePageConfirm: 'Slet den aktuelle side?', openDesign: 'Åbn design', saveDesign: 'Gem design', - print: 'Udskriv som billede (browser)', - sendToZebra: 'Send til Zebra-printer', propertiesTab: 'Egenskaber', layersTab: 'Lag', fontsTab: 'Skrifttyper', @@ -1839,11 +1836,27 @@ const da = { }, zebraPrint: { heading: 'Send til Zebra-printer', + outputHeading: 'Uddata', + kindPrint: 'Udskriv', + kindPrintHint: 'Sender etiketten til en printer.', + kindFile: 'Fil', + kindFileHint: 'Gemmer etiketten på denne computer.', + formatHeading: 'Format', + formatPdf: 'PDF', + formatPdfHint: 'Gemmer etiketten som PDF.', + formatPng: 'PNG', + formatPngHint: 'Gemmer etiketten som et billede. Kun den aktive datarække.', + scopeHeading: 'Omfang', + scopeBatch: 'Én etiket pr. datarække', + scopeDesign: 'Kun designet', + scopeCountFmt: '{label} ({n})', + systemRendering: 'Etiketten gengives. Derefter åbnes systemets udskriftsdialog.', + systemActiveRowOnlyFmt: 'Kun datarække {n} af {rows} udskrives.', batchNoticeFmt: 'Udskriver {n} etiketter (én pr. datarække).', batchNoticeQtyFmt: 'Udskriver {n} etiketter ({rows} rækker × {q} pr. række).', - tabNetwork: 'Netværk (IP)', - tabBrowserPrint: 'Zebra Browser Print', - tabLocal: 'Lokal printer', + tabNetwork: 'ZPL-printer (netværk)', + tabBrowserPrint: 'ZPL-printer (Browser Print)', + tabLocal: 'ZPL-printer (lokal)', ipAddress: 'IP-adresse', port: 'Port', send: 'Send ZPL', @@ -1861,7 +1874,16 @@ const da = { errorNoResponse: 'Printeren svarer ikke, kontrollér IP og netværk', errorGeneric: 'Afsendelse af ZPL mislykkedes', httpsWarning: 'Siden bruger HTTPS, direkte udskrivning via IP kan blive blokeret af browseren (blandet indhold).', - tabUsb: 'USB', + tabUsb: 'ZPL-printer (USB)', + tabSystem: 'Anden printer', + wayHeading: 'Printer', + wayNetwork: 'Sender over TCP direkte til printeren, uden driver og kø. Printeren svarer, så status og forhåndsvisning virker.', + wayNetworkWeb: 'Sender koden til printerens port. Browseren får intet svar, så intet læses retur.', + wayBrowserPrint: 'Går via Zebra Browser Print på den computer, der kører browseren.', + wayLocal: 'Sender via operativsystemets printerkø, som kan pege på den samme printer. Ad den vej svarer printeren ikke.', + wayUsb: 'Sender direkte til den tilsluttede enhed og læser retur.', + waySystem: 'Tegner etiketten og sender den til operativsystemets udskriftsdialog, til enhver printer med en driver. Kun den aktive datarække udskrives.', + wayQueryAddress: 'Kontrollen går altid til printerens adresse.', usbPermissionDenied: 'Ingen adgang til USB-printeren. Klik på Konfigurer adgang for at give den.', usbSetupAccess: 'Konfigurer adgang', usbSetupFlatpak: 'Inde i Flatpak kan appen ikke selv installere udev-reglen. Installer 70-zplab.rules fra ZPLab-repositoriet på værtssystemet, og tilslut derefter printeren igen.', diff --git a/src/locales/de.ts b/src/locales/de.ts index 423ad77a..d81f1ff6 100644 --- a/src/locales/de.ts +++ b/src/locales/de.ts @@ -988,11 +988,10 @@ const de = { exportZpl: 'Export ZPL', menuInitError: 'Das Anwendungsmenü konnte nicht geladen werden. Bitte starten Sie die App neu.', importCsvData: 'CSV-Daten importieren', - sendToZebraBatchFmt: 'An Zebra senden ({n} Etiketten)', exportBatchZplFmt: 'Batch-ZPL exportieren ({n} Etiketten)', exportPdf: 'PDF exportieren', - exportBatchPdfFmt: 'Batch-PDF exportieren ({n} Etiketten)', exportPdfCurrentPage: 'PDF exportieren (aktuelle Seite)', + exportPng: 'PNG exportieren', newDesign: 'Neues Design', addPage: 'Seite hinzufügen', cancel: 'Abbrechen', @@ -1011,8 +1010,6 @@ const de = { deletePageConfirm: 'Aktuelle Seite löschen?', openDesign: 'Design öffnen', saveDesign: 'Design speichern', - print: 'Als Bild drucken (Browser)', - sendToZebra: 'An Zebra-Drucker senden', propertiesTab: 'Eigenschaften', layersTab: 'Ebenen', fontsTab: 'Schriften', @@ -1089,11 +1086,27 @@ const de = { zebraPrint: { heading: 'An Zebra-Drucker senden', + outputHeading: 'Ausgabe', + kindPrint: 'Drucken', + kindPrintHint: 'Sendet das Etikett an einen Drucker.', + kindFile: 'Datei', + kindFileHint: 'Speichert das Etikett auf diesem Computer.', + formatHeading: 'Format', + formatPdf: 'PDF', + formatPdfHint: 'Speichert das Etikett als PDF.', + formatPng: 'PNG', + formatPngHint: 'Speichert das Etikett als Bild. Nur die aktive Datenzeile.', + scopeHeading: 'Umfang', + scopeBatch: 'Ein Etikett je Datenzeile', + scopeDesign: 'Nur das Design', + scopeCountFmt: '{label} ({n})', + systemRendering: 'Das Etikett wird gerendert. Danach öffnet sich der Druckdialog des Systems.', + systemActiveRowOnlyFmt: 'Es wird nur Datenzeile {n} von {rows} gedruckt.', batchNoticeFmt: 'Druckt {n} Etiketten (eine pro Datenzeile).', batchNoticeQtyFmt: 'Druckt {n} Etiketten ({rows} Zeilen × {q} je Zeile).', - tabNetwork: 'Netzwerk (IP)', - tabBrowserPrint: 'Zebra Browser Print', - tabLocal: 'Lokaler Drucker', + tabNetwork: 'ZPL-Drucker (Netzwerk)', + tabBrowserPrint: 'ZPL-Drucker (Browser Print)', + tabLocal: 'ZPL-Drucker (lokal)', ipAddress: 'IP-Adresse', port: 'Port', send: 'ZPL senden', @@ -1111,7 +1124,16 @@ const de = { errorNoResponse: 'Drucker antwortet nicht, IP und Netzwerk prüfen', errorGeneric: 'Fehler beim Senden', httpsWarning: 'Seite läuft über HTTPS, direktes IP-Drucken kann vom Browser blockiert werden (Mixed Content).', - tabUsb: 'USB', + tabUsb: 'ZPL-Drucker (USB)', + tabSystem: 'Anderer Drucker', + wayHeading: 'Drucker', + wayNetwork: 'Sendet per TCP direkt an den Drucker, ohne Treiber und Warteschlange. Der Drucker antwortet, darum funktionieren Status und Vorschau.', + wayNetworkWeb: 'Sendet den Code an den Druckerport. Der Browser bekommt keine Antwort, der Drucker antwortet also nicht.', + wayBrowserPrint: 'Läuft über Zebra Browser Print auf dem Rechner, der den Browser ausführt.', + wayLocal: 'Sendet über die Druckwarteschlange des Betriebssystems, die auf denselben Drucker zeigen kann. Auf diesem Weg antwortet der Drucker nicht.', + wayUsb: 'Sendet direkt an das verbundene Gerät und liest zurück.', + waySystem: 'Zeichnet das Etikett und übergibt es an den Druckdialog des Betriebssystems, für jeden Drucker mit Treiber. Es wird nur die aktive Datenzeile gedruckt.', + wayQueryAddress: 'Die Prüfung geht immer an die Druckeradresse.', usbPermissionDenied: 'Kein Zugriff auf den USB-Drucker. Klicke auf Zugriff einrichten.', usbSetupAccess: 'Zugriff einrichten', usbSetupFlatpak: 'Innerhalb von Flatpak kann die App die udev-Regel nicht selbst installieren. Installiere 70-zplab.rules aus dem ZPLab-Repository auf dem Host und schließe den Drucker danach erneut an.', diff --git a/src/locales/el.ts b/src/locales/el.ts index 51745c7d..b900fdc2 100644 --- a/src/locales/el.ts +++ b/src/locales/el.ts @@ -988,11 +988,10 @@ const el = { exportZpl: 'Export ZPL', menuInitError: 'Δεν ήταν δυνατή η φόρτωση του μενού της εφαρμογής. Επανεκκινήστε την εφαρμογή.', importCsvData: 'Εισαγωγή δεδομένων CSV', - sendToZebraBatchFmt: 'Αποστολή σε Zebra ({n} ετικέτες)', exportBatchZplFmt: 'Εξαγωγή παρτίδας ZPL ({n} ετικέτες)', exportPdf: 'Εξαγωγή PDF', - exportBatchPdfFmt: 'Εξαγωγή παρτίδας PDF ({n} ετικέτες)', exportPdfCurrentPage: 'Εξαγωγή PDF (τρέχουσα σελίδα)', + exportPng: 'Εξαγωγή PNG', newDesign: 'Νέο σχέδιο', addPage: 'Προσθήκη σελίδας', cancel: 'Ακύρωση', @@ -1011,8 +1010,6 @@ const el = { deletePageConfirm: 'Διαγραφή της τρέχουσας σελίδας;', openDesign: 'Άνοιγμα σχεδίου', saveDesign: 'Αποθήκευση σχεδίου', - print: 'Εκτύπωση ως εικόνα (πρόγραμμα περιήγησης)', - sendToZebra: 'Αποστολή σε εκτυπωτή Zebra', propertiesTab: 'Ιδιότητες', layersTab: 'Επίπεδα', fontsTab: 'Γραμματοσειρές', @@ -1839,11 +1836,27 @@ const el = { }, zebraPrint: { heading: 'Αποστολή σε εκτυπωτή Zebra', + outputHeading: 'Εκτύπωση και εξαγωγή', + kindPrint: 'Εκτύπωση', + kindPrintHint: 'Στέλνει την ετικέτα σε έναν εκτυπωτή.', + kindFile: 'Αρχείο', + kindFileHint: 'Αποθηκεύει την ετικέτα σε αυτόν τον υπολογιστή.', + formatHeading: 'Μορφή', + formatPdf: 'PDF', + formatPdfHint: 'Αποθηκεύει την ετικέτα ως PDF.', + formatPng: 'PNG', + formatPngHint: 'Αποθηκεύει την ετικέτα ως εικόνα. Μόνο την ενεργή γραμμή δεδομένων.', + scopeHeading: 'Εύρος', + scopeBatch: 'Μία ετικέτα ανά γραμμή δεδομένων', + scopeDesign: 'Μόνο το σχέδιο', + scopeCountFmt: '{label} ({n})', + systemRendering: 'Η ετικέτα αποδίδεται. Στη συνέχεια ανοίγει το παράθυρο εκτύπωσης του συστήματος.', + systemActiveRowOnlyFmt: 'Εκτυπώνεται μόνο η γραμμή δεδομένων {n} από {rows}.', batchNoticeFmt: 'Εκτυπώνει {n} ετικέτες (μία ανά γραμμή δεδομένων).', batchNoticeQtyFmt: 'Εκτυπώνει {n} ετικέτες ({rows} γραμμές × {q} η καθεμία).', - tabNetwork: 'Δίκτυο (IP)', - tabBrowserPrint: 'Zebra Browser Print', - tabLocal: 'Τοπικός εκτυπωτής', + tabNetwork: 'Εκτυπωτής ZPL (δίκτυο)', + tabBrowserPrint: 'Εκτυπωτής ZPL (Browser Print)', + tabLocal: 'Εκτυπωτής ZPL (τοπικός)', ipAddress: 'Διεύθυνση IP', port: 'Θύρα', send: 'Αποστολή ZPL', @@ -1861,7 +1874,16 @@ const el = { errorNoResponse: 'Ο εκτυπωτής δεν αποκρίνεται, ελέγξτε την IP και το δίκτυο', errorGeneric: 'Αποτυχία αποστολής ZPL', httpsWarning: 'Η σελίδα χρησιμοποιεί HTTPS, η άμεση εκτύπωση μέσω IP ενδέχεται να αποκλειστεί από τον φυλλομετρητή (μεικτό περιεχόμενο).', - tabUsb: 'USB', + tabUsb: 'Εκτυπωτής ZPL (USB)', + tabSystem: 'Άλλος εκτυπωτής', + wayHeading: 'Εκτυπωτής', + wayNetwork: "Στέλνει μέσω TCP απευθείας στον εκτυπωτή, χωρίς οδηγό και σειρά αναμονής. Ο εκτυπωτής απαντά, γι' αυτό λειτουργούν η κατάσταση και η προεπισκόπηση.", + wayNetworkWeb: 'Στέλνει τον κώδικα στη θύρα του εκτυπωτή. Το πρόγραμμα περιήγησης δεν λαμβάνει απάντηση, οπότε τίποτα δεν διαβάζεται πίσω.', + wayBrowserPrint: 'Περνάει μέσω του Zebra Browser Print στον υπολογιστή που εκτελεί το πρόγραμμα περιήγησης.', + wayLocal: 'Στέλνει μέσω της σειράς εκτύπωσης του λειτουργικού συστήματος, η οποία μπορεί να δείχνει στον ίδιο εκτυπωτή. Με αυτόν τον τρόπο ο εκτυπωτής δεν απαντά.', + wayUsb: 'Στέλνει απευθείας στη συνδεδεμένη συσκευή και διαβάζει απάντηση.', + waySystem: 'Σχεδιάζει την ετικέτα και την παραδίδει στον διάλογο εκτύπωσης του λειτουργικού συστήματος, για κάθε εκτυπωτή με οδηγό. Εκτυπώνεται μόνο η ενεργή γραμμή δεδομένων.', + wayQueryAddress: 'Ο έλεγχος πηγαίνει πάντα στη διεύθυνση του εκτυπωτή.', usbPermissionDenied: 'Δεν υπάρχει πρόσβαση στον εκτυπωτή USB. Κάντε κλικ στο Ρύθμιση πρόσβασης για να την παραχωρήσετε.', usbSetupAccess: 'Ρύθμιση πρόσβασης', usbSetupFlatpak: 'Μέσα στο Flatpak η εφαρμογή δεν μπορεί να εγκαταστήσει μόνη της τον κανόνα udev. Εγκαταστήστε το 70-zplab.rules από το αποθετήριο ZPLab στο σύστημα υποδοχής και συνδέστε ξανά τον εκτυπωτή.', diff --git a/src/locales/en.ts b/src/locales/en.ts index 43856408..20ff7e88 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -988,11 +988,10 @@ const en = { exportZpl: 'Export ZPL', menuInitError: 'The application menu could not be loaded. Please restart the app.', importCsvData: 'Import CSV data', - sendToZebraBatchFmt: 'Send to Zebra ({n} labels)', exportBatchZplFmt: 'Export batch ZPL ({n} labels)', exportPdf: 'Export PDF', - exportBatchPdfFmt: 'Export batch PDF ({n} labels)', exportPdfCurrentPage: 'Export PDF (current page)', + exportPng: 'Export PNG', newDesign: 'New design', addPage: 'Add page', cancel: 'Cancel', @@ -1011,8 +1010,6 @@ const en = { deletePageConfirm: 'Delete the current page?', openDesign: 'Open design', saveDesign: 'Save design', - print: 'Print as Image (browser)', - sendToZebra: 'Send to Zebra Printer', propertiesTab: 'Properties', layersTab: 'Layers', fontsTab: 'Fonts', @@ -1089,11 +1086,27 @@ const en = { zebraPrint: { heading: 'Send to Zebra Printer', + outputHeading: 'Output', + kindPrint: 'Print', + kindPrintHint: 'Sends the label to a printer.', + kindFile: 'File', + kindFileHint: 'Saves the label on this computer.', + formatHeading: 'Format', + formatPdf: 'PDF', + formatPdfHint: 'Saves the label as PDF.', + formatPng: 'PNG', + formatPngHint: 'Saves the label as an image. Only the active data row.', + scopeHeading: 'Scope', + scopeBatch: 'One label per data row', + scopeDesign: 'The design only', + scopeCountFmt: '{label} ({n})', + systemRendering: 'The label is rendering. The system print dialog follows.', + systemActiveRowOnlyFmt: 'Only data row {n} of {rows} prints.', batchNoticeFmt: 'Prints {n} labels (one per data row).', batchNoticeQtyFmt: 'Prints {n} labels ({rows} rows × {q} each).', - tabNetwork: 'Network (IP)', - tabBrowserPrint: 'Zebra Browser Print', - tabLocal: 'Local printer', + tabNetwork: 'ZPL printer (network)', + tabBrowserPrint: 'ZPL printer (Browser Print)', + tabLocal: 'ZPL printer (local)', ipAddress: 'IP Address', port: 'Port', send: 'Send ZPL', @@ -1111,7 +1124,16 @@ const en = { errorNoResponse: 'Printer did not respond, check IP and network', errorGeneric: 'Failed to send ZPL', httpsWarning: 'Page is on HTTPS, direct IP printing may be blocked by the browser (Mixed Content).', - tabUsb: 'USB', + tabUsb: 'ZPL printer (USB)', + tabSystem: 'Other printer', + wayHeading: 'Printer', + wayNetwork: 'Sends over TCP straight to the printer, past any driver or queue. The printer answers, so status and preview work.', + wayNetworkWeb: "Posts the code to the printer's port. The browser gets no reply, so nothing reads back.", + wayBrowserPrint: 'Goes through Zebra Browser Print on the computer running the browser.', + wayLocal: "Sends through the operating system's print queue, which can point at the same printer. The printer does not answer this way.", + wayUsb: 'Sends straight to the connected device and reads back.', + waySystem: "Renders the label and hands it to the operating system's print dialog, for any printer with a driver. Only the active data row prints.", + wayQueryAddress: 'The check always goes to the printer address.', usbPermissionDenied: 'No access to the USB printer. Click Set up access to grant it.', usbSetupAccess: 'Set up access', usbSetupFlatpak: 'Inside Flatpak the app cannot install the udev rule itself. Install 70-zplab.rules from the ZPLab repository on the host, then re-plug the printer.', diff --git a/src/locales/es.ts b/src/locales/es.ts index 51604952..57cb14b5 100644 --- a/src/locales/es.ts +++ b/src/locales/es.ts @@ -988,11 +988,10 @@ const es = { exportZpl: 'Export ZPL', menuInitError: 'No se pudo cargar el menú de la aplicación. Reinicia la aplicación.', importCsvData: 'Importar datos CSV', - sendToZebraBatchFmt: 'Enviar a Zebra ({n} etiquetas)', exportBatchZplFmt: 'Exportar ZPL por lotes ({n} etiquetas)', exportPdf: 'Exportar PDF', - exportBatchPdfFmt: 'Exportar PDF por lotes ({n} etiquetas)', exportPdfCurrentPage: 'Exportar PDF (página actual)', + exportPng: 'Exportar PNG', newDesign: 'Nuevo diseño', addPage: 'Añadir página', cancel: 'Cancelar', @@ -1011,8 +1010,6 @@ const es = { deletePageConfirm: '¿Eliminar la página actual?', openDesign: 'Abrir diseño', saveDesign: 'Guardar diseño', - print: 'Imprimir como imagen (navegador)', - sendToZebra: 'Enviar a impresora Zebra', propertiesTab: 'Propiedades', layersTab: 'Capas', fontsTab: 'Fuentes', @@ -1839,11 +1836,27 @@ const es = { }, zebraPrint: { heading: 'Enviar a impresora Zebra', + outputHeading: 'Impresión y exportación', + kindPrint: 'Imprimir', + kindPrintHint: 'Envía la etiqueta a una impresora.', + kindFile: 'Archivo', + kindFileHint: 'Guarda la etiqueta en este equipo.', + formatHeading: 'Formato', + formatPdf: 'PDF', + formatPdfHint: 'Guarda la etiqueta como PDF.', + formatPng: 'PNG', + formatPngHint: 'Guarda la etiqueta como imagen. Solo la fila de datos activa.', + scopeHeading: 'Alcance', + scopeBatch: 'Una etiqueta por fila de datos', + scopeDesign: 'Solo el diseño', + scopeCountFmt: '{label} ({n})', + systemRendering: 'La etiqueta se está generando. Después se abre el diálogo de impresión del sistema.', + systemActiveRowOnlyFmt: 'Solo se imprime la fila de datos {n} de {rows}.', batchNoticeFmt: 'Imprime {n} etiquetas (una por fila de datos).', batchNoticeQtyFmt: 'Imprime {n} etiquetas ({rows} filas × {q} cada una).', - tabNetwork: 'Red (IP)', - tabBrowserPrint: 'Zebra Browser Print', - tabLocal: 'Impresora local', + tabNetwork: 'Impresora ZPL (red)', + tabBrowserPrint: 'Impresora ZPL (Browser Print)', + tabLocal: 'Impresora ZPL (local)', ipAddress: 'Dirección IP', port: 'Puerto', send: 'Enviar ZPL', @@ -1861,7 +1874,16 @@ const es = { errorNoResponse: 'La impresora no respondió, comprueba la IP y la red', errorGeneric: 'Error al enviar ZPL', httpsWarning: 'La página está en HTTPS, la impresión directa por IP puede ser bloqueada por el navegador (contenido mixto).', - tabUsb: 'USB', + tabUsb: 'Impresora ZPL (USB)', + tabSystem: 'Otra impresora', + wayHeading: 'Impresora', + wayNetwork: 'Envía por TCP directamente a la impresora, sin controlador ni cola. La impresora responde, por eso funcionan el estado y la vista previa.', + wayNetworkWeb: 'Envía el código al puerto de la impresora. El navegador no recibe respuesta, así que nada responde.', + wayBrowserPrint: 'Pasa por Zebra Browser Print en el ordenador que ejecuta el navegador.', + wayLocal: 'Envía a través de la cola de impresión del sistema operativo, que puede apuntar a la misma impresora. Por esta vía la impresora no responde.', + wayUsb: 'Envía directamente al dispositivo conectado y recibe respuesta.', + waySystem: 'Dibuja la etiqueta y la entrega al diálogo de impresión del sistema operativo, para cualquier impresora con controlador. Solo se imprime la fila de datos activa.', + wayQueryAddress: 'La comprobación siempre va a la dirección de la impresora.', usbPermissionDenied: 'Sin acceso a la impresora USB. Haz clic en Configurar acceso para concederlo.', usbSetupAccess: 'Configurar acceso', usbSetupFlatpak: 'Dentro de Flatpak, la aplicación no puede instalar la regla udev por sí misma. Instala 70-zplab.rules desde el repositorio de ZPLab en el host y luego vuelve a conectar la impresora.', diff --git a/src/locales/et.ts b/src/locales/et.ts index 3e142bab..0e6d4c97 100644 --- a/src/locales/et.ts +++ b/src/locales/et.ts @@ -988,11 +988,10 @@ const et = { exportZpl: 'Export ZPL', menuInitError: 'Rakenduse menüüd ei õnnestunud laadida. Käivitage rakendus uuesti.', importCsvData: 'Impordi CSV-andmed', - sendToZebraBatchFmt: 'Saada Zebrale ({n} silti)', exportBatchZplFmt: 'Ekspordi partii-ZPL ({n} silti)', exportPdf: 'Ekspordi PDF', - exportBatchPdfFmt: 'Ekspordi partii-PDF ({n} silti)', exportPdfCurrentPage: 'Ekspordi PDF (praegune leht)', + exportPng: 'Ekspordi PNG', newDesign: 'Uus kujundus', addPage: 'Lisa leht', cancel: 'Loobu', @@ -1011,8 +1010,6 @@ const et = { deletePageConfirm: 'Kustuta praegune leht?', openDesign: 'Ava kujundus', saveDesign: 'Salvesta kujundus', - print: 'Prindi pildina (brauser)', - sendToZebra: 'Saada Zebra printerile', propertiesTab: 'Omadused', layersTab: 'Kihid', fontsTab: 'Fondid', @@ -1839,11 +1836,27 @@ const et = { }, zebraPrint: { heading: 'Saada Zebra printerile', + outputHeading: 'Väljund', + kindPrint: 'Prindi', + kindPrintHint: 'Saadab sildi printerisse.', + kindFile: 'Fail', + kindFileHint: 'Salvestab sildi sellesse arvutisse.', + formatHeading: 'Vorming', + formatPdf: 'PDF', + formatPdfHint: 'Salvestab sildi PDF-ina.', + formatPng: 'PNG', + formatPngHint: 'Salvestab sildi pildina. Ainult aktiivne andmerida.', + scopeHeading: 'Ulatus', + scopeBatch: 'Üks silt andmerea kohta', + scopeDesign: 'Ainult kujundus', + scopeCountFmt: '{label} ({n})', + systemRendering: 'Silti renderdatakse. Seejärel avaneb süsteemi printimisdialoog.', + systemActiveRowOnlyFmt: 'Prinditakse ainult andmerida {n} reast {rows}.', batchNoticeFmt: 'Prindib {n} silti (üks andmerea kohta).', batchNoticeQtyFmt: 'Prindib {n} silti ({rows} rida × {q} rea kohta).', - tabNetwork: 'Võrk (IP)', - tabBrowserPrint: 'Zebra Browser Print', - tabLocal: 'Kohalik printer', + tabNetwork: 'ZPL-printer (võrk)', + tabBrowserPrint: 'ZPL-printer (Browser Print)', + tabLocal: 'ZPL-printer (kohalik)', ipAddress: 'IP-aadress', port: 'Port', send: 'Saada ZPL', @@ -1861,7 +1874,16 @@ const et = { errorNoResponse: 'Printer ei vastanud, kontrolli IP-d ja võrku', errorGeneric: 'ZPL saatmine ebaõnnestus', httpsWarning: 'Leht kasutab HTTPS-i, otsene IP-printimistöö võib olla brauseri poolt blokeeritud (segasisaldus).', - tabUsb: 'USB', + tabUsb: 'ZPL-printer (USB)', + tabSystem: 'Muu printer', + wayHeading: 'Printer', + wayNetwork: 'Saadab TCP kaudu otse printerisse, draiverit ja järjekorda vahele jättes. Printer vastab, seetõttu töötavad olek ja eelvaade.', + wayNetworkWeb: 'Saadab koodi printeri porti. Brauser ei saa vastust, nii et midagi tagasi ei loeta.', + wayBrowserPrint: 'Käib Zebra Browser Printi kaudu arvutis, kus brauser töötab.', + wayLocal: 'Saadab operatsioonisüsteemi prindijärjekorra kaudu, mis võib osutada samale printerile. Sel teel printer ei vasta.', + wayUsb: 'Saadab otse ühendatud seadmesse ja loeb vastust.', + waySystem: 'Joonistab sildi ja annab selle operatsioonisüsteemi prindidialoogile, iga draiveriga printeri jaoks. Prinditakse ainult aktiivne andmerida.', + wayQueryAddress: 'Kontroll läheb alati printeri aadressile.', usbPermissionDenied: 'USB-printerile puudub juurdepääs. Klõpsa Seadista juurdepääs, et see anda.', usbSetupAccess: 'Seadista juurdepääs', usbSetupFlatpak: 'Flatpaki sees ei saa rakendus ise udev-reeglit paigaldada. Paigalda 70-zplab.rules ZPLab-i hoidlast hostsüsteemi ja ühenda printer uuesti.', diff --git a/src/locales/fa.ts b/src/locales/fa.ts index 1d1d5087..90f9b43c 100644 --- a/src/locales/fa.ts +++ b/src/locales/fa.ts @@ -988,11 +988,10 @@ const fa = { exportZpl: 'خروجی ZPL', menuInitError: 'منوی برنامه بارگذاری نشد. لطفاً برنامه را دوباره اجرا کنید.', importCsvData: 'وارد کردن داده‌های CSV', - sendToZebraBatchFmt: 'ارسال به Zebra ({n} برچسب)', exportBatchZplFmt: 'خروجی ZPL دسته‌ای ({n} برچسب)', exportPdf: 'خروجی PDF', - exportBatchPdfFmt: 'خروجی PDF دسته‌ای ({n} برچسب)', exportPdfCurrentPage: 'خروجی PDF (صفحه فعلی)', + exportPng: 'خروجی PNG', newDesign: 'طرح جدید', addPage: 'افزودن صفحه', cancel: 'لغو', @@ -1011,8 +1010,6 @@ const fa = { deletePageConfirm: 'صفحه فعلی حذف شود؟', openDesign: 'باز کردن طرح', saveDesign: 'ذخیره طرح', - print: 'چاپ به عنوان تصویر (مرورگر)', - sendToZebra: 'ارسال به چاپگر Zebra', propertiesTab: 'ویژگی‌ها', layersTab: 'لایه‌ها', fontsTab: 'فونت‌ها', @@ -1839,11 +1836,27 @@ const fa = { }, zebraPrint: { heading: 'ارسال به چاپگر Zebra', + outputHeading: 'خروجی', + kindPrint: 'چاپ', + kindPrintHint: 'برچسب را به یک چاپگر می‌فرستد.', + kindFile: 'فایل', + kindFileHint: 'برچسب را روی این کامپیوتر ذخیره می‌کند.', + formatHeading: 'قالب', + formatPdf: 'PDF', + formatPdfHint: 'برچسب را به صورت PDF ذخیره می‌کند.', + formatPng: 'PNG', + formatPngHint: 'برچسب را به صورت تصویر ذخیره می‌کند. فقط ردیف داده فعال.', + scopeHeading: 'محدوده', + scopeBatch: 'یک برچسب برای هر ردیف داده', + scopeDesign: 'فقط طرح', + scopeCountFmt: '{label} ({n})', + systemRendering: 'برچسب در حال آماده‌سازی است. سپس کادر چاپ سیستم باز می‌شود.', + systemActiveRowOnlyFmt: 'فقط ردیف داده {n} از {rows} چاپ می‌شود.', batchNoticeFmt: '{n} برچسب چاپ می‌کند (یکی برای هر ردیف داده).', batchNoticeQtyFmt: '{n} برچسب چاپ می‌کند ({rows} ردیف × {q} برای هر ردیف).', - tabNetwork: 'شبکه (IP)', - tabBrowserPrint: 'Zebra Browser Print', - tabLocal: 'چاپگر محلی', + tabNetwork: 'چاپگر ZPL (شبکه)', + tabBrowserPrint: 'چاپگر ZPL (Browser Print)', + tabLocal: 'چاپگر ZPL (محلی)', ipAddress: 'آدرس IP', port: 'پورت', send: 'ارسال ZPL', @@ -1861,7 +1874,16 @@ const fa = { errorNoResponse: 'چاپگر پاسخ نداد، IP و شبکه را بررسی کنید', errorGeneric: 'ارسال ZPL ناموفق بود', httpsWarning: 'صفحه روی HTTPS است، چاپ مستقیم از طریق IP ممکن است توسط مرورگر مسدود شود (محتوای مختلط).', - tabUsb: 'USB', + tabUsb: 'چاپگر ZPL (USB)', + tabSystem: 'چاپگر دیگر', + wayHeading: 'چاپگر', + wayNetwork: 'با TCP مستقیم به چاپگر می‌فرستد، بدون درایور و صف چاپ. چاپگر پاسخ می‌دهد، بنابراین وضعیت و پیش‌نمایش کار می‌کنند.', + wayNetworkWeb: 'کد را به پورت چاپگر ارسال می‌کند. مرورگر پاسخی دریافت نمی‌کند، پس چیزی خوانده نمی‌شود.', + wayBrowserPrint: 'از طریق Zebra Browser Print روی کامپیوتری که مرورگر را اجرا می‌کند عبور می‌کند.', + wayLocal: 'از طریق صف چاپ سیستم‌عامل می‌فرستد که می‌تواند به همان چاپگر اشاره کند. در این مسیر چاپگر پاسخ نمی‌دهد.', + wayUsb: 'مستقیماً به دستگاه متصل ارسال می‌کند و پاسخ را می‌خواند.', + waySystem: 'برچسب را رسم می‌کند و به گفت‌وگوی چاپ سیستم‌عامل می‌دهد، برای هر چاپگری که راه‌انداز دارد. فقط ردیف داده فعال چاپ می‌شود.', + wayQueryAddress: 'بررسی همیشه به نشانی چاپگر می‌رود.', usbPermissionDenied: 'دسترسی به چاپگر USB وجود ندارد. برای اعطای دسترسی روی «تنظیم دسترسی» کلیک کنید.', usbSetupAccess: 'تنظیم دسترسی', usbSetupFlatpak: 'داخل Flatpak، برنامه نمی‌تواند قانون udev را خودش نصب کند. فایل 70-zplab.rules را از مخزن ZPLab روی میزبان نصب کنید، سپس چاپگر را دوباره وصل کنید.', diff --git a/src/locales/fi.ts b/src/locales/fi.ts index ce0806f5..47ddad41 100644 --- a/src/locales/fi.ts +++ b/src/locales/fi.ts @@ -988,11 +988,10 @@ const fi = { exportZpl: 'Export ZPL', menuInitError: 'Sovelluksen valikkoa ei voitu ladata. Käynnistä sovellus uudelleen.', importCsvData: 'Tuo CSV-tiedot', - sendToZebraBatchFmt: 'Lähetä Zebralle ({n} etikettiä)', exportBatchZplFmt: 'Vie erä-ZPL ({n} etikettiä)', exportPdf: 'Vie PDF', - exportBatchPdfFmt: 'Vie erä-PDF ({n} etikettiä)', exportPdfCurrentPage: 'Vie PDF (nykyinen sivu)', + exportPng: 'Vie PNG', newDesign: 'Uusi rakenne', addPage: 'Lisää sivu', cancel: 'Peruuta', @@ -1011,8 +1010,6 @@ const fi = { deletePageConfirm: 'Poistetaanko nykyinen sivu?', openDesign: 'Avaa rakenne', saveDesign: 'Tallenna rakenne', - print: 'Tulosta kuvana (selain)', - sendToZebra: 'Lähetä Zebra-tulostimelle', propertiesTab: 'Ominaisuudet', layersTab: 'Tasot', fontsTab: 'Fontit', @@ -1839,11 +1836,27 @@ const fi = { }, zebraPrint: { heading: 'Lähetä Zebra-tulostimelle', + outputHeading: 'Tuloste', + kindPrint: 'Tulosta', + kindPrintHint: 'Lähettää etiketin tulostimeen.', + kindFile: 'Tiedosto', + kindFileHint: 'Tallentaa etiketin tähän tietokoneeseen.', + formatHeading: 'Muoto', + formatPdf: 'PDF', + formatPdfHint: 'Tallentaa etiketin PDF-tiedostona.', + formatPng: 'PNG', + formatPngHint: 'Tallentaa etiketin kuvana. Vain aktiivinen tietorivi.', + scopeHeading: 'Laajuus', + scopeBatch: 'Yksi etiketti tietoriviä kohden', + scopeDesign: 'Vain rakenne', + scopeCountFmt: '{label} ({n})', + systemRendering: 'Etikettiä hahmonnetaan. Sen jälkeen avautuu järjestelmän tulostusvalintaikkuna.', + systemActiveRowOnlyFmt: 'Vain tietorivi {n} riveistä {rows} tulostuu.', batchNoticeFmt: 'Tulostaa {n} etikettiä (yksi per datarivi).', batchNoticeQtyFmt: 'Tulostaa {n} etikettiä ({rows} riviä × {q} per rivi).', - tabNetwork: 'Verkko (IP)', - tabBrowserPrint: 'Zebra Browser Print', - tabLocal: 'Paikallinen tulostin', + tabNetwork: 'ZPL-tulostin (verkko)', + tabBrowserPrint: 'ZPL-tulostin (Browser Print)', + tabLocal: 'ZPL-tulostin (paikallinen)', ipAddress: 'IP-osoite', port: 'Portti', send: 'Lähetä ZPL', @@ -1861,7 +1874,16 @@ const fi = { errorNoResponse: 'Tulostin ei vastannut, tarkista IP ja verkko', errorGeneric: 'ZPL:n lähetys epäonnistui', httpsWarning: 'Sivu on HTTPS:llä, suora IP-tulostus saattaa olla selaimen estämä (sekasisältö).', - tabUsb: 'USB', + tabUsb: 'ZPL-tulostin (USB)', + tabSystem: 'Muu tulostin', + wayHeading: 'Tulostin', + wayNetwork: 'Lähettää TCP:n yli suoraan tulostimelle, ohi ajurin ja jonon. Tulostin vastaa, joten tila ja esikatselu toimivat.', + wayNetworkWeb: 'Lähettää koodin tulostimen porttiin. Selain ei saa vastausta, jolloin mitään ei lueta takaisin.', + wayBrowserPrint: 'Kulkee Zebra Browser Printin kautta tietokoneella, jolla selain on käynnissä.', + wayLocal: 'Lähettää käyttöjärjestelmän tulostusjonon kautta, joka voi osoittaa samaan tulostimeen. Tätä tietä tulostin ei vastaa.', + wayUsb: 'Lähettää suoraan liitettyyn laitteeseen ja lukee vastauksen.', + waySystem: 'Piirtää etiketin ja antaa sen käyttöjärjestelmän tulostusikkunalle, mille tahansa tulostimelle jolla on ajuri. Vain aktiivinen tietorivi tulostuu.', + wayQueryAddress: 'Tarkistus menee aina tulostimen osoitteeseen.', usbPermissionDenied: 'Ei käyttöoikeutta USB-tulostimeen. Napsauta Määritä käyttöoikeus myöntääksesi sen.', usbSetupAccess: 'Määritä käyttöoikeus', usbSetupFlatpak: 'Flatpakin sisällä sovellus ei voi asentaa udev-sääntöä itse. Asenna 70-zplab.rules ZPLab-tietovarastosta isäntäjärjestelmään ja kytke tulostin uudelleen.', diff --git a/src/locales/fr.ts b/src/locales/fr.ts index 72863880..a8f58e4a 100644 --- a/src/locales/fr.ts +++ b/src/locales/fr.ts @@ -988,11 +988,10 @@ 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', - sendToZebraBatchFmt: 'Envoyer à Zebra ({n} étiquettes)', exportBatchZplFmt: 'Exporter ZPL en lot ({n} étiquettes)', exportPdf: 'Exporter PDF', - exportBatchPdfFmt: 'Exporter PDF en lot ({n} étiquettes)', exportPdfCurrentPage: 'Exporter PDF (page actuelle)', + exportPng: 'Exporter PNG', newDesign: 'Nouveau design', addPage: 'Ajouter une page', cancel: 'Annuler', @@ -1011,8 +1010,6 @@ const fr = { deletePageConfirm: 'Supprimer la page actuelle ?', openDesign: 'Ouvrir le design', saveDesign: 'Enregistrer le design', - print: 'Imprimer en image (navigateur)', - sendToZebra: 'Envoyer à l’imprimante Zebra', propertiesTab: 'Propriétés', layersTab: 'Calques', fontsTab: 'Polices', @@ -1839,11 +1836,27 @@ const fr = { }, zebraPrint: { heading: 'Envoyer à l’imprimante Zebra', + outputHeading: 'Impression et export', + kindPrint: 'Imprimer', + kindPrintHint: "Envoie l'étiquette à une imprimante.", + kindFile: 'Fichier', + kindFileHint: "Enregistre l'étiquette sur cet ordinateur.", + formatHeading: 'Format', + formatPdf: 'PDF', + formatPdfHint: "Enregistre l'étiquette en PDF.", + formatPng: 'PNG', + formatPngHint: "Enregistre l'étiquette en image. Seule la ligne de données active.", + scopeHeading: 'Étendue', + scopeBatch: 'Une étiquette par ligne de données', + scopeDesign: 'Le design seul', + scopeCountFmt: '{label} ({n})', + systemRendering: "L'étiquette est en cours de rendu. Ensuite la boîte de dialogue d'impression du système s'ouvre.", + systemActiveRowOnlyFmt: 'Seule la ligne de données {n} sur {rows} est imprimée.', batchNoticeFmt: 'Imprime {n} étiquettes (une par ligne de données).', batchNoticeQtyFmt: 'Imprime {n} étiquettes ({rows} lignes × {q} chacune).', - tabNetwork: 'Réseau (IP)', - tabBrowserPrint: 'Zebra Browser Print', - tabLocal: 'Imprimante locale', + tabNetwork: 'Imprimante ZPL (réseau)', + tabBrowserPrint: 'Imprimante ZPL (Browser Print)', + tabLocal: 'Imprimante ZPL (locale)', ipAddress: 'Adresse IP', port: 'Port', send: 'Envoyer ZPL', @@ -1861,7 +1874,16 @@ const fr = { errorNoResponse: "L'imprimante n'a pas répondu, vérifiez l'IP et le réseau", errorGeneric: 'Échec de l’envoi ZPL', httpsWarning: 'La page est en HTTPS, l\'impression directe par IP peut être bloquée par le navigateur (contenu mixte).', - tabUsb: 'USB', + tabUsb: 'Imprimante ZPL (USB)', + tabSystem: 'Autre imprimante', + wayHeading: 'Imprimante', + wayNetwork: "Envoie en TCP directement à l'imprimante, sans pilote ni file d'attente. L'imprimante répond, donc l'état et l'aperçu fonctionnent.", + wayNetworkWeb: "Envoie le code au port de l'imprimante. Le navigateur ne reçoit aucune réponse, donc rien ne répond.", + wayBrowserPrint: "Passe par Zebra Browser Print sur l'ordinateur qui exécute le navigateur.", + wayLocal: "Envoie par la file d'attente d'impression du système d'exploitation, qui peut pointer vers la même imprimante. Par ce chemin l'imprimante ne répond pas.", + wayUsb: "Envoie directement à l'appareil connecté et reçoit une réponse.", + waySystem: "Dessine l'étiquette et la remet à la boîte d'impression du système d'exploitation, pour toute imprimante avec pilote. Seule la ligne de données active est imprimée.", + wayQueryAddress: "La vérification passe toujours par l'adresse de l'imprimante.", usbPermissionDenied: "Aucun accès à l'imprimante USB. Clique sur Configurer l'accès pour l'autoriser.", usbSetupAccess: "Configurer l'accès", usbSetupFlatpak: "À l'intérieur de Flatpak, l'application ne peut pas installer la règle udev elle-même. Installe 70-zplab.rules depuis le dépôt ZPLab sur l'hôte, puis rebranche l'imprimante.", diff --git a/src/locales/he.ts b/src/locales/he.ts index 7e111e46..1b06bf6a 100644 --- a/src/locales/he.ts +++ b/src/locales/he.ts @@ -988,11 +988,10 @@ const he = { exportZpl: 'ייצוא ZPL', menuInitError: 'לא ניתן היה לטעון את תפריט היישום. יש להפעיל מחדש את היישום.', importCsvData: 'ייבוא נתוני CSV', - sendToZebraBatchFmt: 'שלח ל-Zebra ({n} תוויות)', exportBatchZplFmt: 'ייצוא ZPL באצווה ({n} תוויות)', exportPdf: 'ייצוא PDF', - exportBatchPdfFmt: 'ייצוא PDF באצווה ({n} תוויות)', exportPdfCurrentPage: 'ייצוא PDF (העמוד הנוכחי)', + exportPng: 'ייצוא PNG', newDesign: 'עיצוב חדש', addPage: 'הוסף דף', cancel: 'ביטול', @@ -1011,8 +1010,6 @@ const he = { deletePageConfirm: 'למחוק את הדף הנוכחי?', openDesign: 'פתח עיצוב', saveDesign: 'שמור עיצוב', - print: 'הדפסה כתמונה (דפדפן)', - sendToZebra: 'שלח למדפסת Zebra', propertiesTab: 'מאפיינים', layersTab: 'שכבות', fontsTab: 'גופנים', @@ -1839,11 +1836,27 @@ const he = { }, zebraPrint: { heading: 'שלח למדפסת Zebra', + outputHeading: 'פלט', + kindPrint: 'הדפס', + kindPrintHint: 'שולח את התווית למדפסת.', + kindFile: 'קובץ', + kindFileHint: 'שומר את התווית במחשב הזה.', + formatHeading: 'פורמט', + formatPdf: 'PDF', + formatPdfHint: 'שומר את התווית כקובץ PDF.', + formatPng: 'PNG', + formatPngHint: 'שומר את התווית כתמונה. רק שורת הנתונים הפעילה.', + scopeHeading: 'היקף', + scopeBatch: 'תווית אחת לכל שורת נתונים', + scopeDesign: 'העיצוב בלבד', + scopeCountFmt: '{label} ({n})', + systemRendering: 'התווית מעובדת. לאחר מכן נפתח חלון ההדפסה של המערכת.', + systemActiveRowOnlyFmt: 'מודפסת רק שורת הנתונים {n} מתוך {rows}.', batchNoticeFmt: 'מדפיס {n} תוויות (אחת לכל שורת נתונים).', batchNoticeQtyFmt: 'מדפיס {n} תוויות ({rows} שורות × {q} לכל שורה).', - tabNetwork: 'רשת (IP)', - tabBrowserPrint: 'Zebra Browser Print', - tabLocal: 'מדפסת מקומית', + tabNetwork: 'מדפסת ZPL (רשת)', + tabBrowserPrint: 'מדפסת ZPL (Browser Print)', + tabLocal: 'מדפסת ZPL (מקומית)', ipAddress: 'כתובת IP', port: 'פורט', send: 'שלח ZPL', @@ -1861,7 +1874,16 @@ const he = { errorNoResponse: 'המדפסת לא הגיבה, בדוק את כתובת ה-IP והרשת', errorGeneric: 'שליחת ZPL נכשלה', httpsWarning: 'הדף נמצא על HTTPS, הדפסה ישירה דרך IP עלולה להיחסם על ידי הדפדפן (תוכן מעורב).', - tabUsb: 'USB', + tabUsb: 'מדפסת ZPL (USB)', + tabSystem: 'מדפסת אחרת', + wayHeading: 'מדפסת', + wayNetwork: 'שולח ב-TCP ישירות למדפסת, בלי מנהל התקן ותור הדפסה. המדפסת משיבה, ולכן המצב והתצוגה המקדימה עובדים.', + wayNetworkWeb: 'שולח את הקוד לפורט המדפסת. הדפדפן לא מקבל תגובה, כך ששום דבר לא נקרא בחזרה.', + wayBrowserPrint: 'עובר דרך Zebra Browser Print במחשב שמריץ את הדפדפן.', + wayLocal: 'שולח דרך תור ההדפסה של מערכת ההפעלה, שיכול להצביע על אותה מדפסת. בדרך הזו המדפסת לא משיבה.', + wayUsb: 'שולח ישירות למכשיר המחובר וקורא תגובה.', + waySystem: 'מצייר את התווית ומעביר אותה לדיאלוג ההדפסה של מערכת ההפעלה, לכל מדפסת עם מנהל התקן. מודפסת רק שורת הנתונים הפעילה.', + wayQueryAddress: 'הבדיקה הולכת תמיד לכתובת המדפסת.', usbPermissionDenied: "אין גישה למדפסת ה-USB. לחץ על 'הגדרת גישה' כדי להעניק אותה.", usbSetupAccess: 'הגדרת גישה', usbSetupFlatpak: 'בתוך Flatpak האפליקציה לא יכולה להתקין את כלל ה-udev בעצמה. התקן את 70-zplab.rules ממאגר ZPLab על המארח, ואז חבר את המדפסת מחדש.', diff --git a/src/locales/hr.ts b/src/locales/hr.ts index e93c6601..36e65720 100644 --- a/src/locales/hr.ts +++ b/src/locales/hr.ts @@ -988,11 +988,10 @@ const hr = { exportZpl: 'Export ZPL', menuInitError: 'Izbornik aplikacije nije se mogao učitati. Ponovno pokrenite aplikaciju.', importCsvData: 'Uvoz CSV podataka', - sendToZebraBatchFmt: 'Pošalji na Zebra ({n} etiketa)', exportBatchZplFmt: 'Izvoz skupnog ZPL-a ({n} etiketa)', exportPdf: 'Izvoz PDF-a', - exportBatchPdfFmt: 'Izvoz skupnog PDF-a ({n} etiketa)', exportPdfCurrentPage: 'Izvoz PDF-a (trenutna stranica)', + exportPng: 'Izvoz PNG-a', newDesign: 'Novi dizajn', addPage: 'Dodaj stranicu', cancel: 'Odustani', @@ -1011,8 +1010,6 @@ const hr = { deletePageConfirm: 'Izbrisati trenutnu stranicu?', openDesign: 'Otvori dizajn', saveDesign: 'Spremi dizajn', - print: 'Ispis kao slika (preglednik)', - sendToZebra: 'Pošalji na Zebra pisač', propertiesTab: 'Svojstva', layersTab: 'Slojevi', fontsTab: 'Fontovi', @@ -1839,11 +1836,27 @@ const hr = { }, zebraPrint: { heading: 'Pošalji na Zebra pisač', + outputHeading: 'Ispis i izvoz', + kindPrint: 'Ispiši', + kindPrintHint: 'Šalje etiketu pisaču.', + kindFile: 'Datoteka', + kindFileHint: 'Sprema etiketu na ovo računalo.', + formatHeading: 'Format', + formatPdf: 'PDF', + formatPdfHint: 'Sprema etiketu kao PDF.', + formatPng: 'PNG', + formatPngHint: 'Sprema etiketu kao sliku. Samo aktivni redak podataka.', + scopeHeading: 'Obuhvat', + scopeBatch: 'Jedna etiketa po redu podataka', + scopeDesign: 'Samo dizajn', + scopeCountFmt: '{label} ({n})', + systemRendering: 'Etiketa se iscrtava. Zatim se otvara dijalog za ispis sustava.', + systemActiveRowOnlyFmt: 'Ispisuje se samo redak podataka {n} od {rows}.', batchNoticeFmt: 'Ispisuje {n} etiketa (jednu po retku podataka).', batchNoticeQtyFmt: 'Ispisuje {n} etiketa ({rows} redaka × {q} po retku).', - tabNetwork: 'Mreža (IP)', - tabBrowserPrint: 'Zebra Browser Print', - tabLocal: 'Lokalni pisač', + tabNetwork: 'ZPL pisač (mreža)', + tabBrowserPrint: 'ZPL pisač (Browser Print)', + tabLocal: 'ZPL pisač (lokalni)', ipAddress: 'IP adresa', port: 'Port', send: 'Pošalji ZPL', @@ -1861,7 +1874,16 @@ const hr = { errorNoResponse: 'Pisač ne odgovara, provjerite IP i mrežu', errorGeneric: 'Slanje ZPL-a nije uspjelo', httpsWarning: 'Stranica koristi HTTPS, izravno ispisivanje putem IP-a može biti blokirano od preglednika (mješoviti sadržaj).', - tabUsb: 'USB', + tabUsb: 'ZPL pisač (USB)', + tabSystem: 'Drugi pisač', + wayHeading: 'Pisač', + wayNetwork: 'Šalje preko TCP-a izravno na pisač, bez upravljačkog programa i reda čekanja. Pisač odgovara, zato rade stanje i pretpregled.', + wayNetworkWeb: 'Šalje kod na port pisača. Preglednik ne prima odgovor, pa ništa ne čita natrag.', + wayBrowserPrint: 'Prolazi kroz Zebra Browser Print na računalu koje pokreće preglednik.', + wayLocal: 'Šalje preko reda čekanja za ispis operacijskog sustava, koji može pokazivati na isti pisač. Tim putem pisač ne odgovara.', + wayUsb: 'Šalje izravno na povezani uređaj i čita odgovor.', + waySystem: 'Crta etiketu i predaje je dijalogu za ispis operacijskog sustava, za svaki pisač s upravljačkim programom. Ispisuje se samo aktivni redak podataka.', + wayQueryAddress: 'Provjera uvijek ide na adresu pisača.', usbPermissionDenied: 'Nema pristupa USB pisaču. Kliknite Postavi pristup da biste ga odobrili.', usbSetupAccess: 'Postavi pristup', usbSetupFlatpak: 'Unutar Flatpaka aplikacija ne može sama instalirati udev pravilo. Instalirajte 70-zplab.rules iz ZPLab repozitorija na glavnom sustavu, zatim ponovno priključite pisač.', diff --git a/src/locales/hu.ts b/src/locales/hu.ts index 781c878f..d5df7460 100644 --- a/src/locales/hu.ts +++ b/src/locales/hu.ts @@ -988,11 +988,10 @@ 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', - sendToZebraBatchFmt: 'Küldés Zebrára ({n} címke)', exportBatchZplFmt: 'Köteg ZPL exportálása ({n} címke)', exportPdf: 'PDF exportálása', - exportBatchPdfFmt: 'Köteg PDF exportálása ({n} címke)', exportPdfCurrentPage: 'PDF exportálása (aktuális oldal)', + exportPng: 'PNG exportálása', newDesign: 'Új terv', addPage: 'Oldal hozzáadása', cancel: 'Mégse', @@ -1011,8 +1010,6 @@ const hu = { deletePageConfirm: 'Törli az aktuális oldalt?', openDesign: 'Terv megnyitása', saveDesign: 'Terv mentése', - print: 'Nyomtatás képként (böngésző)', - sendToZebra: 'Küldés Zebra nyomtatóra', propertiesTab: 'Tulajdonságok', layersTab: 'Rétegek', fontsTab: 'Betűtípusok', @@ -1839,11 +1836,27 @@ const hu = { }, zebraPrint: { heading: 'Küldés Zebra nyomtatóra', + outputHeading: 'Kimenet', + kindPrint: 'Nyomtatás', + kindPrintHint: 'Elküldi a címkét egy nyomtatóra.', + kindFile: 'Fájl', + kindFileHint: 'Elmenti a címkét erre a számítógépre.', + formatHeading: 'Formátum', + formatPdf: 'PDF', + formatPdfHint: 'Elmenti a címkét PDF-ként.', + formatPng: 'PNG', + formatPngHint: 'Elmenti a címkét képként. Csak az aktív adatsort.', + scopeHeading: 'Terjedelem', + scopeBatch: 'Egy címke adatsoronként', + scopeDesign: 'Csak a terv', + scopeCountFmt: '{label} ({n})', + systemRendering: 'A címke megjelenítése folyamatban van. Ezután megnyílik a rendszer nyomtatási párbeszédpanele.', + systemActiveRowOnlyFmt: 'Csak a {n}. adatsor nyomtatódik a {rows} sorból.', batchNoticeFmt: '{n} címkét nyomtat (adatsoronként egyet).', batchNoticeQtyFmt: '{n} címkét nyomtat ({rows} sor × soronként {q}).', - tabNetwork: 'Hálózat (IP)', - tabBrowserPrint: 'Zebra Browser Print', - tabLocal: 'Helyi nyomtató', + tabNetwork: 'ZPL-nyomtató (hálózat)', + tabBrowserPrint: 'ZPL-nyomtató (Browser Print)', + tabLocal: 'ZPL-nyomtató (helyi)', ipAddress: 'IP-cím', port: 'Port', send: 'ZPL küldése', @@ -1861,7 +1874,16 @@ const hu = { errorNoResponse: 'A nyomtató nem válaszolt, ellenőrizze az IP-címet és a hálózatot', errorGeneric: 'ZPL küldése sikertelen', httpsWarning: 'Az oldal HTTPS-en fut, az IP-n keresztüli közvetlen nyomtatást a böngésző blokkolhatja (vegyes tartalom).', - tabUsb: 'USB', + tabUsb: 'ZPL-nyomtató (USB)', + tabSystem: 'Másik nyomtató', + wayHeading: 'Nyomtató', + wayNetwork: 'TCP-n közvetlenül a nyomtatóra küld, illesztőprogram és nyomtatási sor nélkül. A nyomtató válaszol, ezért működik az állapot és az előnézet.', + wayNetworkWeb: 'Elküldi a kódot a nyomtató portjára. A böngésző nem kap választ, így semmi nem olvas vissza.', + wayBrowserPrint: 'A böngészőt futtató számítógépen keresztül a Zebra Browser Printen megy át.', + wayLocal: 'Az operációs rendszer nyomtatási sorába küld, amely ugyanarra a nyomtatóra mutathat. Ezen az úton a nyomtató nem válaszol.', + wayUsb: 'Egyenesen a csatlakoztatott eszközre küldi, és választ olvas.', + waySystem: 'Megrajzolja a címkét és átadja az operációs rendszer nyomtatási párbeszédének, bármely illesztőprogrammal rendelkező nyomtatóhoz. Csak az aktív adatsort nyomtatja ki.', + wayQueryAddress: 'Az ellenőrzés mindig a nyomtató címére megy.', usbPermissionDenied: 'Nincs hozzáférés az USB-nyomtatóhoz. Kattintson a Hozzáférés beállítása gombra az engedélyezéshez.', usbSetupAccess: 'Hozzáférés beállítása', usbSetupFlatpak: 'A Flatpakon belül az alkalmazás nem tudja magától telepíteni az udev szabályt. Telepítse a 70-zplab.rules fájlt a ZPLab tárolóból a gazdagépre, majd csatlakoztassa újra a nyomtatót.', diff --git a/src/locales/it.ts b/src/locales/it.ts index 7db1e115..3c793dd8 100644 --- a/src/locales/it.ts +++ b/src/locales/it.ts @@ -988,11 +988,10 @@ const it = { exportZpl: 'Export ZPL', menuInitError: "Impossibile caricare il menu dell'applicazione. Riavvia l'app.", importCsvData: 'Importa dati CSV', - sendToZebraBatchFmt: 'Invia a Zebra ({n} etichette)', exportBatchZplFmt: 'Esporta ZPL in batch ({n} etichette)', exportPdf: 'Esporta PDF', - exportBatchPdfFmt: 'Esporta PDF in batch ({n} etichette)', exportPdfCurrentPage: 'Esporta PDF (pagina corrente)', + exportPng: 'Esporta PNG', newDesign: 'Nuovo design', addPage: 'Aggiungi pagina', cancel: 'Annulla', @@ -1011,8 +1010,6 @@ const it = { deletePageConfirm: 'Eliminare la pagina corrente?', openDesign: 'Apri design', saveDesign: 'Salva design', - print: 'Stampa come immagine (browser)', - sendToZebra: 'Invia a stampante Zebra', propertiesTab: 'Proprietà', layersTab: 'Livelli', fontsTab: 'Caratteri', @@ -1839,11 +1836,27 @@ const it = { }, zebraPrint: { heading: 'Invia a stampante Zebra', + outputHeading: 'Stampa ed esportazione', + kindPrint: 'Stampa', + kindPrintHint: "Invia l'etichetta a una stampante.", + kindFile: 'File', + kindFileHint: "Salva l'etichetta su questo computer.", + formatHeading: 'Formato', + formatPdf: 'PDF', + formatPdfHint: "Salva l'etichetta come PDF.", + formatPng: 'PNG', + formatPngHint: "Salva l'etichetta come immagine. Solo la riga di dati attiva.", + scopeHeading: 'Ambito', + scopeBatch: "Un'etichetta per riga di dati", + scopeDesign: 'Solo il design', + scopeCountFmt: '{label} ({n})', + systemRendering: "L'etichetta è in fase di rendering. Poi si apre la finestra di stampa del sistema.", + systemActiveRowOnlyFmt: 'Viene stampata solo la riga dati {n} di {rows}.', batchNoticeFmt: 'Stampa {n} etichette (una per riga di dati).', batchNoticeQtyFmt: 'Stampa {n} etichette ({rows} righe × {q} ciascuna).', - tabNetwork: 'Rete (IP)', - tabBrowserPrint: 'Zebra Browser Print', - tabLocal: 'Stampante locale', + tabNetwork: 'Stampante ZPL (rete)', + tabBrowserPrint: 'Stampante ZPL (Browser Print)', + tabLocal: 'Stampante ZPL (locale)', ipAddress: 'Indirizzo IP', port: 'Porta', send: 'Invia ZPL', @@ -1861,7 +1874,16 @@ const it = { errorNoResponse: 'La stampante non ha risposto, controlla IP e rete', errorGeneric: 'Invio ZPL non riuscito', httpsWarning: 'La pagina è in HTTPS, la stampa diretta tramite IP potrebbe essere bloccata dal browser (contenuto misto).', - tabUsb: 'USB', + tabUsb: 'Stampante ZPL (USB)', + tabSystem: 'Altra stampante', + wayHeading: 'Stampante', + wayNetwork: 'Invia via TCP direttamente alla stampante, senza driver né coda. La stampante risponde, quindi stato e anteprima funzionano.', + wayNetworkWeb: 'Invia il codice alla porta della stampante. Il browser non riceve risposta, quindi nulla risponde.', + wayBrowserPrint: 'Passa da Zebra Browser Print sul computer che esegue il browser.', + wayLocal: 'Invia tramite la coda di stampa del sistema operativo, che può puntare alla stessa stampante. Su questa via la stampante non risponde.', + wayUsb: 'Invia direttamente al dispositivo collegato e riceve risposta.', + waySystem: "Disegna l'etichetta e la passa alla finestra di stampa del sistema operativo, per qualsiasi stampante con driver. Viene stampata solo la riga di dati attiva.", + wayQueryAddress: "Il controllo va sempre all'indirizzo della stampante.", usbPermissionDenied: 'Nessun accesso alla stampante USB. Fai clic su Configura accesso per concederlo.', usbSetupAccess: 'Configura accesso', usbSetupFlatpak: "All'interno di Flatpak l'app non può installare la regola udev da sola. Installa 70-zplab.rules dal repository ZPLab sull'host, poi ricollega la stampante.", diff --git a/src/locales/ja.ts b/src/locales/ja.ts index 19632c85..1e83fb8a 100644 --- a/src/locales/ja.ts +++ b/src/locales/ja.ts @@ -988,11 +988,10 @@ const ja = { exportZpl: 'ZPL エクスポート', menuInitError: 'アプリケーションメニューを読み込めませんでした。アプリを再起動してください。', importCsvData: 'CSVデータをインポート', - sendToZebraBatchFmt: 'Zebra へ送信 ({n} ラベル)', exportBatchZplFmt: 'バッチZPLをエクスポート ({n} ラベル)', exportPdf: 'PDF エクスポート', - exportBatchPdfFmt: 'バッチPDFをエクスポート ({n} ラベル)', exportPdfCurrentPage: 'PDF エクスポート (現在のページ)', + exportPng: 'PNG エクスポート', newDesign: '新しいデザイン', addPage: 'ページを追加', cancel: 'キャンセル', @@ -1011,8 +1010,6 @@ const ja = { deletePageConfirm: '現在のページを削除しますか?', openDesign: 'デザインを開く', saveDesign: 'デザインを保存', - print: '画像として印刷(ブラウザ)', - sendToZebra: 'Zebra プリンターへ送信', propertiesTab: 'プロパティ', layersTab: 'レイヤー', fontsTab: 'フォント', @@ -1839,11 +1836,27 @@ const ja = { }, zebraPrint: { heading: 'Zebra プリンターへ送信', + outputHeading: '出力', + kindPrint: '印刷', + kindPrintHint: 'ラベルをプリンターへ送ります。', + kindFile: 'ファイル', + kindFileHint: 'ラベルをこのコンピューターに保存します。', + formatHeading: 'フォーマット', + formatPdf: 'PDF', + formatPdfHint: 'ラベルを PDF として保存します。', + formatPng: 'PNG', + formatPngHint: 'ラベルを画像として保存します。アクティブなデータ行だけです。', + scopeHeading: '範囲', + scopeBatch: 'データ行ごとに 1 枚のラベル', + scopeDesign: 'デザインのみ', + scopeCountFmt: '{label}({n})', + systemRendering: 'ラベルを生成しています。そのあとシステムの印刷ダイアログが開きます。', + systemActiveRowOnlyFmt: '{rows} 行のうちデータ行 {n} だけが印刷されます。', batchNoticeFmt: '{n} 件のラベルを印刷します(データ行ごとに1枚)。', batchNoticeQtyFmt: '{n} 件のラベルを印刷します({rows} 行 × 各 {q} 枚)。', - tabNetwork: 'ネットワーク (IP)', - tabBrowserPrint: 'Zebra Browser Print', - tabLocal: 'ローカルプリンター', + tabNetwork: 'ZPLプリンター (ネットワーク)', + tabBrowserPrint: 'ZPLプリンター (Browser Print)', + tabLocal: 'ZPLプリンター (ローカル)', ipAddress: 'IP アドレス', port: 'ポート', send: 'ZPL を送信', @@ -1861,7 +1874,16 @@ const ja = { errorNoResponse: 'プリンターが応答しませんでした。IP とネットワークを確認してください', errorGeneric: 'ZPL の送信に失敗しました', httpsWarning: 'ページが HTTPS 上にあります、ブラウザが直接 IP 印刷をブロックする場合があります(混在コンテンツ)。', - tabUsb: 'USB', + tabUsb: 'ZPLプリンター (USB)', + tabSystem: 'その他のプリンター', + wayHeading: 'プリンター', + wayNetwork: 'ドライバーやキューを通さず、TCP でプリンターに直接送ります。プリンターが応答するので、状態表示とプレビューが使えます。', + wayNetworkWeb: 'コードをプリンターのポートに送信します。ブラウザーは応答を受け取らないため、何も読み取りません。', + wayBrowserPrint: 'ブラウザを実行しているコンピューター上のZebra Browser Printを経由します。', + wayLocal: '同じプリンターを指すこともできる OS の印刷キュー経由で送ります。この経路ではプリンターは応答しません。', + wayUsb: '接続された機器に直接送信し、応答を読み取ります。', + waySystem: 'ラベルを描画し、OS の印刷ダイアログへ渡します。ドライバーのあるどのプリンターでも使えます。アクティブなデータ行だけが印刷されます。', + wayQueryAddress: '確認は常にプリンターのアドレスに送られます。', usbPermissionDenied: 'USBプリンターにアクセスできません。「アクセスを設定」をクリックして許可してください。', usbSetupAccess: 'アクセスを設定', usbSetupFlatpak: 'Flatpak内ではアプリが自分でudevルールをインストールできません。ホスト側でZPLabリポジトリから70-zplab.rulesをインストールし、プリンターを再接続してください。', diff --git a/src/locales/ko.ts b/src/locales/ko.ts index ea401938..ffc4ba0d 100644 --- a/src/locales/ko.ts +++ b/src/locales/ko.ts @@ -988,11 +988,10 @@ const ko = { exportZpl: 'ZPL 내보내기', menuInitError: '애플리케이션 메뉴를 불러올 수 없습니다. 앱을 다시 시작하세요.', importCsvData: 'CSV 데이터 가져오기', - sendToZebraBatchFmt: 'Zebra로 전송 ({n}개 라벨)', exportBatchZplFmt: '일괄 ZPL 내보내기 ({n}개 라벨)', exportPdf: 'PDF 내보내기', - exportBatchPdfFmt: '일괄 PDF 내보내기 ({n}개 라벨)', exportPdfCurrentPage: 'PDF 내보내기 (현재 페이지)', + exportPng: 'PNG 내보내기', newDesign: '새 디자인', addPage: '페이지 추가', cancel: '취소', @@ -1011,8 +1010,6 @@ const ko = { deletePageConfirm: '현재 페이지를 삭제하시겠습니까?', openDesign: '디자인 열기', saveDesign: '디자인 저장', - print: '이미지로 인쇄 (브라우저)', - sendToZebra: 'Zebra 프린터로 전송', propertiesTab: '속성', layersTab: '레이어', fontsTab: '글꼴', @@ -1839,11 +1836,27 @@ const ko = { }, zebraPrint: { heading: 'Zebra 프린터로 전송', + outputHeading: '출력', + kindPrint: '인쇄', + kindPrintHint: '레이블을 프린터로 전송합니다.', + kindFile: '파일', + kindFileHint: '레이블을 이 컴퓨터에 저장합니다.', + formatHeading: '형식', + formatPdf: 'PDF', + formatPdfHint: '레이블을 PDF로 저장합니다.', + formatPng: 'PNG', + formatPngHint: '레이블을 이미지로 저장합니다. 활성 데이터 행만 저장합니다.', + scopeHeading: '범위', + scopeBatch: '데이터 행마다 레이블 한 장', + scopeDesign: '디자인만', + scopeCountFmt: '{label} ({n})', + systemRendering: '레이블을 생성하고 있습니다. 이어서 시스템 인쇄 대화 상자가 열립니다.', + systemActiveRowOnlyFmt: '데이터 행 {rows}개 중 {n}번째 행만 인쇄됩니다.', batchNoticeFmt: '{n}개 라벨을 인쇄합니다 (데이터 행마다 1개).', batchNoticeQtyFmt: '{n}개 라벨을 인쇄합니다 ({rows}개 행 × 행당 {q}개).', - tabNetwork: '네트워크 (IP)', - tabBrowserPrint: 'Zebra Browser Print', - tabLocal: '로컬 프린터', + tabNetwork: 'ZPL 프린터 (네트워크)', + tabBrowserPrint: 'ZPL 프린터 (Browser Print)', + tabLocal: 'ZPL 프린터 (로컬)', ipAddress: 'IP 주소', port: '포트', send: 'ZPL 전송', @@ -1861,7 +1874,16 @@ const ko = { errorNoResponse: '프린터가 응답하지 않습니다. IP와 네트워크를 확인하세요', errorGeneric: 'ZPL 전송에 실패했습니다', httpsWarning: '페이지가 HTTPS 상에 있습니다, 브라우저가 직접 IP 인쇄를 차단할 수 있습니다(혼합 콘텐츠).', - tabUsb: 'USB', + tabUsb: 'ZPL 프린터 (USB)', + tabSystem: '다른 프린터', + wayHeading: '프린터', + wayNetwork: '드라이버와 대기열을 거치지 않고 TCP로 프린터에 직접 보냅니다. 프린터가 응답하므로 상태와 미리 보기가 작동합니다.', + wayNetworkWeb: '코드를 프린터 포트로 전송합니다. 브라우저는 응답을 받지 못하므로 아무것도 읽어오지 않습니다.', + wayBrowserPrint: '브라우저를 실행하는 컴퓨터의 Zebra Browser Print를 거칩니다.', + wayLocal: '같은 프린터를 가리킬 수도 있는 운영체제의 인쇄 대기열을 거쳐 보냅니다. 이 경로에서는 프린터가 응답하지 않습니다.', + wayUsb: '연결된 장치로 바로 전송하고 응답을 읽습니다.', + waySystem: '레이블을 그려 운영체제의 인쇄 대화상자로 넘깁니다. 드라이버가 있는 모든 프린터에서 쓸 수 있습니다. 활성 데이터 행만 인쇄됩니다.', + wayQueryAddress: '확인은 항상 프린터 주소로 갑니다.', usbPermissionDenied: "USB 프린터에 액세스할 수 없습니다. '액세스 설정'을 클릭하여 권한을 부여하세요.", usbSetupAccess: '액세스 설정', usbSetupFlatpak: 'Flatpak 내부에서는 앱이 직접 udev 규칙을 설치할 수 없습니다. 호스트에서 ZPLab 저장소의 70-zplab.rules를 설치한 뒤 프린터를 다시 연결하세요.', diff --git a/src/locales/lt.ts b/src/locales/lt.ts index 1649a8e4..af8d5c09 100644 --- a/src/locales/lt.ts +++ b/src/locales/lt.ts @@ -988,11 +988,10 @@ const lt = { exportZpl: 'Export ZPL', menuInitError: 'Nepavyko įkelti programos meniu. Iš naujo paleiskite programą.', importCsvData: 'Importuoti CSV duomenis', - sendToZebraBatchFmt: 'Siųsti į Zebra ({n} etikečių)', exportBatchZplFmt: 'Eksportuoti paketinį ZPL ({n} etikečių)', exportPdf: 'Eksportuoti PDF', - exportBatchPdfFmt: 'Eksportuoti paketinį PDF ({n} etikečių)', exportPdfCurrentPage: 'Eksportuoti PDF (dabartinis puslapis)', + exportPng: 'Eksportuoti PNG', newDesign: 'Naujas dizainas', addPage: 'Pridėti puslapį', cancel: 'Atšaukti', @@ -1011,8 +1010,6 @@ const lt = { deletePageConfirm: 'Ištrinti dabartinį puslapį?', openDesign: 'Atidaryti dizainą', saveDesign: 'Išsaugoti dizainą', - print: 'Spausdinti kaip vaizdą (naršyklė)', - sendToZebra: 'Siųsti į Zebra spausdintuvą', propertiesTab: 'Savybės', layersTab: 'Sluoksniai', fontsTab: 'Šriftai', @@ -1839,11 +1836,27 @@ const lt = { }, zebraPrint: { heading: 'Siųsti į Zebra spausdintuvą', + outputHeading: 'Išvestis', + kindPrint: 'Spausdinti', + kindPrintHint: 'Siunčia etiketę į spausdintuvą.', + kindFile: 'Failas', + kindFileHint: 'Išsaugo etiketę šiame kompiuteryje.', + formatHeading: 'Formatas', + formatPdf: 'PDF', + formatPdfHint: 'Išsaugo etiketę kaip PDF.', + formatPng: 'PNG', + formatPngHint: 'Išsaugo etiketę kaip vaizdą. Tik aktyvi duomenų eilutė.', + scopeHeading: 'Apimtis', + scopeBatch: 'Viena etiketė kiekvienai duomenų eilutei', + scopeDesign: 'Tik dizainas', + scopeCountFmt: '{label} ({n})', + systemRendering: 'Etiketė generuojama. Paskui atsidaro sistemos spausdinimo dialogo langas.', + systemActiveRowOnlyFmt: 'Spausdinama tik {n} duomenų eilutė iš {rows}.', batchNoticeFmt: 'Spausdina {n} etikečių (po vieną kiekvienai duomenų eilutei).', batchNoticeQtyFmt: 'Spausdina {n} etikečių ({rows} eilutės × {q} kiekvienai).', - tabNetwork: 'Tinklas (IP)', - tabBrowserPrint: 'Zebra Browser Print', - tabLocal: 'Vietinis spausdintuvas', + tabNetwork: 'ZPL spausdintuvas (tinklas)', + tabBrowserPrint: 'ZPL spausdintuvas (Browser Print)', + tabLocal: 'ZPL spausdintuvas (vietinis)', ipAddress: 'IP adresas', port: 'Prievadas', send: 'Siųsti ZPL', @@ -1861,7 +1874,16 @@ const lt = { errorNoResponse: 'Spausdintuvas neatsakė, patikrinkite IP ir tinklą', errorGeneric: 'ZPL siuntimas nepavyko', httpsWarning: 'Puslapis naudoja HTTPS, tiesioginis spausdinimas per IP gali būti užblokuotas naršyklės (mišrus turinys).', - tabUsb: 'USB', + tabUsb: 'ZPL spausdintuvas (USB)', + tabSystem: 'Kitas spausdintuvas', + wayHeading: 'Spausdintuvas', + wayNetwork: 'Siunčia per TCP tiesiai į spausdintuvą, aplenkdama tvarkyklę ir eilę. Spausdintuvas atsako, todėl veikia būsena ir peržiūra.', + wayNetworkWeb: 'Siunčia kodą į spausdintuvo prievadą. Naršyklė nesulaukia atsakymo, todėl atgal nieko nenuskaitoma.', + wayBrowserPrint: 'Eina per Zebra Browser Print kompiuteryje, kuriame veikia naršyklė.', + wayLocal: 'Siunčia per operacinės sistemos spausdinimo eilę, kuri gali nurodyti tą patį spausdintuvą. Šiuo keliu spausdintuvas neatsako.', + wayUsb: 'Siunčia tiesiai į prijungtą įrenginį ir nuskaito atsakymą.', + waySystem: 'Nupiešia etiketę ir perduoda ją operacinės sistemos spausdinimo dialogui, bet kuriam spausdintuvui su tvarkykle. Spausdinama tik aktyvi duomenų eilutė.', + wayQueryAddress: 'Patikra visada eina į spausdintuvo adresą.', usbPermissionDenied: 'Nėra prieigos prie USB spausdintuvo. Spustelėkite Nustatyti prieigą, kad ją suteiktumėte.', usbSetupAccess: 'Nustatyti prieigą', usbSetupFlatpak: 'Flatpak aplinkoje programa negali pati įdiegti udev taisyklės. Įdiekite 70-zplab.rules iš ZPLab saugyklos pagrindinėje sistemoje, tada iš naujo prijunkite spausdintuvą.', diff --git a/src/locales/lv.ts b/src/locales/lv.ts index e1f85453..1ec18800 100644 --- a/src/locales/lv.ts +++ b/src/locales/lv.ts @@ -988,11 +988,10 @@ const lv = { exportZpl: 'Export ZPL', menuInitError: 'Neizdevās ielādēt lietotnes izvēlni. Lūdzu, restartējiet lietotni.', importCsvData: 'Importēt CSV datus', - sendToZebraBatchFmt: 'Sūtīt uz Zebra ({n} etiķetes)', exportBatchZplFmt: 'Eksportēt pakešu ZPL ({n} etiķetes)', exportPdf: 'Eksportēt PDF', - exportBatchPdfFmt: 'Eksportēt pakešu PDF ({n} etiķetes)', exportPdfCurrentPage: 'Eksportēt PDF (pašreizējā lapa)', + exportPng: 'Eksportēt PNG', newDesign: 'Jauns dizains', addPage: 'Pievienot lapu', cancel: 'Atcelt', @@ -1011,8 +1010,6 @@ const lv = { deletePageConfirm: 'Dzēst pašreizējo lapu?', openDesign: 'Atvērt dizainu', saveDesign: 'Saglabāt dizainu', - print: 'Drukāt kā attēlu (pārlūks)', - sendToZebra: 'Sūtīt uz Zebra printeri', propertiesTab: 'Īpašības', layersTab: 'Slāņi', fontsTab: 'Fonti', @@ -1839,11 +1836,27 @@ const lv = { }, zebraPrint: { heading: 'Sūtīt uz Zebra printeri', + outputHeading: 'Izvade', + kindPrint: 'Drukāt', + kindPrintHint: 'Nosūta etiķeti uz printeri.', + kindFile: 'Fails', + kindFileHint: 'Saglabā etiķeti šajā datorā.', + formatHeading: 'Formāts', + formatPdf: 'PDF', + formatPdfHint: 'Saglabā etiķeti kā PDF.', + formatPng: 'PNG', + formatPngHint: 'Saglabā etiķeti kā attēlu. Tikai aktīvā datu rinda.', + scopeHeading: 'Apjoms', + scopeBatch: 'Viena etiķete katrai datu rindai', + scopeDesign: 'Tikai dizains', + scopeCountFmt: '{label} ({n})', + systemRendering: 'Etiķete tiek ģenerēta. Pēc tam atveras sistēmas drukāšanas dialoglodziņš.', + systemActiveRowOnlyFmt: 'Tiek izdrukāta tikai {n}. datu rinda no {rows}.', batchNoticeFmt: 'Izdrukā {n} etiķetes (pa vienai katrai datu rindai).', batchNoticeQtyFmt: 'Izdrukā {n} etiķetes ({rows} rindas × {q} katrā rindā).', - tabNetwork: 'Tīkls (IP)', - tabBrowserPrint: 'Zebra Browser Print', - tabLocal: 'Vietējais printeris', + tabNetwork: 'ZPL printeris (tīkls)', + tabBrowserPrint: 'ZPL printeris (Browser Print)', + tabLocal: 'ZPL printeris (vietējais)', ipAddress: 'IP adrese', port: 'Ports', send: 'Sūtīt ZPL', @@ -1861,7 +1874,16 @@ const lv = { errorNoResponse: 'Printeris neatbildēja, pārbaudiet IP un tīklu', errorGeneric: 'ZPL nosūtīšana neizdevās', httpsWarning: 'Lapa izmanto HTTPS, tiešā IP drukāšana var tikt bloķēta pārlūkprogrammā (jaukts saturs).', - tabUsb: 'USB', + tabUsb: 'ZPL printeris (USB)', + tabSystem: 'Cits printeris', + wayHeading: 'Printeris', + wayNetwork: 'Sūta pa TCP tieši uz printeri, apejot draiveri un rindu. Printeris atbild, tāpēc darbojas statuss un priekšskatījums.', + wayNetworkWeb: 'Nosūta kodu uz printera portu. Pārlūkprogramma nesaņem atbildi, tāpēc nekas netiek nolasīts atpakaļ.', + wayBrowserPrint: 'Notiek caur Zebra Browser Print datorā, kurā darbojas pārlūkprogramma.', + wayLocal: 'Sūta caur operētājsistēmas drukas rindu, kas var norādīt uz to pašu printeri. Šajā ceļā printeris neatbild.', + wayUsb: 'Nosūta tieši uz pievienoto ierīci un nolasa atbildi.', + waySystem: 'Uzzīmē etiķeti un nodod to operētājsistēmas drukas dialogam, jebkuram printerim ar draiveri. Tiek izdrukāta tikai aktīvā datu rinda.', + wayQueryAddress: 'Pārbaude vienmēr dodas uz printera adresi.', usbPermissionDenied: 'Nav piekļuves USB printerim. Noklikšķiniet uz Iestatīt piekļuvi, lai to piešķirtu.', usbSetupAccess: 'Iestatīt piekļuvi', usbSetupFlatpak: 'Flatpak iekšienē lietotne nevar pati instalēt udev kārtulu. Instalējiet 70-zplab.rules no ZPLab repozitorija resursdatorā un pēc tam atkārtoti pievienojiet printeri.', diff --git a/src/locales/nl.ts b/src/locales/nl.ts index 141f18ed..ddfb6f07 100644 --- a/src/locales/nl.ts +++ b/src/locales/nl.ts @@ -988,11 +988,10 @@ const nl = { exportZpl: 'Export ZPL', menuInitError: 'Het menu van de applicatie kon niet worden geladen. Start de app opnieuw.', importCsvData: 'CSV-gegevens importeren', - sendToZebraBatchFmt: 'Verzenden naar Zebra ({n} etiketten)', exportBatchZplFmt: 'Batch-ZPL exporteren ({n} etiketten)', exportPdf: 'PDF exporteren', - exportBatchPdfFmt: 'Batch-PDF exporteren ({n} etiketten)', exportPdfCurrentPage: 'PDF exporteren (huidige pagina)', + exportPng: 'PNG exporteren', newDesign: 'Nieuw ontwerp', addPage: 'Pagina toevoegen', cancel: 'Annuleren', @@ -1011,8 +1010,6 @@ const nl = { deletePageConfirm: 'Huidige pagina verwijderen?', openDesign: 'Ontwerp openen', saveDesign: 'Ontwerp opslaan', - print: 'Afdrukken als afbeelding (browser)', - sendToZebra: 'Verzenden naar Zebra-printer', propertiesTab: 'Eigenschappen', layersTab: 'Lagen', fontsTab: 'Lettertypen', @@ -1839,11 +1836,27 @@ const nl = { }, zebraPrint: { heading: 'Verzenden naar Zebra-printer', + outputHeading: 'Uitvoer', + kindPrint: 'Afdrukken', + kindPrintHint: 'Stuurt het etiket naar een printer.', + kindFile: 'Bestand', + kindFileHint: 'Slaat het etiket op deze computer op.', + formatHeading: 'Formaat', + formatPdf: 'PDF', + formatPdfHint: 'Slaat het etiket op als PDF.', + formatPng: 'PNG', + formatPngHint: 'Slaat het etiket op als afbeelding. Alleen de actieve datarij.', + scopeHeading: 'Omvang', + scopeBatch: 'Eén etiket per datarij', + scopeDesign: 'Alleen het ontwerp', + scopeCountFmt: '{label} ({n})', + systemRendering: 'Het etiket wordt gerenderd. Daarna opent het afdrukvenster van het systeem.', + systemActiveRowOnlyFmt: 'Alleen datarij {n} van {rows} wordt afgedrukt.', batchNoticeFmt: 'Drukt {n} etiketten af (één per gegevensrij).', batchNoticeQtyFmt: 'Drukt {n} etiketten af ({rows} rijen × {q} per rij).', - tabNetwork: 'Netwerk (IP)', - tabBrowserPrint: 'Zebra Browser Print', - tabLocal: 'Lokale printer', + tabNetwork: 'ZPL-printer (netwerk)', + tabBrowserPrint: 'ZPL-printer (Browser Print)', + tabLocal: 'ZPL-printer (lokaal)', ipAddress: 'IP-adres', port: 'Poort', send: 'ZPL verzenden', @@ -1861,7 +1874,16 @@ const nl = { errorNoResponse: 'Printer reageerde niet, controleer IP en netwerk', errorGeneric: 'ZPL verzenden mislukt', httpsWarning: 'Pagina gebruikt HTTPS, direct afdrukken via IP kan worden geblokkeerd door de browser (gemengde inhoud).', - tabUsb: 'USB', + tabUsb: 'ZPL-printer (USB)', + tabSystem: 'Andere printer', + wayHeading: 'Printer', + wayNetwork: 'Stuurt via TCP rechtstreeks naar de printer, zonder driver en wachtrij. De printer antwoordt, daarom werken status en voorbeeld.', + wayNetworkWeb: 'Stuurt de code naar de printerpoort. De browser krijgt geen antwoord, dus niets leest terug.', + wayBrowserPrint: 'Loopt via Zebra Browser Print op de computer die de browser uitvoert.', + wayLocal: 'Stuurt via de afdrukwachtrij van het besturingssysteem, die naar dezelfde printer kan wijzen. Langs deze weg antwoordt de printer niet.', + wayUsb: 'Stuurt rechtstreeks naar het aangesloten apparaat en leest terug.', + waySystem: 'Tekent het etiket en geeft het aan het afdrukvenster van het besturingssysteem, voor elke printer met een driver. Alleen de actieve datarij wordt afgedrukt.', + wayQueryAddress: 'De controle gaat altijd naar het adres van de printer.', usbPermissionDenied: 'Geen toegang tot de USB-printer. Klik op Toegang instellen om toegang te verlenen.', usbSetupAccess: 'Toegang instellen', usbSetupFlatpak: 'Binnen Flatpak kan de app de udev-regel niet zelf installeren. Installeer 70-zplab.rules uit de ZPLab-repository op de host en sluit de printer vervolgens opnieuw aan.', diff --git a/src/locales/no.ts b/src/locales/no.ts index b38b23db..ab63658c 100644 --- a/src/locales/no.ts +++ b/src/locales/no.ts @@ -988,11 +988,10 @@ const no = { exportZpl: 'Export ZPL', menuInitError: 'Programmenyen kunne ikke lastes inn. Start appen på nytt.', importCsvData: 'Importer CSV-data', - sendToZebraBatchFmt: 'Send til Zebra ({n} etiketter)', exportBatchZplFmt: 'Eksporter batch-ZPL ({n} etiketter)', exportPdf: 'Eksporter PDF', - exportBatchPdfFmt: 'Eksporter batch-PDF ({n} etiketter)', exportPdfCurrentPage: 'Eksporter PDF (gjeldende side)', + exportPng: 'Eksporter PNG', newDesign: 'Nytt design', addPage: 'Legg til side', cancel: 'Avbryt', @@ -1011,8 +1010,6 @@ const no = { deletePageConfirm: 'Slette gjeldende side?', openDesign: 'Åpne design', saveDesign: 'Lagre design', - print: 'Skriv ut som bilde (nettleser)', - sendToZebra: 'Send til Zebra-skriver', propertiesTab: 'Egenskaper', layersTab: 'Lag', fontsTab: 'Skrifter', @@ -1839,11 +1836,27 @@ const no = { }, zebraPrint: { heading: 'Send til Zebra-skriver', + outputHeading: 'Utdata', + kindPrint: 'Skriv ut', + kindPrintHint: 'Sender etiketten til en skriver.', + kindFile: 'Fil', + kindFileHint: 'Lagrer etiketten på denne datamaskinen.', + formatHeading: 'Format', + formatPdf: 'PDF', + formatPdfHint: 'Lagrer etiketten som PDF.', + formatPng: 'PNG', + formatPngHint: 'Lagrer etiketten som bilde. Bare den aktive dataraden.', + scopeHeading: 'Omfang', + scopeBatch: 'Én etikett per datarad', + scopeDesign: 'Bare designet', + scopeCountFmt: '{label} ({n})', + systemRendering: 'Etiketten gjengis. Deretter åpnes systemets utskriftsdialog.', + systemActiveRowOnlyFmt: 'Bare datarad {n} av {rows} skrives ut.', batchNoticeFmt: 'Skriver ut {n} etiketter (én per datarad).', batchNoticeQtyFmt: 'Skriver ut {n} etiketter ({rows} rader × {q} per rad).', - tabNetwork: 'Nettverk (IP)', - tabBrowserPrint: 'Zebra Browser Print', - tabLocal: 'Lokal skriver', + tabNetwork: 'ZPL-skriver (nettverk)', + tabBrowserPrint: 'ZPL-skriver (Browser Print)', + tabLocal: 'ZPL-skriver (lokal)', ipAddress: 'IP-adresse', port: 'Port', send: 'Send ZPL', @@ -1861,7 +1874,16 @@ const no = { errorNoResponse: 'Skriveren svarte ikke, sjekk IP og nettverk', errorGeneric: 'Sending av ZPL mislyktes', httpsWarning: 'Siden bruker HTTPS, direkte utskrift via IP kan bli blokkert av nettleseren (blandet innhold).', - tabUsb: 'USB', + tabUsb: 'ZPL-skriver (USB)', + tabSystem: 'Annen skriver', + wayHeading: 'Skriver', + wayNetwork: 'Sender over TCP direkte til skriveren, uten driver og kø. Skriveren svarer, så status og forhåndsvisning virker.', + wayNetworkWeb: 'Sender koden til skriverporten. Nettleseren får ikke svar, så ingenting leser tilbake.', + wayBrowserPrint: 'Går via Zebra Browser Print på datamaskinen som kjører nettleseren.', + wayLocal: 'Sender via operativsystemets utskriftskø, som kan peke på den samme skriveren. Den veien svarer ikke skriveren.', + wayUsb: 'Sender rett til den tilkoblede enheten og leser tilbake.', + waySystem: 'Tegner etiketten og gir den til utskriftsdialogen i operativsystemet, for enhver skriver med driver. Bare den aktive dataraden skrives ut.', + wayQueryAddress: 'Kontrollen går alltid til skriverens adresse.', usbPermissionDenied: 'Ingen tilgang til USB-skriveren. Klikk på Konfigurer tilgang for å gi den.', usbSetupAccess: 'Konfigurer tilgang', usbSetupFlatpak: 'Inne i Flatpak kan appen ikke installere udev-regelen selv. Installer 70-zplab.rules fra ZPLab-repositoriet på verten, og koble deretter skriveren til på nytt.', diff --git a/src/locales/pl.ts b/src/locales/pl.ts index d79dc1dc..bda1696e 100644 --- a/src/locales/pl.ts +++ b/src/locales/pl.ts @@ -988,11 +988,10 @@ const pl = { exportZpl: 'Export ZPL', menuInitError: 'Nie udało się załadować menu aplikacji. Uruchom aplikację ponownie.', importCsvData: 'Importuj dane CSV', - sendToZebraBatchFmt: 'Wyślij do Zebra ({n} etykiet)', exportBatchZplFmt: 'Eksportuj wsadowy ZPL ({n} etykiet)', exportPdf: 'Eksportuj PDF', - exportBatchPdfFmt: 'Eksportuj wsadowy PDF ({n} etykiet)', exportPdfCurrentPage: 'Eksportuj PDF (bieżąca strona)', + exportPng: 'Eksportuj PNG', newDesign: 'Nowy projekt', addPage: 'Dodaj stronę', cancel: 'Anuluj', @@ -1011,8 +1010,6 @@ const pl = { deletePageConfirm: 'Usunąć bieżącą stronę?', openDesign: 'Otwórz projekt', saveDesign: 'Zapisz projekt', - print: 'Drukuj jako obraz (przeglądarka)', - sendToZebra: 'Wyślij do drukarki Zebra', propertiesTab: 'Właściwości', layersTab: 'Warstwy', fontsTab: 'Czcionki', @@ -1839,11 +1836,27 @@ const pl = { }, zebraPrint: { heading: 'Wyślij do drukarki Zebra', + outputHeading: 'Wydruk i eksport', + kindPrint: 'Drukuj', + kindPrintHint: 'Wysyła etykietę do drukarki.', + kindFile: 'Plik', + kindFileHint: 'Zapisuje etykietę na tym komputerze.', + formatHeading: 'Format', + formatPdf: 'PDF', + formatPdfHint: 'Zapisuje etykietę jako PDF.', + formatPng: 'PNG', + formatPngHint: 'Zapisuje etykietę jako obraz. Tylko aktywny wiersz danych.', + scopeHeading: 'Zakres', + scopeBatch: 'Jedna etykieta na wiersz danych', + scopeDesign: 'Tylko projekt', + scopeCountFmt: '{label} ({n})', + systemRendering: 'Etykieta jest renderowana. Następnie otwiera się okno drukowania systemu.', + systemActiveRowOnlyFmt: 'Drukowany jest tylko wiersz danych {n} z {rows}.', batchNoticeFmt: 'Drukuje {n} etykiet (po jednej na wiersz danych).', batchNoticeQtyFmt: 'Drukuje {n} etykiet ({rows} wierszy × {q} na wiersz).', - tabNetwork: 'Sieć (IP)', - tabBrowserPrint: 'Zebra Browser Print', - tabLocal: 'Drukarka lokalna', + tabNetwork: 'Drukarka ZPL (sieć)', + tabBrowserPrint: 'Drukarka ZPL (Browser Print)', + tabLocal: 'Drukarka ZPL (lokalna)', ipAddress: 'Adres IP', port: 'Port', send: 'Wyślij ZPL', @@ -1861,7 +1874,16 @@ const pl = { errorNoResponse: 'Drukarka nie odpowiedziała, sprawdź adres IP i sieć', errorGeneric: 'Błąd wysyłania ZPL', httpsWarning: 'Strona używa HTTPS, bezpośrednie drukowanie przez IP może zostać zablokowane przez przeglądarkę (mieszana treść).', - tabUsb: 'USB', + tabUsb: 'Drukarka ZPL (USB)', + tabSystem: 'Inna drukarka', + wayHeading: 'Drukarka', + wayNetwork: 'Wysyła przez TCP wprost do drukarki, bez sterownika i kolejki. Drukarka odpowiada, dlatego status i podgląd działają.', + wayNetworkWeb: 'Wysyła kod na port drukarki. Przeglądarka nie otrzymuje odpowiedzi, więc nic nie wraca.', + wayBrowserPrint: 'Przechodzi przez Zebra Browser Print na komputerze, na którym działa przeglądarka.', + wayLocal: 'Wysyła przez kolejkę druku systemu operacyjnego, która może wskazywać tę samą drukarkę. Tą drogą drukarka nie odpowiada.', + wayUsb: 'Wysyła bezpośrednio do podłączonego urządzenia i odpowiada.', + waySystem: 'Rysuje etykietę i przekazuje ją do okna druku systemu operacyjnego, dla każdej drukarki ze sterownikiem. Drukowany jest tylko aktywny wiersz danych.', + wayQueryAddress: 'Sprawdzenie zawsze idzie na adres drukarki.', usbPermissionDenied: 'Brak dostępu do drukarki USB. Kliknij Skonfiguruj dostęp, aby go przyznać.', usbSetupAccess: 'Skonfiguruj dostęp', usbSetupFlatpak: 'Wewnątrz Flatpaka aplikacja nie może sama zainstalować reguły udev. Zainstaluj 70-zplab.rules z repozytorium ZPLab na hoście, a następnie podłącz drukarkę ponownie.', diff --git a/src/locales/pt.ts b/src/locales/pt.ts index 0f98155c..21a75e62 100644 --- a/src/locales/pt.ts +++ b/src/locales/pt.ts @@ -988,11 +988,10 @@ const pt = { exportZpl: 'Export ZPL', menuInitError: 'Não foi possível carregar o menu do aplicativo. Reinicie o aplicativo.', importCsvData: 'Importar dados CSV', - sendToZebraBatchFmt: 'Enviar para Zebra ({n} etiquetas)', exportBatchZplFmt: 'Exportar ZPL em lote ({n} etiquetas)', exportPdf: 'Exportar PDF', - exportBatchPdfFmt: 'Exportar PDF em lote ({n} etiquetas)', exportPdfCurrentPage: 'Exportar PDF (página atual)', + exportPng: 'Exportar PNG', newDesign: 'Novo design', addPage: 'Adicionar página', cancel: 'Cancelar', @@ -1011,8 +1010,6 @@ const pt = { deletePageConfirm: 'Eliminar a página atual?', openDesign: 'Abrir design', saveDesign: 'Salvar design', - print: 'Imprimir como imagem (navegador)', - sendToZebra: 'Enviar para impressora Zebra', propertiesTab: 'Propriedades', layersTab: 'Camadas', fontsTab: 'Fontes', @@ -1839,11 +1836,27 @@ const pt = { }, zebraPrint: { heading: 'Enviar para impressora Zebra', + outputHeading: 'Impressão e exportação', + kindPrint: 'Imprimir', + kindPrintHint: 'Envia a etiqueta para uma impressora.', + kindFile: 'Arquivo', + kindFileHint: 'Salva a etiqueta neste computador.', + formatHeading: 'Formato', + formatPdf: 'PDF', + formatPdfHint: 'Salva a etiqueta como PDF.', + formatPng: 'PNG', + formatPngHint: 'Salva a etiqueta como imagem. Apenas a linha de dados ativa.', + scopeHeading: 'Abrangência', + scopeBatch: 'Uma etiqueta por linha de dados', + scopeDesign: 'Apenas o design', + scopeCountFmt: '{label} ({n})', + systemRendering: 'A etiqueta está sendo gerada. Depois abre a caixa de diálogo de impressão do sistema.', + systemActiveRowOnlyFmt: 'Apenas a linha de dados {n} de {rows} é impressa.', batchNoticeFmt: 'Imprime {n} etiquetas (uma por linha de dados).', batchNoticeQtyFmt: 'Imprime {n} etiquetas ({rows} linhas × {q} cada).', - tabNetwork: 'Rede (IP)', - tabBrowserPrint: 'Zebra Browser Print', - tabLocal: 'Impressora local', + tabNetwork: 'Impressora ZPL (rede)', + tabBrowserPrint: 'Impressora ZPL (Browser Print)', + tabLocal: 'Impressora ZPL (local)', ipAddress: 'Endereço IP', port: 'Porta', send: 'Enviar ZPL', @@ -1861,7 +1874,16 @@ const pt = { errorNoResponse: 'A impressora não respondeu, verifique o IP e a rede', errorGeneric: 'Falha ao enviar ZPL', httpsWarning: 'A página está em HTTPS, a impressão direta por IP pode ser bloqueada pelo navegador (conteúdo misto).', - tabUsb: 'USB', + tabUsb: 'Impressora ZPL (USB)', + tabSystem: 'Outra impressora', + wayHeading: 'Impressora', + wayNetwork: 'Envia por TCP direto para a impressora, sem driver nem fila. A impressora responde, por isso o status e a pré-visualização funcionam.', + wayNetworkWeb: 'Envia o código para a porta da impressora. O navegador não recebe resposta, então nada responde.', + wayBrowserPrint: 'Passa pelo Zebra Browser Print no computador que executa o navegador.', + wayLocal: 'Envia pela fila de impressão do sistema operacional, que pode apontar para a mesma impressora. Por este caminho a impressora não responde.', + wayUsb: 'Envia diretamente para o dispositivo conectado e recebe resposta.', + waySystem: 'Desenha a etiqueta e a entrega ao diálogo de impressão do sistema operacional, para qualquer impressora com driver. Apenas a linha de dados ativa é impressa.', + wayQueryAddress: 'A verificação vai sempre para o endereço da impressora.', usbPermissionDenied: 'Sem acesso à impressora USB. Clique em Configurar acesso para conceder.', usbSetupAccess: 'Configurar acesso', usbSetupFlatpak: 'Dentro do Flatpak, a aplicação não pode instalar a regra udev por si própria. Instale o 70-zplab.rules a partir do repositório ZPLab no anfitrião e depois volte a ligar a impressora.', diff --git a/src/locales/ro.ts b/src/locales/ro.ts index cc6a48b8..92afd8dd 100644 --- a/src/locales/ro.ts +++ b/src/locales/ro.ts @@ -988,11 +988,10 @@ const ro = { exportZpl: 'Export ZPL', menuInitError: 'Meniul aplicației nu a putut fi încărcat. Reporniți aplicația.', importCsvData: 'Importă date CSV', - sendToZebraBatchFmt: 'Trimite la Zebra ({n} etichete)', exportBatchZplFmt: 'Exportă ZPL în lot ({n} etichete)', exportPdf: 'Exportă PDF', - exportBatchPdfFmt: 'Exportă PDF în lot ({n} etichete)', exportPdfCurrentPage: 'Exportă PDF (pagina curentă)', + exportPng: 'Exportă PNG', newDesign: 'Design nou', addPage: 'Adaugă pagină', cancel: 'Anulează', @@ -1011,8 +1010,6 @@ const ro = { deletePageConfirm: 'Ștergeți pagina curentă?', openDesign: 'Deschide design', saveDesign: 'Salvează design', - print: 'Tipărire ca imagine (browser)', - sendToZebra: 'Trimite la imprimanta Zebra', propertiesTab: 'Proprietăți', layersTab: 'Straturi', fontsTab: 'Fonturi', @@ -1839,11 +1836,27 @@ const ro = { }, zebraPrint: { heading: 'Trimite la imprimanta Zebra', + outputHeading: 'Imprimare și export', + kindPrint: 'Imprimă', + kindPrintHint: 'Trimite eticheta la o imprimantă.', + kindFile: 'Fișier', + kindFileHint: 'Salvează eticheta pe acest computer.', + formatHeading: 'Format', + formatPdf: 'PDF', + formatPdfHint: 'Salvează eticheta ca PDF.', + formatPng: 'PNG', + formatPngHint: 'Salvează eticheta ca imagine. Doar rândul de date activ.', + scopeHeading: 'Domeniu', + scopeBatch: 'O etichetă pentru fiecare rând de date', + scopeDesign: 'Doar designul', + scopeCountFmt: '{label} ({n})', + systemRendering: 'Eticheta este generată. Apoi se deschide dialogul de imprimare al sistemului.', + systemActiveRowOnlyFmt: 'Se imprimă doar rândul de date {n} din {rows}.', batchNoticeFmt: 'Tipărește {n} etichete (una pe rând de date).', batchNoticeQtyFmt: 'Tipărește {n} etichete ({rows} rânduri × {q} pe rând).', - tabNetwork: 'Rețea (IP)', - tabBrowserPrint: 'Zebra Browser Print', - tabLocal: 'Imprimantă locală', + tabNetwork: 'Imprimantă ZPL (rețea)', + tabBrowserPrint: 'Imprimantă ZPL (Browser Print)', + tabLocal: 'Imprimantă ZPL (locală)', ipAddress: 'Adresă IP', port: 'Port', send: 'Trimite ZPL', @@ -1861,7 +1874,16 @@ const ro = { errorNoResponse: 'Imprimanta nu a răspuns, verificați IP-ul și rețeaua', errorGeneric: 'Trimiterea ZPL a eşuat', httpsWarning: 'Pagina este pe HTTPS, imprimarea directă prin IP poate fi blocată de browser (conținut mixt).', - tabUsb: 'USB', + tabUsb: 'Imprimantă ZPL (USB)', + tabSystem: 'Altă imprimantă', + wayHeading: 'Imprimantă', + wayNetwork: 'Trimite prin TCP direct către imprimantă, fără driver și fără coadă. Imprimanta răspunde, de aceea starea și previzualizarea funcționează.', + wayNetworkWeb: 'Trimite codul la portul imprimantei. Browserul nu primește răspuns, așa că nimic nu se citește înapoi.', + wayBrowserPrint: 'Trece prin Zebra Browser Print pe computerul care rulează browserul.', + wayLocal: 'Trimite prin coada de imprimare a sistemului de operare, care poate indica aceeași imprimantă. Pe această cale imprimanta nu răspunde.', + wayUsb: 'Trimite direct la dispozitivul conectat și citește răspuns.', + waySystem: 'Desenează eticheta și o predă dialogului de imprimare al sistemului de operare, pentru orice imprimantă cu driver. Se imprimă doar rândul de date activ.', + wayQueryAddress: 'Verificarea merge întotdeauna la adresa imprimantei.', usbPermissionDenied: 'Fără acces la imprimanta USB. Fă clic pe Configurează accesul pentru a-l acorda.', usbSetupAccess: 'Configurează accesul', usbSetupFlatpak: 'În interiorul Flatpak, aplicația nu poate instala regula udev singură. Instalează 70-zplab.rules din depozitul ZPLab pe sistemul-host, apoi reconectează imprimanta.', diff --git a/src/locales/sk.ts b/src/locales/sk.ts index 7e30258b..d572b5a5 100644 --- a/src/locales/sk.ts +++ b/src/locales/sk.ts @@ -988,11 +988,10 @@ const sk = { exportZpl: 'Export ZPL', menuInitError: 'Ponuku aplikácie sa nepodarilo načítať. Reštartujte aplikáciu.', importCsvData: 'Importovať údaje CSV', - sendToZebraBatchFmt: 'Odoslať na Zebra ({n} etikiet)', exportBatchZplFmt: 'Exportovať dávkové ZPL ({n} etikiet)', exportPdf: 'Exportovať PDF', - exportBatchPdfFmt: 'Exportovať dávkové PDF ({n} etikiet)', exportPdfCurrentPage: 'Exportovať PDF (aktuálna stránka)', + exportPng: 'Exportovať PNG', newDesign: 'Nový návrh', addPage: 'Pridať stránku', cancel: 'Zrušiť', @@ -1011,8 +1010,6 @@ const sk = { deletePageConfirm: 'Odstrániť aktuálnu stránku?', openDesign: 'Otvoriť návrh', saveDesign: 'Uložiť návrh', - print: 'Tlač ako obrázok (prehliadač)', - sendToZebra: 'Odoslať na tlačiareň Zebra', propertiesTab: 'Vlastnosti', layersTab: 'Vrstvy', fontsTab: 'Písma', @@ -1839,11 +1836,27 @@ const sk = { }, zebraPrint: { heading: 'Odoslať na tlačiareň Zebra', + outputHeading: 'Výstup', + kindPrint: 'Vytlačiť', + kindPrintHint: 'Odošle štítok do tlačiarne.', + kindFile: 'Súbor', + kindFileHint: 'Uloží štítok do tohto počítača.', + formatHeading: 'Formát', + formatPdf: 'PDF', + formatPdfHint: 'Uloží štítok ako PDF.', + formatPng: 'PNG', + formatPngHint: 'Uloží štítok ako obrázok. Len aktívny riadok dát.', + scopeHeading: 'Rozsah', + scopeBatch: 'Jeden štítok na riadok dát', + scopeDesign: 'Len návrh', + scopeCountFmt: '{label} ({n})', + systemRendering: 'Štítok sa vykresľuje. Potom sa otvorí dialóg tlače systému.', + systemActiveRowOnlyFmt: 'Vytlačí sa len riadok údajov {n} z {rows}.', batchNoticeFmt: 'Vytlačí {n} etikiet (jednu na každý riadok údajov).', batchNoticeQtyFmt: 'Vytlačí {n} etikiet ({rows} riadkov × {q} na riadok).', - tabNetwork: 'Sieť (IP)', - tabBrowserPrint: 'Zebra Browser Print', - tabLocal: 'Miestna tlačiareň', + tabNetwork: 'ZPL tlačiareň (sieť)', + tabBrowserPrint: 'ZPL tlačiareň (Browser Print)', + tabLocal: 'ZPL tlačiareň (miestna)', ipAddress: 'IP adresa', port: 'Port', send: 'Odoslať ZPL', @@ -1861,7 +1874,16 @@ const sk = { errorNoResponse: 'Tlačiareň neodpovedá, skontrolujte IP a sieť', errorGeneric: 'Odoslanie ZPL zlyhalo', httpsWarning: 'Stránka beží cez HTTPS, priama tlač cez IP môže byť prehliadačom zablokovaná (zmiešaný obsah).', - tabUsb: 'USB', + tabUsb: 'ZPL tlačiareň (USB)', + tabSystem: 'Iná tlačiareň', + wayHeading: 'Tlačiareň', + wayNetwork: 'Posiela cez TCP priamo na tlačiareň, bez ovládača a tlačového frontu. Tlačiareň odpovedá, preto funguje stav aj náhľad.', + wayNetworkWeb: 'Odosiela kód na port tlačiarne. Prehliadač nedostane odpoveď, takže sa nič nenačíta späť.', + wayBrowserPrint: 'Ide cez Zebra Browser Print na počítači, na ktorom beží prehliadač.', + wayLocal: 'Posiela cez tlačový front operačného systému, ktorý môže smerovať na tú istú tlačiareň. Touto cestou tlačiareň neodpovedá.', + wayUsb: 'Odosiela priamo do pripojeného zariadenia a číta odpoveď.', + waySystem: 'Vykreslí štítok a predá ho tlačovému dialógu operačného systému, pre každú tlačiareň s ovládačom. Vytlačí sa len aktívny riadok dát.', + wayQueryAddress: 'Kontrola ide vždy na adresu tlačiarne.', usbPermissionDenied: 'Žiadny prístup k tlačiarni USB. Prístup udelíte kliknutím na Nastaviť prístup.', usbSetupAccess: 'Nastaviť prístup', usbSetupFlatpak: 'Vo Flatpaku si aplikácia nemôže pravidlo udev nainštalovať sama. Nainštalujte 70-zplab.rules z repozitára ZPLab na hostiteľský systém a potom znova pripojte tlačiareň.', diff --git a/src/locales/sl.ts b/src/locales/sl.ts index 0b2f8737..31cb2137 100644 --- a/src/locales/sl.ts +++ b/src/locales/sl.ts @@ -988,11 +988,10 @@ const sl = { exportZpl: 'Export ZPL', menuInitError: 'Menija aplikacije ni bilo mogoče naložiti. Znova zaženite aplikacijo.', importCsvData: 'Uvozi podatke CSV', - sendToZebraBatchFmt: 'Pošlji na Zebra ({n} etiket)', exportBatchZplFmt: 'Izvozi paketni ZPL ({n} etiket)', exportPdf: 'Izvozi PDF', - exportBatchPdfFmt: 'Izvozi paketni PDF ({n} etiket)', exportPdfCurrentPage: 'Izvozi PDF (trenutna stran)', + exportPng: 'Izvozi PNG', newDesign: 'Nov dizajn', addPage: 'Dodaj stran', cancel: 'Prekliči', @@ -1011,8 +1010,6 @@ const sl = { deletePageConfirm: 'Izbrišem trenutno stran?', openDesign: 'Odpri dizajn', saveDesign: 'Shrani dizajn', - print: 'Natisni kot sliko (brskalnik)', - sendToZebra: 'Pošlji na tiskalnik Zebra', propertiesTab: 'Lastnosti', layersTab: 'Plasti', fontsTab: 'Pisave', @@ -1839,11 +1836,27 @@ const sl = { }, zebraPrint: { heading: 'Pošlji na tiskalnik Zebra', + outputHeading: 'Tiskanje in izvoz', + kindPrint: 'Natisni', + kindPrintHint: 'Pošlje etiketo tiskalniku.', + kindFile: 'Datoteka', + kindFileHint: 'Shrani etiketo na ta računalnik.', + formatHeading: 'Oblika', + formatPdf: 'PDF', + formatPdfHint: 'Shrani etiketo kot PDF.', + formatPng: 'PNG', + formatPngHint: 'Shrani etiketo kot sliko. Samo aktivna vrstica podatkov.', + scopeHeading: 'Obseg', + scopeBatch: 'Ena etiketa na vrstico podatkov', + scopeDesign: 'Samo dizajn', + scopeCountFmt: '{label} ({n})', + systemRendering: 'Etiketa se izrisuje. Nato se odpre sistemsko pogovorno okno za tiskanje.', + systemActiveRowOnlyFmt: 'Natisne se samo podatkovna vrstica {n} od {rows}.', batchNoticeFmt: 'Natisne {n} etiket (eno na vrstico podatkov).', batchNoticeQtyFmt: 'Natisne {n} etiket ({rows} vrstic × {q} na vrstico).', - tabNetwork: 'Omrežje (IP)', - tabBrowserPrint: 'Zebra Browser Print', - tabLocal: 'Lokalni tiskalnik', + tabNetwork: 'ZPL tiskalnik (omrežje)', + tabBrowserPrint: 'ZPL tiskalnik (Browser Print)', + tabLocal: 'ZPL tiskalnik (lokalni)', ipAddress: 'IP naslov', port: 'Vrata', send: 'Pošlji ZPL', @@ -1861,7 +1874,16 @@ const sl = { errorNoResponse: 'Tiskalnik se ni odzval, preverite IP in omrežje', errorGeneric: 'Pošiljanje ZPL ni uspelo', httpsWarning: 'Stran uporablja HTTPS, neposredno tiskanje prek IP-ja je morda blokirano s strani brskalnika (mešana vsebina).', - tabUsb: 'USB', + tabUsb: 'ZPL tiskalnik (USB)', + tabSystem: 'Drug tiskalnik', + wayHeading: 'Tiskalnik', + wayNetwork: 'Pošilja prek TCP neposredno na tiskalnik, brez gonilnika in čakalne vrste. Tiskalnik odgovori, zato delujeta stanje in predogled.', + wayNetworkWeb: 'Pošlje kodo na vrata tiskalnika. Brskalnik ne prejme odziva, zato nič ne prebere nazaj.', + wayBrowserPrint: 'Poteka preko Zebra Browser Print na računalniku, ki poganja brskalnik.', + wayLocal: 'Pošilja prek tiskalne vrste operacijskega sistema, ki lahko kaže na isti tiskalnik. Po tej poti tiskalnik ne odgovori.', + wayUsb: 'Pošlje neposredno na povezano napravo in prebere odziv.', + waySystem: 'Nariše etiketo in jo izroči pogovornemu oknu za tiskanje operacijskega sistema, za vsak tiskalnik z gonilnikom. Natisne se samo aktivna vrstica podatkov.', + wayQueryAddress: 'Preverjanje gre vedno na naslov tiskalnika.', usbPermissionDenied: 'Ni dostopa do tiskalnika USB. Za dodelitev kliknite Nastavi dostop.', usbSetupAccess: 'Nastavi dostop', usbSetupFlatpak: 'Znotraj Flatpaka aplikacija ne more sama namestiti pravila udev. Namestite 70-zplab.rules iz repozitorija ZPLab na gostitelja in nato tiskalnik znova priključite.', diff --git a/src/locales/sr.ts b/src/locales/sr.ts index 08184edf..fbc73b7e 100644 --- a/src/locales/sr.ts +++ b/src/locales/sr.ts @@ -988,11 +988,10 @@ const sr = { exportZpl: 'Export ZPL', menuInitError: 'Мени апликације није могао да се учита. Поново покрените апликацију.', importCsvData: 'Увоз CSV података', - sendToZebraBatchFmt: 'Пошаљи на Zebra ({n} етикета)', exportBatchZplFmt: 'Извоз групног ZPL-а ({n} етикета)', exportPdf: 'Извоз PDF-а', - exportBatchPdfFmt: 'Извоз групног PDF-а ({n} етикета)', exportPdfCurrentPage: 'Извоз PDF-а (тренутна страница)', + exportPng: 'Извоз PNG-а', newDesign: 'Нови дизајн', addPage: 'Додај страницу', cancel: 'Откажи', @@ -1011,8 +1010,6 @@ const sr = { deletePageConfirm: 'Обрисати тренутну страницу?', openDesign: 'Отвори дизајн', saveDesign: 'Сачувај дизајн', - print: 'Штампање као слика (прегледач)', - sendToZebra: 'Пошаљи на Zebra штампач', propertiesTab: 'Својства', layersTab: 'Слојеви', fontsTab: 'Фонтови', @@ -1839,11 +1836,27 @@ const sr = { }, zebraPrint: { heading: 'Пошаљи на Zebra штампач', + outputHeading: 'Штампање и извоз', + kindPrint: 'Штампај', + kindPrintHint: 'Шаље етикету штампачу.', + kindFile: 'Датотека', + kindFileHint: 'Чува етикету на овом рачунару.', + formatHeading: 'Формат', + formatPdf: 'PDF', + formatPdfHint: 'Чува етикету као PDF.', + formatPng: 'PNG', + formatPngHint: 'Чува етикету као слику. Само активни ред података.', + scopeHeading: 'Обухват', + scopeBatch: 'Једна етикета по реду података', + scopeDesign: 'Само дизајн', + scopeCountFmt: '{label} ({n})', + systemRendering: 'Етикета се исцртава. Потом се отвара системски дијалог за штампу.', + systemActiveRowOnlyFmt: 'Штампа се само ред података {n} од {rows}.', batchNoticeFmt: 'Штампа {n} етикета (по једну за сваки ред података).', batchNoticeQtyFmt: 'Штампа {n} етикета ({rows} редова × {q} по реду).', - tabNetwork: 'Мрежа (IP)', - tabBrowserPrint: 'Zebra Browser Print', - tabLocal: 'Локални штампач', + tabNetwork: 'ZPL штампач (мрежа)', + tabBrowserPrint: 'ZPL штампач (Browser Print)', + tabLocal: 'ZPL штампач (локални)', ipAddress: 'IP adresa', port: 'Port', send: 'Pošalji ZPL', @@ -1861,7 +1874,16 @@ const sr = { errorNoResponse: 'Штампач не одговара, проверите IP и мрежу', errorGeneric: 'Slanje ZPL-a nije uspelo', httpsWarning: 'Stranica koristi HTTPS, direktno štampanje putem IP može biti blokirano od strane pregledača (mešoviti sadržaj).', - tabUsb: 'USB', + tabUsb: 'ZPL штампач (USB)', + tabSystem: 'Drugi štampač', + wayHeading: 'Штампач', + wayNetwork: 'Шаље преко TCP-а директно на штампач, без драјвера и реда за штампу. Штампач одговара, зато раде статус и преглед.', + wayNetworkWeb: 'Шаље код на порт штампача. Прегледач не прима одговор, па ништа не чита назад.', + wayBrowserPrint: 'Иде преко Zebra Browser Print-а на рачунару који покреће прегледач.', + wayLocal: 'Шаље преко реда за штампу оперативног система, који може да показује на исти штампач. Овим путем штампач не одговара.', + wayUsb: 'Шаље директно на повезани уређај и чита одговор.', + waySystem: 'Исцртава етикету и предаје је дијалогу за штампу оперативног система, за сваки штампач са драјвером. Штампа се само активни ред података.', + wayQueryAddress: 'Провера увек иде на адресу штампача.', usbPermissionDenied: 'Нема приступа USB штампачу. Кликните на Подеси приступ да бисте га омогућили.', usbSetupAccess: 'Подеси приступ', usbSetupFlatpak: 'Унутар Flatpak-а апликација не може сама да инсталира udev правило. Инсталирајте 70-zplab.rules из ZPLab репозиторијума на хост систем, затим поново прикључите штампач.', diff --git a/src/locales/sv.ts b/src/locales/sv.ts index e8034f98..d1ca4760 100644 --- a/src/locales/sv.ts +++ b/src/locales/sv.ts @@ -988,11 +988,10 @@ const sv = { exportZpl: 'Export ZPL', menuInitError: 'Programmets meny kunde inte laddas. Starta om appen.', importCsvData: 'Importera CSV-data', - sendToZebraBatchFmt: 'Skicka till Zebra ({n} etiketter)', exportBatchZplFmt: 'Exportera batch-ZPL ({n} etiketter)', exportPdf: 'Exportera PDF', - exportBatchPdfFmt: 'Exportera batch-PDF ({n} etiketter)', exportPdfCurrentPage: 'Exportera PDF (aktuell sida)', + exportPng: 'Exportera PNG', newDesign: 'Nytt design', addPage: 'Lägg till sida', cancel: 'Avbryt', @@ -1011,8 +1010,6 @@ const sv = { deletePageConfirm: 'Ta bort aktuell sida?', openDesign: 'Öppna design', saveDesign: 'Spara design', - print: 'Skriv ut som bild (webbläsare)', - sendToZebra: 'Skicka till Zebra-skrivare', propertiesTab: 'Egenskaper', layersTab: 'Lager', fontsTab: 'Typsnitt', @@ -1839,11 +1836,27 @@ const sv = { }, zebraPrint: { heading: 'Skicka till Zebra-skrivare', + outputHeading: 'Utdata', + kindPrint: 'Skriv ut', + kindPrintHint: 'Skickar etiketten till en skrivare.', + kindFile: 'Fil', + kindFileHint: 'Sparar etiketten på den här datorn.', + formatHeading: 'Format', + formatPdf: 'PDF', + formatPdfHint: 'Sparar etiketten som PDF.', + formatPng: 'PNG', + formatPngHint: 'Sparar etiketten som en bild. Endast den aktiva dataraden.', + scopeHeading: 'Omfattning', + scopeBatch: 'En etikett per datarad', + scopeDesign: 'Bara designen', + scopeCountFmt: '{label} ({n})', + systemRendering: 'Etiketten renderas. Därefter öppnas systemets utskriftsdialog.', + systemActiveRowOnlyFmt: 'Endast datarad {n} av {rows} skrivs ut.', batchNoticeFmt: 'Skriver ut {n} etiketter (en per datarad).', batchNoticeQtyFmt: 'Skriver ut {n} etiketter ({rows} rader × {q} per rad).', - tabNetwork: 'Nätverk (IP)', - tabBrowserPrint: 'Zebra Browser Print', - tabLocal: 'Lokal skrivare', + tabNetwork: 'ZPL-skrivare (nätverk)', + tabBrowserPrint: 'ZPL-skrivare (Browser Print)', + tabLocal: 'ZPL-skrivare (lokal)', ipAddress: 'IP-adress', port: 'Port', send: 'Skicka ZPL', @@ -1861,7 +1874,16 @@ const sv = { errorNoResponse: 'Skrivaren svarade inte, kontrollera IP och nätverk', errorGeneric: 'Det gick inte att skicka ZPL', httpsWarning: 'Sidan använder HTTPS, direktutskrift via IP kan blockeras av webbläsaren (blandat innehåll).', - tabUsb: 'USB', + tabUsb: 'ZPL-skrivare (USB)', + tabSystem: 'Annan skrivare', + wayHeading: 'Skrivare', + wayNetwork: 'Skickar över TCP direkt till skrivaren, utan drivrutin och kö. Skrivaren svarar, så status och förhandsvisning fungerar.', + wayNetworkWeb: 'Skickar koden till skrivarens port. Webbläsaren får inget svar, så inget läser tillbaka.', + wayBrowserPrint: 'Går via Zebra Browser Print på datorn som kör webbläsaren.', + wayLocal: 'Skickar via operativsystemets utskriftskö, som kan peka på samma skrivare. Den vägen svarar skrivaren inte.', + wayUsb: 'Skickar direkt till den anslutna enheten och läser tillbaka.', + waySystem: 'Ritar etiketten och lämnar den till operativsystemets utskriftsdialog, för varje skrivare med drivrutin. Endast den aktiva dataraden skrivs ut.', + wayQueryAddress: 'Kontrollen går alltid till skrivarens adress.', usbPermissionDenied: 'Ingen åtkomst till USB-skrivaren. Klicka på Konfigurera åtkomst för att ge den.', usbSetupAccess: 'Konfigurera åtkomst', usbSetupFlatpak: 'Inuti Flatpak kan appen inte installera udev-regeln själv. Installera 70-zplab.rules från ZPLab-förrådet på värden och anslut sedan skrivaren igen.', diff --git a/src/locales/tr.ts b/src/locales/tr.ts index e46409d4..feb9cd5a 100644 --- a/src/locales/tr.ts +++ b/src/locales/tr.ts @@ -988,11 +988,10 @@ 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', - sendToZebraBatchFmt: "Zebra'ya gönder ({n} etiket)", exportBatchZplFmt: 'Toplu ZPL dışa aktar ({n} etiket)', exportPdf: 'PDF Dışa Aktar', - exportBatchPdfFmt: 'Toplu PDF dışa aktar ({n} etiket)', exportPdfCurrentPage: 'PDF Dışa Aktar (geçerli sayfa)', + exportPng: 'PNG Dışa Aktar', newDesign: 'Yeni Tasarım', addPage: 'Sayfa ekle', cancel: 'İptal', @@ -1011,8 +1010,6 @@ const tr = { deletePageConfirm: 'Geçerli sayfa silinsin mi?', openDesign: 'Tasarım Aç', saveDesign: 'Tasarım Kaydet', - print: 'Görüntü olarak yazdır (tarayıcı)', - sendToZebra: 'Zebra yazıcıya gönder', propertiesTab: 'Özellikler', layersTab: 'Katmanlar', fontsTab: 'Yazı Tipleri', @@ -1839,11 +1836,27 @@ const tr = { }, zebraPrint: { heading: 'Zebra yazıcıya gönder', + outputHeading: 'Çıktı', + kindPrint: 'Yazdır', + kindPrintHint: 'Etiketi bir yazıcıya gönderir.', + kindFile: 'Dosya', + kindFileHint: 'Etiketi bu bilgisayara kaydeder.', + formatHeading: 'Biçim', + formatPdf: 'PDF', + formatPdfHint: 'Etiketi PDF olarak kaydeder.', + formatPng: 'PNG', + formatPngHint: 'Etiketi resim olarak kaydeder. Yalnızca etkin veri satırı.', + scopeHeading: 'Kapsam', + scopeBatch: 'Her veri satırı için bir etiket', + scopeDesign: 'Sadece tasarım', + scopeCountFmt: '{label} ({n})', + systemRendering: 'Etiket oluşturuluyor. Ardından sistemin yazdırma penceresi açılır.', + systemActiveRowOnlyFmt: 'Yalnızca {rows} veri satırından {n}. satır yazdırılır.', batchNoticeFmt: '{n} etiket yazdırır (veri satırı başına bir).', batchNoticeQtyFmt: '{n} etiket yazdırır ({rows} satır × satır başına {q}).', - tabNetwork: 'Ağ (IP)', - tabBrowserPrint: 'Zebra Browser Print', - tabLocal: 'Yerel yazıcı', + tabNetwork: 'ZPL yazıcı (ağ)', + tabBrowserPrint: 'ZPL yazıcı (Browser Print)', + tabLocal: 'ZPL yazıcı (yerel)', ipAddress: 'IP Adresi', port: 'Port', send: 'ZPL Gönder', @@ -1861,7 +1874,16 @@ const tr = { errorNoResponse: 'Yazıcı yanıt vermedi, IP ve ağı kontrol edin', errorGeneric: 'ZPL gönderilemedi', httpsWarning: 'Sayfa HTTPS üzerinde, doğrudan IP yazdırma tarayıcı tarafından engellenebilir (karma içerik).', - tabUsb: 'USB', + tabUsb: 'ZPL yazıcı (USB)', + tabSystem: 'Diğer yazıcı', + wayHeading: 'Yazıcı', + wayNetwork: 'TCP üzerinden doğrudan yazıcıya gönderir, sürücü ve kuyruk devreye girmez. Yazıcı yanıt verir, bu yüzden durum ve önizleme çalışır.', + wayNetworkWeb: 'Kodu yazıcı portuna gönderir. Tarayıcı yanıt almaz, bu yüzden hiçbir şey geri okunmaz.', + wayBrowserPrint: 'Tarayıcıyı çalıştıran bilgisayardaki Zebra Browser Print üzerinden gider.', + wayLocal: 'İşletim sisteminin yazdırma kuyruğu üzerinden gönderir, kuyruk aynı yazıcıyı gösterebilir. Bu yolda yazıcı yanıt vermez.', + wayUsb: 'Bağlı aygıta doğrudan gönderir ve yanıt okur.', + waySystem: 'Etiketi çizer ve işletim sisteminin yazdırma iletişim kutusuna verir, sürücüsü olan her yazıcı için. Yalnızca etkin veri satırı yazdırılır.', + wayQueryAddress: 'Denetim her zaman yazıcı adresine gider.', usbPermissionDenied: "USB yazıcıya erişilemiyor. İzin vermek için Erişimi ayarla'ya tıklayın.", usbSetupAccess: 'Erişimi ayarla', usbSetupFlatpak: 'Flatpak içinde uygulama udev kuralını kendi başına yükleyemez. ZPLab deposundan 70-zplab.rules dosyasını ana makineye yükleyin, ardından yazıcıyı yeniden takın.', diff --git a/src/locales/zh-hans.ts b/src/locales/zh-hans.ts index 8a674f86..49b389d0 100644 --- a/src/locales/zh-hans.ts +++ b/src/locales/zh-hans.ts @@ -988,11 +988,10 @@ const zhHans = { exportZpl: '导出 ZPL', menuInitError: '无法加载应用程序菜单。请重新启动应用。', importCsvData: '导入 CSV 数据', - sendToZebraBatchFmt: '发送到 Zebra({n} 个标签)', exportBatchZplFmt: '导出批量 ZPL ({n} 个标签)', exportPdf: '导出 PDF', - exportBatchPdfFmt: '导出批量 PDF ({n} 个标签)', exportPdfCurrentPage: '导出 PDF (当前页)', + exportPng: '导出 PNG', newDesign: '新建设计', addPage: '添加页面', cancel: '取消', @@ -1011,8 +1010,6 @@ const zhHans = { deletePageConfirm: '删除当前页面?', openDesign: '打开设计', saveDesign: '保存设计', - print: '打印为图片(浏览器)', - sendToZebra: '发送到 Zebra 打印机', propertiesTab: '属性', layersTab: '图层', fontsTab: '字体', @@ -1839,11 +1836,27 @@ const zhHans = { }, zebraPrint: { heading: '发送到 Zebra 打印机', + outputHeading: '输出', + kindPrint: '打印', + kindPrintHint: '将标签发送到打印机。', + kindFile: '文件', + kindFileHint: '将标签保存到这台计算机。', + formatHeading: '格式', + formatPdf: 'PDF', + formatPdfHint: '将标签保存为 PDF。', + formatPng: 'PNG', + formatPngHint: '将标签保存为图像。只保存当前的数据行。', + scopeHeading: '范围', + scopeBatch: '每个数据行一张标签', + scopeDesign: '仅设计', + scopeCountFmt: '{label}({n})', + systemRendering: '正在生成标签。随后会打开系统的打印对话框。', + systemActiveRowOnlyFmt: '只打印 {rows} 行数据中的第 {n} 行。', batchNoticeFmt: '打印 {n} 个标签(每条数据行一个)。', batchNoticeQtyFmt: '打印 {n} 个标签({rows} 行 × 每行 {q} 个)。', - tabNetwork: '网络 (IP)', - tabBrowserPrint: 'Zebra Browser Print', - tabLocal: '本地打印机', + tabNetwork: 'ZPL打印机 (网络)', + tabBrowserPrint: 'ZPL打印机 (Browser Print)', + tabLocal: 'ZPL打印机 (本地)', ipAddress: 'IP 地址', port: '端口', send: '发送 ZPL', @@ -1861,7 +1874,16 @@ const zhHans = { errorNoResponse: '打印机无响应,请检查 IP 和网络', errorGeneric: '发送 ZPL 失败', httpsWarning: '页面在 HTTPS 上,浏览器可能阻止直接 IP 打印(混合内容)。', - tabUsb: 'USB', + tabUsb: 'ZPL打印机 (USB)', + tabSystem: '其他打印机', + wayHeading: '打印机', + wayNetwork: '通过 TCP 直接发送到打印机,不经过驱动和队列。打印机会应答,所以状态和预览都可用。', + wayNetworkWeb: '将代码发送到打印机端口。浏览器收不到回复,因此不会回读。', + wayBrowserPrint: '通过运行浏览器的计算机上的 Zebra Browser Print 发送。', + wayLocal: '通过操作系统的打印队列发送,队列可以指向同一台打印机。走这条路打印机不会应答。', + wayUsb: '直接发送到已连接的设备并回读。', + waySystem: '绘制标签并交给操作系统的打印对话框,适用于任何带驱动的打印机。只打印当前的数据行。', + wayQueryAddress: '检查总是发往打印机地址。', usbPermissionDenied: '无法访问 USB 打印机。点击「设置访问权限」以授予权限。', usbSetupAccess: '设置访问权限', usbSetupFlatpak: '在 Flatpak 中,应用无法自行安装 udev 规则。请在主机上从 ZPLab 仓库安装 70-zplab.rules,然后重新插拔打印机。', diff --git a/src/locales/zh-hant.ts b/src/locales/zh-hant.ts index c03e23e2..9f999dec 100644 --- a/src/locales/zh-hant.ts +++ b/src/locales/zh-hant.ts @@ -988,11 +988,10 @@ const zhHant = { exportZpl: '匯出 ZPL', menuInitError: '無法載入應用程式選單。請重新啟動應用程式。', importCsvData: '匯入 CSV 資料', - sendToZebraBatchFmt: '傳送至 Zebra({n} 個標籤)', exportBatchZplFmt: '匯出批次 ZPL ({n} 個標籤)', exportPdf: '匯出 PDF', - exportBatchPdfFmt: '匯出批次 PDF ({n} 個標籤)', exportPdfCurrentPage: '匯出 PDF (目前頁面)', + exportPng: '匯出 PNG', newDesign: '新增設計', addPage: '新增頁面', cancel: '取消', @@ -1011,8 +1010,6 @@ const zhHant = { deletePageConfirm: '刪除目前頁面?', openDesign: '開啟設計', saveDesign: '儲存設計', - print: '列印為圖片(瀏覽器)', - sendToZebra: '傳送至 Zebra 印表機', propertiesTab: '屬性', layersTab: '圖層', fontsTab: '字體', @@ -1839,11 +1836,27 @@ const zhHant = { }, zebraPrint: { heading: '傳送至 Zebra 印表機', + outputHeading: '輸出', + kindPrint: '列印', + kindPrintHint: '將標籤傳送至印表機。', + kindFile: '檔案', + kindFileHint: '將標籤儲存到這台電腦。', + formatHeading: '格式', + formatPdf: 'PDF', + formatPdfHint: '將標籤儲存為 PDF。', + formatPng: 'PNG', + formatPngHint: '將標籤儲存為圖像。只儲存目前的資料列。', + scopeHeading: '範圍', + scopeBatch: '每個資料列一張標籤', + scopeDesign: '僅設計', + scopeCountFmt: '{label}({n})', + systemRendering: '正在產生標籤。接著會開啟系統的列印對話框。', + systemActiveRowOnlyFmt: '只會列印 {rows} 列資料中的第 {n} 列。', batchNoticeFmt: '列印 {n} 個標籤(每筆資料一個)。', batchNoticeQtyFmt: '列印 {n} 個標籤({rows} 列 × 每列 {q} 個)。', - tabNetwork: '網路 (IP)', - tabBrowserPrint: 'Zebra Browser Print', - tabLocal: '本機印表機', + tabNetwork: 'ZPL印表機 (網路)', + tabBrowserPrint: 'ZPL印表機 (Browser Print)', + tabLocal: 'ZPL印表機 (本機)', ipAddress: 'IP 位址', port: '連接埠', send: '傳送 ZPL', @@ -1861,7 +1874,16 @@ const zhHant = { errorNoResponse: '印表機無回應,請檢查 IP 與網路', errorGeneric: '傳送 ZPL 失敗', httpsWarning: '頁面在 HTTPS 上,瀏覽器可能封鎖直接 IP 列印(混合內容)。', - tabUsb: 'USB', + tabUsb: 'ZPL印表機 (USB)', + tabSystem: '其他印表機', + wayHeading: '印表機', + wayNetwork: '透過 TCP 直接送到印表機,不經過驅動程式和佇列。印表機會回應,所以狀態和預覽都可用。', + wayNetworkWeb: '將程式碼傳送至印表機連接埠。瀏覽器收不到回覆,因此不會回讀。', + wayBrowserPrint: '透過執行瀏覽器的電腦上的 Zebra Browser Print 傳送。', + wayLocal: '透過作業系統的列印佇列傳送,佇列可以指向同一台印表機。走這條路印表機不會回應。', + wayUsb: '直接傳送至已連接的裝置並回讀。', + waySystem: '繪製標籤並交給作業系統的列印對話框,適用於任何有驅動程式的印表機。只會列印目前的資料列。', + wayQueryAddress: '檢查一律發往印表機位址。', usbPermissionDenied: '無法存取 USB 印表機。點按「設定存取權限」以授予權限。', usbSetupAccess: '設定存取權限', usbSetupFlatpak: '在 Flatpak 中,應用程式無法自行安裝 udev 規則。請在主機上從 ZPLab 儲存庫安裝 70-zplab.rules,然後重新插拔印表機。', diff --git a/src/store/slices/uiSlice.outputWay.test.ts b/src/store/slices/uiSlice.outputWay.test.ts new file mode 100644 index 00000000..7f4b031a --- /dev/null +++ b/src/store/slices/uiSlice.outputWay.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; +import { useLabelStore } from "../labelStore"; +import { DEFAULT_PRINT_TARGET } from "../../lib/printTarget"; + +describe("chooseOutputWay", () => { + it("binds a Zebra way to the print target and lets the printer settings decide again", () => { + useLabelStore.setState({ printTarget: DEFAULT_PRINT_TARGET }); + useLabelStore.getState().setOutputChoice({ printWay: "system" }); + + useLabelStore.getState().chooseOutputWay("usb"); + + expect(useLabelStore.getState().printTarget.transport).toBe("usb"); + expect(useLabelStore.getState().outputChoice.printWay).toBeNull(); + }); + + it("keeps the print target the printer settings own when the system way is chosen", () => { + useLabelStore.setState({ printTarget: { ...DEFAULT_PRINT_TARGET, transport: "browserprint" } }); + + useLabelStore.getState().chooseOutputWay("system"); + + expect(useLabelStore.getState().outputChoice.printWay).toBe("system"); + expect(useLabelStore.getState().printTarget.transport).toBe("browserprint"); + }); +}); diff --git a/src/store/slices/uiSlice.ts b/src/store/slices/uiSlice.ts index ddcf6f25..2f6e1ca0 100644 --- a/src/store/slices/uiSlice.ts +++ b/src/store/slices/uiSlice.ts @@ -1,4 +1,5 @@ import type { StateCreator } from 'zustand'; +import { defaultOutputChoice, type OutputChoice, type OutputSource } from '../../lib/outputChoice'; import type { Unit } from '@zplab/core/lib/units'; import type { ViewRotation } from '@zplab/core/registry/rotation'; import { fallbackTranslations, loadLocale, type LocaleCode, type Translations } from '../../locales'; @@ -8,6 +9,7 @@ import { thirdPartyDefaults, } from '../labelStore.internals'; import type { LabelState } from '../labelStore'; +import type { PrintWay } from '../../lib/printTarget'; import { defaultPaletteRows } from '../../registry/paletteTypes'; import { makeCredentialHydrator, setCredential, setLabelaryKeyBound, migrateLabelaryKeyBinding, LABELARY_KEY_CRED } from '../../lib/credentialStore'; import { isDesktopShell } from '../../lib/platform'; @@ -180,9 +182,10 @@ export interface UiSlice { /** Reference for the "Align" section toggle; see AlignSelectionRef. Transient. */ alignRef: AlignSelectionRef; printerSettingsTab: PrinterSettingsTab | null; - /** Cross-component trigger for the "send to Zebra" dialog. `null` - * keeps the dialog closed. */ - zebraPrintSource: 'label' | 'setupScript' | null; + /** Cross-component trigger for the output dialog. `null` keeps it closed. */ + outputSource: OutputSource | null; + /** Session memory only, so a restart begins neutral. */ + outputChoice: OutputChoice; /** Barcode object id whose GS1 content builder modal is open; null = closed. */ gs1BuilderObjectId: string | null; /** Object id whose typed-content builder modal is open; null = closed. @@ -250,8 +253,11 @@ export interface UiSlice { endRfidPositionPick: () => void; setAlignRef: (ref: AlignSelectionRef) => void; setPrinterSettingsTab: (tab: PrinterSettingsTab | null) => void; - openZebraPrint: (source: 'label' | 'setupScript') => void; - closeZebraPrint: () => void; + openOutput: (source: OutputSource) => void; + closeOutput: () => void; + setOutputChoice: (patch: Partial) => void; + /** The one write of the chosen way: the sentinel and the print target cannot disagree about it. */ + chooseOutputWay: (way: PrintWay) => void; openGs1Builder: (objectId: string) => void; closeGs1Builder: () => void; openContentBuilder: (objectId: string) => void; @@ -340,7 +346,8 @@ export const createUiSlice: StateCreator = (set, ge pickingRfidPosition: false, alignRef: 'selection', printerSettingsTab: null, - zebraPrintSource: null, + outputSource: null, + outputChoice: defaultOutputChoice(isDesktopShell), gs1BuilderObjectId: null, contentBuilderObjectId: null, variableBuilderObjectId: null, @@ -522,8 +529,14 @@ export const createUiSlice: StateCreator = (set, ge // Opening or closing the dialog ends a pick: its capture layer must never // outlive the flow that started it. setPrinterSettingsTab: (tab) => set({ printerSettingsTab: tab, pickingRfidPosition: false }), - openZebraPrint: (source) => set({ zebraPrintSource: source }), - closeZebraPrint: () => set({ zebraPrintSource: null }), + openOutput: (source) => set({ outputSource: source }), + closeOutput: () => set({ outputSource: null }), + setOutputChoice: (patch) => set((state) => ({ outputChoice: { ...state.outputChoice, ...patch } })), + chooseOutputWay: (way) => + set((state) => ({ + outputChoice: { ...state.outputChoice, printWay: way === "system" ? "system" : null }, + printTarget: way === "system" ? state.printTarget : { ...state.printTarget, transport: way }, + })), openGs1Builder: (objectId) => set({ gs1BuilderObjectId: objectId }), closeGs1Builder: () => set({ gs1BuilderObjectId: null }), openContentBuilder: (objectId) => set({ contentBuilderObjectId: objectId }), diff --git a/src/test/privacyMenuPaths.test.ts b/src/test/privacyMenuPaths.test.ts new file mode 100644 index 00000000..961d6e90 --- /dev/null +++ b/src/test/privacyMenuPaths.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from "vitest"; +import { readFileSync } from "node:fs"; +import { fallbackTranslations as en } from "../locales"; + +/** The policy sends users to a menu entry, so a renamed entry must not leave a dead path behind. */ +describe("the privacy policy's menu paths", () => { + it("names the output entry by the heading the menu uses", () => { + const policy = readFileSync("PRIVACY.md", "utf8"); + + expect(policy).toContain(`**File → ${en.zebraPrint.outputHeading}**`); + expect(policy).not.toContain(`**File → ${en.zebraPrint.kindPrint}**`); + }); +}); diff --git a/src/test/zebraPrint.test.ts b/src/test/zebraPrint.test.ts index 5e656af4..a4bdc7cb 100644 --- a/src/test/zebraPrint.test.ts +++ b/src/test/zebraPrint.test.ts @@ -6,7 +6,6 @@ import { sendViaNetwork, type BrowserPrintDevice, } from "../lib/zebraPrint"; -import { buildPrintHtml, buildLoadingHtml } from "../lib/printPreview"; // ── helpers ────────────────────────────────────────────────────────────────── @@ -174,26 +173,3 @@ describe("sendViaNetwork", () => { }); }); }); - -// ── printPreview pure functions ─────────────────────────────────────────────── - -describe("buildPrintHtml", () => { - it("embeds the image URL as src", () => { - const html = buildPrintHtml("blob:http://localhost/abc"); - expect(html).toContain('src="blob:http://localhost/abc"'); - }); - - it("triggers window.print and window.close on image load", () => { - const html = buildPrintHtml("blob:x"); - expect(html).toContain("window.print()"); - expect(html).toContain("window.close()"); - }); -}); - -describe("buildLoadingHtml", () => { - it("returns a non-empty HTML string with loading indicator", () => { - const html = buildLoadingHtml(); - expect(html).toContain("