diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 000000000..48b889858 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,4 @@ +# Changelog + +## Unreleased +- Add filament label printing with separate presets, QR codes, and filament QR scanning support. diff --git a/client/package-lock.json b/client/package-lock.json index b19647a12..4efe04a31 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -24,6 +24,7 @@ "i18next": "^25.7.3", "i18next-browser-languagedetector": "^8.2.0", "i18next-http-backend": "^3.0.2", + "jszip": "3.10.1", "react": "^19.2.3", "react-dnd": "^16.0.1", "react-dnd-html5-backend": "^16.0.1", @@ -6206,6 +6207,12 @@ "url": "https://opencollective.com/core-js" } }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, "node_modules/cosmiconfig": { "version": "8.3.6", "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-8.3.6.tgz", @@ -8656,6 +8663,12 @@ "node": ">= 4" } }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -8698,7 +8711,6 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true, "license": "ISC" }, "node_modules/ini": { @@ -9626,6 +9638,54 @@ "node": ">=4.0" } }, + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, + "node_modules/jszip/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/jszip/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/jszip/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/jszip/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/kbar": { "version": "0.1.0-beta.40", "resolved": "https://registry.npmjs.org/kbar/-/kbar-0.1.0-beta.40.tgz", @@ -9687,6 +9747,15 @@ "node": ">= 0.8.0" } }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, "node_modules/lines-and-columns": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", @@ -10857,6 +10926,12 @@ "dev": true, "license": "MIT" }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, "node_modules/papaparse": { "version": "5.5.3", "resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.5.3.tgz", @@ -11316,6 +11391,12 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, "node_modules/promise-inflight": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/promise-inflight/-/promise-inflight-1.0.1.tgz", @@ -13036,6 +13117,12 @@ "node": ">= 0.4" } }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "license": "MIT" + }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", @@ -14352,7 +14439,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "dev": true, "license": "MIT" }, "node_modules/utils-merge": { diff --git a/client/package.json b/client/package.json index 1603ff246..4088c8b9e 100644 --- a/client/package.json +++ b/client/package.json @@ -23,6 +23,7 @@ "i18next": "^25.7.3", "i18next-browser-languagedetector": "^8.2.0", "i18next-http-backend": "^3.0.2", + "jszip": "3.10.1", "react": "^19.2.3", "react-dnd": "^16.0.1", "react-dnd-html5-backend": "^16.0.1", @@ -39,19 +40,19 @@ "@refinedev/cli": "^2.16.50", "@types/loadable__component": "^5.13.10", "@types/node": "^25.0.3", - "@types/react-dom": "^19.2.3", "@types/react": "^19.2.7", + "@types/react-dom": "^19.2.3", "@types/uuid": "^10.0.0", "@vitejs/plugin-react": "^5.1.2", + "eslint": "^9.39.2", "eslint-config-prettier": "^10.1.8", + "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.4.26", - "eslint-plugin-react": "^7.37.5", - "eslint": "^9.39.2", "globals": "^17.0.0", "prettier": "3.7.4", - "typescript-eslint": "^8.52.0", "typescript": "^5.9.3", + "typescript-eslint": "^8.52.0", "vite": "^7.3.0", "vite-plugin-mkcert": "^1.17.9", "vite-plugin-pwa": "^1.2.0" diff --git a/client/public/locales/en/common.json b/client/public/locales/en/common.json index 88ff2ae85..b14318acc 100644 --- a/client/public/locales/en/common.json +++ b/client/public/locales/en/common.json @@ -65,11 +65,21 @@ "helpMargin": "Margins should be configured to match your label paper and printer, changing these will affect the size of the entire grid.", "helpPrinterMargin": "Safe-Zone should be set to how close to the paper edge your printer can print, changing these will not affect the entire grid.", "print": "Print", + "exportLabels": "Export Labels", "columns": "Columns", "rows": "Rows", "paperSize": "Paper Size", "customSize": "Custom", "dimensions": "Dimensions", + "amlLabelSize": "AML Label Size", + "exportFormat": "Export Format", + "exportFormatOptions": { + "png": "PNG", + "aml": "AML" + }, + "exportAsZip": "Export as .zip", + "exportDpi": "Export DPI", + "exportDpiHelp": "Higher DPI makes sharper PNG/AML exports but increases file size and export time.", "showBorder": "Show Border", "previewScale": "Preview Scale", "skipItems": "Skip Items", @@ -92,6 +102,10 @@ "grid": "Grid" }, "settings": "Presets", + "spoolPrintPresets": "Spool Print Presets", + "filamentPrintPresets": "Filament Print Presets", + "spoolImagePresets": "Spool Image Presets", + "filamentImagePresets": "Filament Image Presets", "defaultSettings": "Default", "addSettings": "Add New Preset", "newSetting": "New", @@ -100,13 +114,25 @@ "deleteSettingsConfirm": "Are you sure you want to delete this preset?", "settingsName": "Preset Name", "saveSetting": "Save Presets", - "saveAsImage": "Save as Image" + "saveAsImage": "Save as Image", + "saveAsAmlLabels": "Save as AML (Labels)" }, "qrcode": { "button": "Print Labels", + "exportButton": "Export Labels", + "exportFilamentTitle": "Export Filament Labels", + "exportSpoolTitle": "Export Spool Labels", + "printFilamentTitle": "Print Filament Labels", + "printSpoolTitle": "Print Spool Labels", + "selectButton": "Export/Print", + "selectTitle": "Export / Print Labels", "title": "Label Printing", "template": "Label Template", + "filenameTemplate": "Filename Template", + "filenameTemplateTooltipSpool": "Use {} to insert values of the spool object as text. Refer to the label template rules and available tags for more details.", + "filenameTemplateTooltipFilament": "Use {} to insert values of the filament object as text. Refer to the label template rules and available tags for more details.", "templateHelp": "Use {} to insert values of the spool object as text. For example, {id} will be replaced with the spool id, or {filament.material} will be replaced with the material of the spool. if a value is missing it will be replaced with \"?\". A second set of {} can be used to remove this. In addition, any text between the sets of {} will be removed if the value is missing. For example, {Lot Nr: {lot_nr}} will only show the label if the spool has a lot number. Enclose text with double asterix ** to make it bold. Click the button to view a list of all available tags.", + "templateHelpFilament": "Use {} to insert values of the filament object as text. For example, {id} will be replaced with the filament id, or {vendor.name} will be replaced with the vendor name. If a value is missing it will be replaced with \"?\". A second set of {} can be used to remove this. In addition, any text between the sets of {} will be removed if the value is missing. For example, {Article: {article_number}} will only show the label if a filament has an article number. Enclose text with double asterix ** to make it bold. Click the button to view a list of all available tags.", "textSize": "Label Text Size", "showContent": "Print Label", "useHTTPUrl": { @@ -127,12 +153,22 @@ }, "spoolSelect": { "title": "Select Spools", - "description": "Select spools to print labels for.", + "description": "Select spools to export or print labels for.", + "searchPlaceholder": "Search vendor, filament, material, location, lot #", "showArchived": "Show Archived", "noSpoolsSelected": "You have not selected any spools.", "selectAll": "Select/Unselect All", "selectedTotal_one": "{{count}} spool selected", "selectedTotal_other": "{{count}} spools selected" + }, + "filamentSelect": { + "title": "Select Filaments", + "description": "Select filaments to export or print labels for.", + "searchPlaceholder": "Search vendor, name, material, article #", + "noFilamentsSelected": "You have not selected any filaments.", + "selectAll": "Select/Unselect All", + "selectedTotal_one": "{{count}} filament selected", + "selectedTotal_other": "{{count}} filaments selected" } }, "scanner": { diff --git a/client/src/App.tsx b/client/src/App.tsx index d907b8ee1..24d3ef298 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -194,7 +194,9 @@ function App() { /> } /> } /> + } /> } /> + } /> } /> @@ -208,6 +210,9 @@ function App() { /> } /> } /> + } /> + } /> + } /> } /> diff --git a/client/src/components/otherModels.tsx b/client/src/components/otherModels.tsx index 0adabd80c..6255d00bf 100644 --- a/client/src/components/otherModels.tsx +++ b/client/src/components/otherModels.tsx @@ -5,6 +5,32 @@ import { IFilament } from "../pages/filaments/model"; import { IVendor } from "../pages/vendors/model"; import { getAPIURL } from "../utils/url"; +/** + * Factory function to create a reusable query hook for fetching and sorting string arrays from API endpoints. + * @param queryKey - Unique cache key for react-query + * @param endpoint - API endpoint to fetch from + * @param enabled - Whether the query should be enabled + */ +function useSimpleSortedArrayQuery(queryKey: string[], endpoint: string, enabled: boolean = false) { + return useQuery({ + enabled, + queryKey, + queryFn: async () => { + const response = await fetch(getAPIURL() + endpoint); + if (!response.ok) { + throw new Error(`Failed to fetch from ${endpoint}: ${response.statusText}`); + } + return response.json(); + }, + select: (data) => { + if (Array.isArray(data)) { + return [...data].sort(); + } + return []; + }, + }); +} + export function useSpoolmanFilamentFilter(enabled: boolean = false) { return useQuery({ enabled: enabled, @@ -135,69 +161,17 @@ export function useSpoolmanVendors(enabled: boolean = false) { } export function useSpoolmanMaterials(enabled: boolean = false) { - return useQuery({ - enabled: enabled, - queryKey: ["materials"], - queryFn: async () => { - const response = await fetch(getAPIURL() + "/material"); - if (!response.ok) { - throw new Error("Network response was not ok"); - } - return response.json(); - }, - select: (data) => { - return data.sort(); - }, - }); + return useSimpleSortedArrayQuery(["materials"], "/material", enabled); } export function useSpoolmanArticleNumbers(enabled: boolean = false) { - return useQuery({ - enabled: enabled, - queryKey: ["articleNumbers"], - queryFn: async () => { - const response = await fetch(getAPIURL() + "/article-number"); - if (!response.ok) { - throw new Error("Network response was not ok"); - } - return response.json(); - }, - select: (data) => { - return data.sort(); - }, - }); + return useSimpleSortedArrayQuery(["articleNumbers"], "/article-number", enabled); } export function useSpoolmanLotNumbers(enabled: boolean = false) { - return useQuery({ - enabled: enabled, - queryKey: ["lotNumbers"], - queryFn: async () => { - const response = await fetch(getAPIURL() + "/lot-number"); - if (!response.ok) { - throw new Error("Network response was not ok"); - } - return response.json(); - }, - select: (data) => { - return data.sort(); - }, - }); + return useSimpleSortedArrayQuery(["lotNumbers"], "/lot-number", enabled); } export function useSpoolmanLocations(enabled: boolean = false) { - return useQuery({ - enabled: enabled, - queryKey: ["locations"], - queryFn: async () => { - const response = await fetch(getAPIURL() + "/location"); - if (!response.ok) { - throw new Error("Network response was not ok"); - } - return response.json(); - }, - select: (data) => { - return data.sort(); - }, - }); + return useSimpleSortedArrayQuery(["locations"], "/location", enabled); } diff --git a/client/src/components/qrCodeScanner.tsx b/client/src/components/qrCodeScanner.tsx index ecf97eafb..4aaa92525 100644 --- a/client/src/components/qrCodeScanner.tsx +++ b/client/src/components/qrCodeScanner.tsx @@ -17,16 +17,30 @@ const QRCodeScannerModal = () => { } const result = detectedCodes[0].rawValue; - // Check for the spoolman ID format - const match = result.match(/^web\+spoolman:s-(?[0-9]+)$/i); - if (match && match.groups) { + // Accept both compact WEB+SPOOLMAN payloads and full show-page URLs so + // exported and printed labels keep scanning after base URL changes. + const spoolMatch = result.match(/^web\+spoolman:s-(?[0-9]+)$/i); + if (spoolMatch && spoolMatch.groups) { setVisible(false); - navigate(`/spool/show/${match.groups.id}`); + navigate(`/spool/show/${spoolMatch.groups.id}`); + return; + } + const filamentMatch = result.match(/^web\+spoolman:f-(?[0-9]+)$/i); + if (filamentMatch && filamentMatch.groups) { + setVisible(false); + navigate(`/filament/show/${filamentMatch.groups.id}`); + return; + } + const spoolURLmatch = result.match(/^https?:\/\/[^/]+(?:\/[^/]+)*\/spool\/show\/(?[0-9]+)$/i); + if (spoolURLmatch && spoolURLmatch.groups) { + setVisible(false); + navigate(`/spool/show/${spoolURLmatch.groups.id}`); + return; } - const fullURLmatch = result.match(/^https?:\/\/[^/]+\/spool\/show\/(?[0-9]+)$/i); - if (fullURLmatch && fullURLmatch.groups) { + const filamentURLmatch = result.match(/^https?:\/\/[^/]+(?:\/[^/]+)*\/filament\/show\/(?[0-9]+)$/i); + if (filamentURLmatch && filamentURLmatch.groups) { setVisible(false); - navigate(`/spool/show/${fullURLmatch.groups.id}`); + navigate(`/filament/show/${filamentURLmatch.groups.id}`); } }; @@ -43,6 +57,8 @@ const QRCodeScannerModal = () => { onScan={onScan} formats={["qr_code"]} onError={(err: unknown) => { + // Map browser/scanner-library failures onto translated messages instead of + // exposing raw exception names in the modal. const error = err as Error; console.error(error); if (error.name === "NotAllowedError") { diff --git a/client/src/pages/filamentExport/index.tsx b/client/src/pages/filamentExport/index.tsx new file mode 100644 index 000000000..c12fbfd59 --- /dev/null +++ b/client/src/pages/filamentExport/index.tsx @@ -0,0 +1,69 @@ +import { PageHeader } from "@refinedev/antd"; +import { useTranslate } from "@refinedev/core"; +import { theme } from "antd"; +import { Content } from "antd/es/layout/layout"; +import { useEffect, useMemo } from "react"; +import { useNavigate, useSearchParams } from "react-router"; +import FilamentQRCodeExportDialog from "../printing/filamentQrCodeExportDialog"; + +const { useToken } = theme; + +export const FilamentExport = () => { + const { token } = useToken(); + const t = useTranslate(); + const [searchParams] = useSearchParams(); + const navigate = useNavigate(); + + const filamentIds = searchParams.getAll("filaments").map(Number); + const returnUrl = searchParams.get("return"); + const selectionPath = useMemo(() => { + const params = new URLSearchParams(); + if (returnUrl) { + params.set("return", returnUrl); + } + const query = params.toString(); + return `/filament/labels${query ? `?${query}` : ""}`; + }, [returnUrl]); + + useEffect(() => { + if (filamentIds.length === 0) { + navigate(selectionPath, { replace: true }); + } + }, [filamentIds.length, navigate, selectionPath]); + + return ( + <> + { + const returnUrl = searchParams.get("return"); + if (returnUrl) { + navigate(returnUrl, { relative: "path" }); + } else { + navigate("/filament"); + } + }} + > + + {filamentIds.length > 0 && } + + + + ); +}; + +export default FilamentExport; diff --git a/client/src/pages/filamentLabels/index.tsx b/client/src/pages/filamentLabels/index.tsx new file mode 100644 index 000000000..e86f1daf3 --- /dev/null +++ b/client/src/pages/filamentLabels/index.tsx @@ -0,0 +1,80 @@ +import { PageHeader } from "@refinedev/antd"; +import { useTranslate } from "@refinedev/core"; +import { theme } from "antd"; +import { Content } from "antd/es/layout/layout"; +import { useMemo } from "react"; +import { useNavigate, useSearchParams } from "react-router"; +import FilamentSelectModal from "../printing/filamentSelectModal"; + +const { useToken } = theme; + +export const FilamentLabels = () => { + const { token } = useToken(); + const t = useTranslate(); + const [searchParams] = useSearchParams(); + const navigate = useNavigate(); + + const returnUrl = searchParams.get("return"); + const initialSelectedIds = searchParams + .getAll("filaments") + .map(Number) + .filter((id) => !Number.isNaN(id)); + + const selectionPath = useMemo(() => { + const params = new URLSearchParams(); + if (returnUrl) { + params.set("return", returnUrl); + } + const query = params.toString(); + return `/filament/labels${query ? `?${query}` : ""}`; + }, [returnUrl]); + + const handleNavigate = (mode: "print" | "export", ids: number[]) => { + const params = new URLSearchParams(); + ids.forEach((id) => params.append("filaments", id.toString())); + params.set("return", selectionPath); + navigate(`/filament/${mode}?${params.toString()}`); + }; + + return ( + <> + { + if (returnUrl) { + navigate(returnUrl, { relative: "path" }); + } else { + navigate("/filament"); + } + }} + > + + handleNavigate("print", ids)} + onExport={(ids) => handleNavigate("export", ids)} + /> + + + + ); +}; + +export default FilamentLabels; diff --git a/client/src/pages/filamentPrinting/index.tsx b/client/src/pages/filamentPrinting/index.tsx new file mode 100644 index 000000000..f9a88e6f9 --- /dev/null +++ b/client/src/pages/filamentPrinting/index.tsx @@ -0,0 +1,67 @@ +import { PageHeader } from "@refinedev/antd"; +import { useTranslate } from "@refinedev/core"; +import { theme } from "antd"; +import { Content } from "antd/es/layout/layout"; +import { useEffect, useMemo } from "react"; +import { useNavigate, useSearchParams } from "react-router"; +import FilamentQRCodePrintingDialog from "../printing/filamentQrCodePrintingDialog"; + +const { useToken } = theme; + +export const FilamentPrinting = () => { + const { token } = useToken(); + const t = useTranslate(); + const [searchParams] = useSearchParams(); + const navigate = useNavigate(); + + const filamentIds = searchParams.getAll("filaments").map(Number); + const returnUrl = searchParams.get("return"); + const selectionPath = useMemo(() => { + const params = new URLSearchParams(); + if (returnUrl) { + params.set("return", returnUrl); + } + const query = params.toString(); + return `/filament/labels${query ? `?${query}` : ""}`; + }, [returnUrl]); + + useEffect(() => { + if (filamentIds.length === 0) { + navigate(selectionPath, { replace: true }); + } + }, [filamentIds.length, navigate, selectionPath]); + + return ( + <> + { + const returnUrl = searchParams.get("return"); + if (returnUrl) { + navigate(returnUrl, { relative: "path" }); + } else { + navigate("/filament"); + } + }} + > + + {filamentIds.length > 0 && } + + + + ); +}; + +export default FilamentPrinting; diff --git a/client/src/pages/filaments/functions.ts b/client/src/pages/filaments/functions.ts index e19d5b3db..2187e0c7b 100644 --- a/client/src/pages/filaments/functions.ts +++ b/client/src/pages/filaments/functions.ts @@ -1,3 +1,4 @@ +import { useQueries } from "@tanstack/react-query"; import { ExternalFilament } from "../../utils/queryExternalDB"; import { getAPIURL } from "../../utils/url"; import { getOrCreateVendorFromExternal } from "../vendors/functions"; @@ -48,3 +49,24 @@ export async function createFilamentFromExternal(externalFilament: ExternalFilam } return response.json(); } + +/** + * Returns an array of queries using the useQueries hook from @tanstack/react-query. + * Each query fetches a filament by its ID from the server. + * + * @param {number[]} ids - An array of filament IDs to fetch. + * @return An array of query results, each containing the fetched filament data. + */ +export function useGetFilamentsByIds(ids: number[]) { + return useQueries({ + queries: ids.map((id) => { + return { + queryKey: ["filament", id], + queryFn: async () => { + const res = await fetch(getAPIURL() + "/filament/" + id); + return (await res.json()) as IFilament; + }, + }; + }), + }); +} diff --git a/client/src/pages/filaments/list.tsx b/client/src/pages/filaments/list.tsx index 2d42198ce..a44f8ecb6 100644 --- a/client/src/pages/filaments/list.tsx +++ b/client/src/pages/filaments/list.tsx @@ -1,4 +1,11 @@ -import { EditOutlined, EyeOutlined, FileOutlined, FilterOutlined, PlusSquareOutlined } from "@ant-design/icons"; +import { + EditOutlined, + EyeOutlined, + FileOutlined, + FilterOutlined, + PlusSquareOutlined, + PrinterOutlined, +} from "@ant-design/icons"; import { List, useTable } from "@refinedev/antd"; import { useInvalidate, useNavigation, useTranslate } from "@refinedev/core"; import { Button, Dropdown, Table } from "antd"; @@ -169,6 +176,15 @@ export const FilamentList = () => { ( <> + + {defaultButtons} )} diff --git a/client/src/pages/printing/exportDialog.tsx b/client/src/pages/printing/exportDialog.tsx new file mode 100644 index 000000000..3bfc248e5 --- /dev/null +++ b/client/src/pages/printing/exportDialog.tsx @@ -0,0 +1,1010 @@ +import { DownloadOutlined } from "@ant-design/icons"; +import { useTranslate } from "@refinedev/core"; +import { Button, Checkbox, Col, Collapse, Divider, Form, InputNumber, Radio, Row, Slider, Space } from "antd"; +import * as htmlToImage from "html-to-image"; +import JSZip from "jszip"; +import { ReactElement, useContext, useRef } from "react"; +import { ColorModeContext } from "../../contexts/color-mode"; +import { useSavedState } from "../../utils/saveload"; +import { PrintSettings } from "./printing"; + +interface ExportDialogProps { + items: ReactElement[]; + printSettings: PrintSettings; + setPrintSettings: (setPrintSettings: PrintSettings) => void; + style?: string; + extraSettings?: ReactElement; + extraSettingsStart?: ReactElement; + extraFormatSettings?: ReactElement; + extraButtons?: ReactElement; + zipFileTypeName: string; +} + +// PNG binary utilities — module-level so they are not recreated on every render. +// Used exclusively by setPngDpiMetadata to inject a pHYs DPI chunk into exported PNGs. + +// Standard PNG file signature (first 8 bytes of every valid PNG file). +const pngSignature = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]); + +const readUint32BE = (bytes: Uint8Array, offset: number) => { + return ( + ((bytes[offset] << 24) >>> 0) | + ((bytes[offset + 1] << 16) >>> 0) | + ((bytes[offset + 2] << 8) >>> 0) | + (bytes[offset + 3] >>> 0) + ); +}; + +const writeUint32BE = (target: Uint8Array, offset: number, value: number) => { + target[offset] = (value >>> 24) & 0xff; + target[offset + 1] = (value >>> 16) & 0xff; + target[offset + 2] = (value >>> 8) & 0xff; + target[offset + 3] = value & 0xff; +}; + +const isPng = (bytes: Uint8Array) => { + if (bytes.length < pngSignature.length) { + return false; + } + for (let i = 0; i < pngSignature.length; i += 1) { + if (bytes[i] !== pngSignature[i]) { + return false; + } + } + return true; +}; + +const getChunkType = (bytes: Uint8Array, offset: number) => { + return String.fromCharCode(bytes[offset], bytes[offset + 1], bytes[offset + 2], bytes[offset + 3]); +}; + +// Standard CRC-32 lookup table (IEEE 802.3 polynomial 0xEDB88320, reflected). +// Computed once at module scope; each pHYs chunk write reuses this table. +const CRC32_TABLE = (() => { + const table = new Uint32Array(256); + for (let n = 0; n < 256; n += 1) { + let c = n; + for (let k = 0; k < 8; k += 1) { + c = (c & 1) !== 0 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; + } + table[n] = c >>> 0; + } + return table; +})(); + +const crc32 = (bytes: Uint8Array) => { + let crc = 0xffffffff; + for (let i = 0; i < bytes.length; i += 1) { + crc = CRC32_TABLE[(crc ^ bytes[i]) & 0xff] ^ (crc >>> 8); + } + return (crc ^ 0xffffffff) >>> 0; +}; + +const createPngChunk = (chunkType: string, data: Uint8Array) => { + const typeBytes = new Uint8Array([ + chunkType.charCodeAt(0), + chunkType.charCodeAt(1), + chunkType.charCodeAt(2), + chunkType.charCodeAt(3), + ]); + const crcInput = new Uint8Array(typeBytes.length + data.length); + crcInput.set(typeBytes, 0); + crcInput.set(data, typeBytes.length); + + const chunk = new Uint8Array(12 + data.length); + writeUint32BE(chunk, 0, data.length); + chunk.set(typeBytes, 4); + chunk.set(data, 8); + writeUint32BE(chunk, 8 + data.length, crc32(crcInput)); + return chunk; +}; + +// Rewrites the exported PNG blob to inject a pHYs chunk with the target DPI. +// html-to-image produces correct pixel dimensions but no DPI metadata; this +// chunk tells image viewers and print software the intended resolution. +const setPngDpiMetadata = async (pngBlob: Blob, dpi: number) => { + const bytes = new Uint8Array(await pngBlob.arrayBuffer()); + if (!isPng(bytes)) { + return pngBlob; + } + + // html-to-image gives us correct pixel dimensions, but many image viewers still report + // 72 DPI unless we write an explicit pHYs chunk into the final PNG bytes. + const pixelsPerMeter = Math.max(1, Math.round(dpi / 0.0254)); + const physData = new Uint8Array(9); + writeUint32BE(physData, 0, pixelsPerMeter); + writeUint32BE(physData, 4, pixelsPerMeter); + physData[8] = 1; + const physChunk = createPngChunk("pHYs", physData); + + const chunks: Uint8Array[] = [bytes.slice(0, 8)]; + let offset = 8; + let insertedPhys = false; + let removedExistingPhys = false; + + while (offset + 8 <= bytes.length) { + const chunkLength = readUint32BE(bytes, offset); + const chunkTotalSize = 12 + chunkLength; + if (offset + chunkTotalSize > bytes.length) { + return pngBlob; + } + + const chunkType = getChunkType(bytes, offset + 4); + const fullChunk = bytes.slice(offset, offset + chunkTotalSize); + + if (chunkType === "pHYs") { + removedExistingPhys = true; + offset += chunkTotalSize; + continue; + } + + if (!insertedPhys && chunkType === "IHDR") { + chunks.push(fullChunk); + chunks.push(physChunk); + insertedPhys = true; + } else if (!insertedPhys && chunkType === "IDAT") { + chunks.push(physChunk); + chunks.push(fullChunk); + insertedPhys = true; + } else if (!insertedPhys && chunkType === "IEND") { + chunks.push(physChunk); + chunks.push(fullChunk); + insertedPhys = true; + } else { + chunks.push(fullChunk); + } + + offset += chunkTotalSize; + } + + if (!insertedPhys && !removedExistingPhys) { + return pngBlob; + } + + const blobParts: BlobPart[] = chunks.map((chunk) => chunk as unknown as BlobPart); + return new Blob(blobParts, { type: "image/png" }); +}; + +// Render one preview page per exported label and reuse that DOM for PNG/AML generation +// so the preview stays the source of truth for both single-file and ZIP exports. +const ExportDialog = ({ + items, + printSettings, + setPrintSettings, + style, + extraSettings, + extraSettingsStart, + extraFormatSettings, + extraButtons, + zipFileTypeName, +}: ExportDialogProps) => { + const t = useTranslate(); + const { mode } = useContext(ColorModeContext); + + const [collapseState, setCollapseState] = useSavedState("export-collapseState", []); + const [previewScale, setPreviewScale] = useSavedState("export-previewScale", 0.7); + + const margin = printSettings?.margin || { top: 0, bottom: 0, left: 0, right: 0 }; + const printerMargin = printSettings?.printerMargin || { top: 0, bottom: 0, left: 0, right: 0 }; + const customPaperSize = printSettings?.customPaperSize || { width: 40, height: 30 }; + const exportDpi = printSettings?.exportDpi || 300; + const exportFormat = printSettings?.exportFormat || "aml"; + const exportAsZip = printSettings?.exportAsZip ?? false; + const zipPreviewName = `${exportFormat.toUpperCase()} ${zipFileTypeName} labels.zip`; + const previewMetaColor = mode === "dark" ? "#bfbfbf" : "#333"; + + const paperWidth = customPaperSize.width; + const paperHeight = customPaperSize.height; + const itemWidth = Math.max(paperWidth - margin.left - margin.right, 0); + const itemHeight = Math.max(paperHeight - margin.top - margin.bottom, 0); + + const contentRef = useRef(null); + + const sanitizeFilename = (value: string) => { + const trimmed = value.trim(); + if (trimmed === "") { + return ""; + } + return ( + trimmed + // eslint-disable-next-line no-control-regex + .replace(/[<>:"/\\|?*\x00-\x1F]/g, "-") + .replace(/\s+/g, " ") + .replace(/\.+$/g, "") + ); + }; + + // Exports deliberately stay one-label-per-page so preview names, AML payloads, + // and downloaded files all map 1:1 to a logical label. + const pageBlocks: ReactElement[][] = []; + for (const item of items) { + pageBlocks.push([item]); + } + + const pages = pageBlocks.map(function (pageItems, pageIdx) { + const pagePreviewSource = pageItems[0]; + const pageRawPreviewName = + (pagePreviewSource?.props as { "data-aml-name"?: string } | undefined)?.["data-aml-name"] ?? + `label-${pageIdx + 1}`; + const pagePreviewName = sanitizeFilename(pageRawPreviewName) || `label-${pageIdx + 1}`; + + const itemDivs = pageItems.map((item, itemIdx) => { + return ( +
+ {item} +
+ ); + }); + + return ( +
+
+
+
+ {itemDivs} +
+
+
+ {/* Keep preview filenames outside the white label canvas so they stay readable in the + UI without becoming part of the exported PNG/AML image content. */} +
+ {pagePreviewName}.{exportFormat} +
+
+ ); + }); + + const getPrintItems = () => { + const root = contentRef.current ?? document; + return Array.from(root.getElementsByClassName("print-qrcode-item")); + }; + + const downloadTextFile = (filename: string, content: string, mimeType: string) => { + const blob = new Blob([content], { type: mimeType }); + const link = document.createElement("a"); + link.href = URL.createObjectURL(blob); + link.download = filename; + link.click(); + URL.revokeObjectURL(link.href); + }; + + const downloadBlobFile = (filename: string, blob: Blob) => { + const link = document.createElement("a"); + link.href = URL.createObjectURL(blob); + link.download = filename; + link.click(); + URL.revokeObjectURL(link.href); + }; + + const blobToDataUrl = async (blob: Blob) => { + return await new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => { + resolve(String(reader.result ?? "")); + }; + reader.onerror = () => { + reject(reader.error ?? new Error("Failed to convert blob to data URL")); + }; + reader.readAsDataURL(blob); + }); + }; + + const escapeXml = (value: string) => { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); + }; + + const buildAmlPageXml = (width: number, height: number, base64Png: string) => { + const id = Math.floor(Math.random() * 2 ** 31); + const objectId = Math.floor(Math.random() * 2 ** 31); + return ` + 0 + 0 + 0 + 0 + 1 + #000000 + 0 + + 0 + ${base64Png} + ${height.toFixed(3)} + ${width.toFixed(3)} + 0.000 + 0.000 + 0.000000 + 0 + 0 + 0.7055555449591742 + #000000 + ${id} + ${objectId} + 0 + 0 + 1 + 0 + 0 + 0 + + 0 + 0 + `; + }; + + const buildAmlXml = (name: string, widthMm: number, heightMm: number, base64Pages: string[]) => { + const width = Number.isFinite(widthMm) ? widthMm : 0; + const height = Number.isFinite(heightMm) ? heightMm : 0; + const validBoundsWidth = Math.max(width - 2, 0); + const validBoundsHeight = Math.max(height - 2, 0); + const widthIn = width / 25.4; + const heightIn = height / 25.4; + + return ` + + ${escapeXml(name)} + Custom Label + 0 + ${height.toFixed(3)} + ${width.toFixed(3)} + 1 + 1 + ${validBoundsWidth.toFixed(0)} + ${validBoundsHeight.toFixed(0)} + 0 + #ffffff + #000000 + ${width.toFixed(2)}mm * ${height.toFixed(2)}mm + ${widthIn.toFixed(3)}inch * ${heightIn.toFixed(3)}inch + 0 + 0 + 0 + 0 + 0 + 0 + 0 + Custom + ${width.toFixed(1)} * ${height.toFixed(1)} mm + ${widthIn.toFixed(2)} * ${heightIn.toFixed(2)} in + + ${base64Pages.map((base64Png) => buildAmlPageXml(width, height, base64Png)).join("\n")} + + +`; + }; + + const getExportImageOptions = () => { + const exportPixelRatio = Math.max(1, Math.min(exportDpi / 96, 10)); + return { + backgroundColor: "#FFF", + cacheBust: true, + pixelRatio: exportPixelRatio, + }; + }; + + const getUniqueExportItems = () => { + const hasPrinted: Element[] = []; + const itemsToPrint = getPrintItems(); + const usedNames = new Set(); + const uniqueItems: { item: Element; safeName: string }[] = []; + let idx = 1; + + for (const item of itemsToPrint) { + // Prevent printing copies + let isDuplicate = false; + for (let i = 0; i < hasPrinted.length; i += 1) { + if (item.isEqualNode(hasPrinted[i])) { + isDuplicate = true; + break; + } + } + if (isDuplicate) { + continue; + } + hasPrinted.push(item); + + // Export one file per logical label. Duplicate DOM nodes exist only because the print + // preview can render copies on the sheet, but file exports should not clone names/files. + const rawName = (item as HTMLElement).dataset.amlName || `label-${idx}`; + const baseName = sanitizeFilename(rawName) || `label-${idx}`; + let safeName = baseName; + let nameSuffix = 1; + while (usedNames.has(safeName)) { + safeName = `${baseName}${String(nameSuffix).padStart(2, "0")}`; + nameSuffix += 1; + } + usedNames.add(safeName); + uniqueItems.push({ item, safeName }); + idx += 1; + } + return uniqueItems; + }; + + const saveAsImage = async () => { + const uniqueItems = getUniqueExportItems(); + for (const { item, safeName } of uniqueItems) { + const url = await htmlToImage.toPng(item as HTMLElement, getExportImageOptions()); + const response = await fetch(url); + const blob = await response.blob(); + const pngBlob = await setPngDpiMetadata(blob, exportDpi); + downloadBlobFile(`${safeName}.png`, pngBlob); + } + }; + + const saveAsAmlLabels = async () => { + const uniqueItems = getUniqueExportItems(); + for (const { item, safeName } of uniqueItems) { + const url = await htmlToImage.toPng(item as HTMLElement, getExportImageOptions()); + const response = await fetch(url); + const blob = await response.blob(); + const pngBlob = await setPngDpiMetadata(blob, exportDpi); + const dataUrl = await blobToDataUrl(pngBlob); + const base64 = dataUrl.split(",")[1] ?? ""; + const aml = buildAmlXml(safeName, paperWidth, paperHeight, [base64]); + downloadTextFile(`${safeName}.aml`, aml, "application/xml"); + } + }; + + const saveAsZip = async () => { + const uniqueItems = getUniqueExportItems(); + if (uniqueItems.length === 0) { + return; + } + + // ZIP exports reuse the same per-label rendering path so single-file and batch + // downloads stay consistent apart from the outer archive wrapper. + const zip = new JSZip(); + for (const { item, safeName } of uniqueItems) { + const url = await htmlToImage.toPng(item as HTMLElement, getExportImageOptions()); + if (exportFormat === "png") { + const response = await fetch(url); + const blob = await response.blob(); + const pngBlob = await setPngDpiMetadata(blob, exportDpi); + zip.file(`${safeName}.png`, pngBlob); + } else { + const response = await fetch(url); + const blob = await response.blob(); + const pngBlob = await setPngDpiMetadata(blob, exportDpi); + const dataUrl = await blobToDataUrl(pngBlob); + const base64 = dataUrl.split(",")[1] ?? ""; + const aml = buildAmlXml(safeName, paperWidth, paperHeight, [base64]); + zip.file(`${safeName}.aml`, aml); + } + } + + const blob = await zip.generateAsync({ type: "blob" }); + downloadBlobFile(zipPreviewName, blob); + }; + + const handleExport = async () => { + if (exportAsZip) { + await saveAsZip(); + return; + } + + if (exportFormat === "png") { + await saveAsImage(); + return; + } + + await saveAsAmlLabels(); + }; + + return ( + <> + + +
+
+ + {exportAsZip &&
{zipPreviewName}
} + {pages} +
+
+ + +
+
+ {extraSettingsStart} + + +
+ { + printSettings.exportFormat = e.target.value; + setPrintSettings(printSettings); + }} + value={exportFormat} + optionType="button" + buttonStyle="solid" + /> + { + printSettings.exportAsZip = event.target.checked; + setPrintSettings(printSettings); + }} + > + {t("printing.generic.exportAsZip")} + +
+
+ {extraFormatSettings} + + + + `${value} dpi` }} + value={exportDpi} + onChange={(value) => { + printSettings.exportDpi = value; + setPrintSettings(printSettings); + }} + /> + + + { + printSettings.exportDpi = value ?? 300; + setPrintSettings(printSettings); + }} + /> + + + + + + + { + setPreviewScale(value); + }} + /> + + + { + setPreviewScale(value ?? 0.1); + }} + /> + + + + + { + if (Array.isArray(key)) { + setCollapseState(key); + } + }} + > + + {extraSettings} + + + + + + { + customPaperSize.width = value ?? 0; + printSettings.customPaperSize = customPaperSize; + setPrintSettings(printSettings); + }} + /> + + + x + + + { + customPaperSize.height = value ?? 0; + printSettings.customPaperSize = customPaperSize; + setPrintSettings(printSettings); + }} + /> + + + + +

{t("printing.generic.helpMargin")}

+ + + + `${value} mm` }} + value={margin.left} + onChange={(value) => { + margin.left = value; + printSettings.margin = margin; + setPrintSettings(printSettings); + }} + /> + + + { + margin.left = value ?? 0; + printSettings.margin = margin; + setPrintSettings(printSettings); + }} + /> + + + + + + + `${value} mm` }} + value={margin.top} + onChange={(value) => { + margin.top = value; + printSettings.margin = margin; + setPrintSettings(printSettings); + }} + /> + + + { + margin.top = value ?? 0; + printSettings.margin = margin; + setPrintSettings(printSettings); + }} + /> + + + + + + + `${value} mm` }} + value={margin.right} + onChange={(value) => { + margin.right = value; + printSettings.margin = margin; + setPrintSettings(printSettings); + }} + /> + + + { + margin.right = value ?? 0; + printSettings.margin = margin; + setPrintSettings(printSettings); + }} + /> + + + + + + + `${value} mm` }} + value={margin.bottom} + onChange={(value) => { + margin.bottom = value; + printSettings.margin = margin; + setPrintSettings(printSettings); + }} + /> + + + { + margin.bottom = value ?? 0; + printSettings.margin = margin; + setPrintSettings(printSettings); + }} + /> + + + + +

{t("printing.generic.helpPrinterMargin")}

+ + + + `${value} mm` }} + value={printerMargin.left} + onChange={(value) => { + printerMargin.left = value; + printSettings.printerMargin = printerMargin; + setPrintSettings(printSettings); + }} + /> + + + { + printerMargin.left = value ?? 0; + printSettings.printerMargin = printerMargin; + setPrintSettings(printSettings); + }} + /> + + + + + + + `${value} mm` }} + value={printerMargin.top} + onChange={(value) => { + printerMargin.top = value; + printSettings.printerMargin = printerMargin; + setPrintSettings(printSettings); + }} + /> + + + { + printerMargin.top = value ?? 0; + printSettings.printerMargin = printerMargin; + setPrintSettings(printSettings); + }} + /> + + + + + + + `${value} mm` }} + value={printerMargin.right} + onChange={(value) => { + printerMargin.right = value; + printSettings.printerMargin = printerMargin; + setPrintSettings(printSettings); + }} + /> + + + { + printerMargin.right = value ?? 0; + printSettings.printerMargin = printerMargin; + setPrintSettings(printSettings); + }} + /> + + + + + + + `${value} mm` }} + value={printerMargin.bottom} + onChange={(value) => { + printerMargin.bottom = value; + printSettings.printerMargin = printerMargin; + setPrintSettings(printSettings); + }} + /> + + + { + printerMargin.bottom = value ?? 0; + printSettings.printerMargin = printerMargin; + setPrintSettings(printSettings); + }} + /> + + + +
+
+ +
+ +
+ + + + {extraButtons} + + + + + + ); +}; + +export default ExportDialog; diff --git a/client/src/pages/printing/filamentQrCodeExportDialog.tsx b/client/src/pages/printing/filamentQrCodeExportDialog.tsx new file mode 100644 index 000000000..480d88cb9 --- /dev/null +++ b/client/src/pages/printing/filamentQrCodeExportDialog.tsx @@ -0,0 +1,361 @@ +import { CopyOutlined, DeleteOutlined, PlusOutlined, SaveOutlined } from "@ant-design/icons"; +import { useTranslate } from "@refinedev/core"; +import { Button, Flex, Form, Input, Modal, Popconfirm, Select, Table, Typography, message } from "antd"; +import TextArea from "antd/es/input/TextArea"; +import { useState } from "react"; +import { v4 as uuidv4 } from "uuid"; +import { EntityType, useGetFields } from "../../utils/queryFields"; +import { useGetSetting } from "../../utils/querySettings"; +import { useSavedState } from "../../utils/saveload"; +import { useGetFilamentsByIds } from "../filaments/functions"; +import { IFilament } from "../filaments/model"; +import { + getConfiguredBaseUrl, + SpoolQRCodePrintSettings, + renderLabelContents, + renderTemplateText, + useGetPrintSettings as useGetPrintPresets, + useSetPrintSettings as useSetPrintPresets, +} from "./printing"; +import QRCodeExportDialog from "./qrCodeExportDialog"; + +const { Text } = Typography; + +interface FilamentQRCodeExportDialogProps { + filamentIds: number[]; +} + +// Adapt filament records into the generic QR export dialog and keep export-only +// preset fields isolated from the simpler print-only filament presets. +const FilamentQRCodeExportDialog = ({ filamentIds }: FilamentQRCodeExportDialogProps) => { + const t = useTranslate(); + const baseUrlSetting = useGetSetting("base_url"); + // Accept both JSON-backed settings and legacy plain strings so old `base_url` values do not crash the dialog. + const baseUrlRoot = getConfiguredBaseUrl(baseUrlSetting.data?.value, window.location.origin); + const [messageApi, contextHolder] = message.useMessage(); + const [useHTTPUrl, setUseHTTPUrl] = useSavedState("export-useHTTPUrl-filament", false); + + const itemQueries = useGetFilamentsByIds(filamentIds); + const items = itemQueries + .map((itemQuery) => { + return itemQuery.data ?? null; + }) + .filter((item) => item !== null) as IFilament[]; + + const [selectedPresetState, setSelectedPresetState] = useSavedState( + "selectedImagePresetFilament", + undefined, + ); + + const [localPresets, setLocalPresets] = useState(); + // Export presets stay in their own bucket so filename/DPI/export-format choices do not + // mutate the simpler print-only presets used by the non-export dialog. + const remotePresets = useGetPrintPresets("image_presets_filament"); + const setRemotePresets = useSetPrintPresets("image_presets_filament"); + + const localOrRemotePresets = localPresets ?? remotePresets; + + const savePresetsRemote = () => { + if (!localPresets) return; + setRemotePresets.mutate(localPresets); + }; + + const addNewPreset = () => { + if (!localOrRemotePresets) return; + const newId = uuidv4(); + const newPreset = { + labelSettings: { + printSettings: { + id: newId, + name: t("printing.generic.newSetting"), + }, + }, + }; + setLocalPresets([...localOrRemotePresets, newPreset]); + setSelectedPresetState(newId); + return newPreset; + }; + const duplicateCurrentPreset = () => { + if (!localOrRemotePresets) return; + const newPreset = { + ...curPreset, + labelSettings: { ...curPreset.labelSettings, printSettings: { ...curPreset.labelSettings.printSettings } }, + }; + newPreset.labelSettings.printSettings.id = uuidv4(); + setLocalPresets([...localOrRemotePresets, newPreset]); + setSelectedPresetState(newPreset.labelSettings.printSettings.id); + }; + const updateCurrentPreset = (newSettings: SpoolQRCodePrintSettings) => { + if (!localOrRemotePresets) return; + setLocalPresets( + localOrRemotePresets.map((presets) => + presets.labelSettings.printSettings.id === newSettings.labelSettings.printSettings.id ? newSettings : presets, + ), + ); + }; + const deleteCurrentPreset = () => { + if (!localOrRemotePresets) return; + setLocalPresets( + localOrRemotePresets.filter((qPreset) => qPreset.labelSettings.printSettings.id !== selectedPresetState), + ); + setSelectedPresetState(undefined); + }; + + let curPreset: SpoolQRCodePrintSettings; + if (localOrRemotePresets === undefined) { + curPreset = { + labelSettings: { + printSettings: { + id: "TEMP", + name: t("printing.generic.newSetting"), + }, + }, + }; + } else { + if (localOrRemotePresets.length === 0) { + // First-time export users should land in a usable preset immediately instead of an + // empty export dialog with no selected settings object to edit. + const newSetting = addNewPreset(); + if (!newSetting) { + console.error("Error adding new setting, this should never happen"); + return; + } + localOrRemotePresets.push(newSetting); + curPreset = newSetting; + } else { + if (!selectedPresetState) { + curPreset = localOrRemotePresets[0]; + setSelectedPresetState(localOrRemotePresets[0].labelSettings.printSettings.id); + } else { + const foundSetting = localOrRemotePresets.find( + (settings) => settings.labelSettings.printSettings.id === selectedPresetState, + ); + if (foundSetting) { + curPreset = foundSetting; + } else { + // Recover to the first saved preset when the remembered selection no longer exists. + curPreset = localOrRemotePresets[0]; + setSelectedPresetState(localOrRemotePresets[0].labelSettings.printSettings.id); + } + } + } + } + + const [templateHelpOpen, setTemplateHelpOpen] = useState(false); + const template = + curPreset.template ?? + `**{vendor.name} - {name} +#{id} - {material}** +{Diameter: {diameter} mm} +{Weight: {weight} g} +{Spool Weight: {spool_weight} g} +{ET: {settings_extruder_temp} °C} +{BT: {settings_bed_temp} °C} +{Article: {article_number}} +{{comment}} +{comment} +{vendor.comment}`; + const filenameTemplate = curPreset.filenameTemplate ?? `{vendor.name}-{material}-{name}`; + + const filamentTags = [ + { tag: "id" }, + { tag: "registered" }, + { tag: "name" }, + { tag: "material" }, + { tag: "price" }, + { tag: "density" }, + { tag: "diameter" }, + { tag: "weight" }, + { tag: "spool_weight" }, + { tag: "article_number" }, + { tag: "comment" }, + { tag: "settings_extruder_temp" }, + { tag: "settings_bed_temp" }, + { tag: "color_hex" }, + { tag: "multi_color_hexes" }, + { tag: "multi_color_direction" }, + { tag: "external_id" }, + ]; + const filamentFields = useGetFields(EntityType.filament); + if (filamentFields.data !== undefined) { + filamentFields.data.forEach((field) => { + filamentTags.push({ tag: `extra.${field.key}` }); + }); + } + const vendorTags = [ + { tag: "vendor.id" }, + { tag: "vendor.registered" }, + { tag: "vendor.name" }, + { tag: "vendor.comment" }, + { tag: "vendor.empty_spool_weight" }, + { tag: "vendor.external_id" }, + ]; + const vendorFields = useGetFields(EntityType.vendor); + if (vendorFields.data !== undefined) { + vendorFields.data.forEach((field) => { + vendorTags.push({ tag: `vendor.extra.${field.key}` }); + }); + } + + // Expose both filament and vendor placeholders because the same tag picker drives + // label text and export filename templates. + const templateTags = [...filamentTags, ...vendorTags]; + + return ( + <> + {contextHolder} + { + updateCurrentPreset({ ...curPreset, labelSettings: newSettings }); + }} + baseUrlRoot={baseUrlRoot} + useHTTPUrl={useHTTPUrl} + setUseHTTPUrl={setUseHTTPUrl} + previewValues={{ + default: "WEB+SPOOLMAN:F-{id}", + url: `${baseUrlRoot}/filament/show/{id}`, + }} + zipFileTypeName="filament" + extraSettingsStart={ + <> + + + +