diff --git a/.playwright-cli/page-2026-03-14T13-03-08-306Z.yml b/.playwright-cli/page-2026-03-14T13-03-08-306Z.yml new file mode 100644 index 000000000..e69de29bb diff --git a/.playwright-cli/page-2026-03-14T13-04-28-164Z.yml b/.playwright-cli/page-2026-03-14T13-04-28-164Z.yml new file mode 100644 index 000000000..d944002d6 --- /dev/null +++ b/.playwright-cli/page-2026-03-14T13-04-28-164Z.yml @@ -0,0 +1 @@ +- generic [ref=e2]: loading \ No newline at end of file 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..d4331c386 100644 --- a/client/public/locales/en/common.json +++ b/client/public/locales/en/common.json @@ -70,6 +70,7 @@ "paperSize": "Paper Size", "customSize": "Custom", "dimensions": "Dimensions", + "amlLabelSize": "AML Label Size", "showBorder": "Show Border", "previewScale": "Preview Scale", "skipItems": "Skip Items", @@ -100,15 +101,76 @@ "deleteSettingsConfirm": "Are you sure you want to delete this preset?", "settingsName": "Preset Name", "saveSetting": "Save Presets", - "saveAsImage": "Save as Image" + "savePreset": "Save Preset", + "saveAsImage": "Save as Image", + "saveAsAmlLabels": "Save as AML (Labels)", + "saveAsAmlPages": "Save as AML (Pages)", + "spoolImagePresets": "Spool Image Presets", + "filamentImagePresets": "Filament Image Presets", + "exportFormat": "Export Format", + "exportFormatOptions": { + "png": "PNG", + "aml": "AML" + }, + "exportAsZip": "Export as .zip", + "exportDpi": "Export DPI", + "exportDpiHelp": "Higher DPI improves image quality for exports but increases file size.", + "exportLabels": "Export Labels", + "filenamePreview": "Filename Preview", + "filenamePreviewAdditional": "Additional Example", + "zipFilenamePreview": "ZIP Filename" }, "qrcode": { "button": "Print Labels", + "selectButton": "Print / Export Labels", + "exportButton": "Export Labels", + "selectTitle": "Export / Print Labels", + "exportFilamentTitle": "Export Filament Labels", + "exportSpoolTitle": "Export Spool Labels", + "printFilamentTitle": "Print Filament Labels", + "printSpoolTitle": "Print Spool Labels", "title": "Label Printing", + "sectionLogo": "Logo", + "sectionTitle": "Title", + "sectionQRCode": "QR Code", + "sectionInformation": "Information", "template": "Label Template", - "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.", + "titleTemplate": "Title Template", + "infoTemplate": "Information Template", + "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, and use ==text== to invert text. 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, and use ==text== to invert text. Click the button to view a list of all available tags.", "textSize": "Label Text Size", - "showContent": "Print Label", + "infoTextSize": "Information Text Size", + "titleTextSize": "Title Text Size", + "titleMaxTextSize": "Title Max Text Size", + "titleAreaHeight": "Title Area Height", + "showContent": "Show Information on label", + "showManufacturerLogo": "Show Manufacturer Logo", + "logoSource": "Logo Source", + "logoSourceOptions": { + "print": "Print Logo", + "color": "Color Logo" + }, + "showTitle": "Show Title", + "titleFitToWidth": "Fit Title to Width", + "titleFitToWidthOptions": { + "on": "On", + "off": "Off" + }, + "appliedTextSize": "Applied Size", + "logoSize": "Logo Size", + "logoAlign": "Logo Horizontal Align", + "titleAlign": "Title Horizontal Align", + "qrCodePosition": "QR Code Position", + "qrCodePositionLeft": "Left", + "qrCodePositionRight": "Right", + "qrCodeAlign": "QR Code Vertical Align", + "qrCodeAlignTop": "Top", + "qrCodeAlignCenter": "Center", + "qrCodeAlignBottom": "Bottom", + "qrCodeSize": "QR Code Size", + "infoAlign": "Information Horizontal Align", + "infoVerticalAlign": "Information Vertical Align", "useHTTPUrl": { "label": "QR code link", "tooltip": "Will use proprietary link that will work only if scanned from Spoolman's scanning feature (default). URL uses either the base URL specified in settings, or the current page URL if not set.", @@ -123,16 +185,31 @@ "no": "No", "simple": "Simple", "withIcon": "With Icon" - } + }, + "filenameTemplate": "Filename Template", + "filenameTemplateTooltipSpool": "Use {} to insert values of the spool object as text. Refer to the label template rules and available tags for details.", + "filenameTemplateTooltipFilament": "Use {} to insert values of the filament object as text. Refer to the label template rules and available tags for details.", + "titleTemplateTooltipSpool": "Use {} to insert values of the spool object as text. Refer to the label template rules and available tags for details.", + "titleTemplateTooltipFilament": "Use {} to insert values of the filament object as text. Refer to the label template rules and available tags for details." }, "spoolSelect": { "title": "Select Spools", "description": "Select spools to print labels for.", + "searchPlaceholder": "Search vendor, name, material, 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 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": { @@ -269,14 +346,27 @@ "vendor": "Manufacturers", "fields": { "id": "ID", + "logo": "Logo", + "logo_url": "Logo URL", + "print_logo_url": "Print Logo URL", + "logo_preview": "Logo Preview", + "print_logo_preview": "Print Logo Preview", "name": "Name", "empty_spool_weight": "Empty Spool Weight", "external_id": "External ID", "registered": "Registered", - "comment": "Comment" + "comment": "Comment", + "logo_suggestions": "Logo Suggestions", + "print_logo_suggestions": "Print Logo Suggestions", + "logo_suggestions_placeholder": "Select a suggested logo path", + "logo_suggestions_none": "None" }, "fields_help": { - "empty_spool_weight": "The weight of an empty spool from this manufacturer." + "empty_spool_weight": "The weight of an empty spool from this manufacturer.", + "logo_url": "Optional custom logo used in the UI. Supports absolute URLs or local paths like /vendor-logos/web/bambu-lab-web.png. Use any image your browser can display from that path or URL.", + "print_logo_url": "Optional custom logo used for label rendering. Supports absolute URLs or local paths like /vendor-logos/print/bambu-lab.png. Use any image your browser can display from that path or URL.", + "logo_suggestions": "Checks available logo files in /vendor-logos/web for names similar to this manufacturer.", + "print_logo_suggestions": "Checks available logo files in /vendor-logos/print for names similar to this manufacturer." }, "titles": { "create": "Create Manufacturer", @@ -286,8 +376,27 @@ "show": "Show Manufacturer", "show_title": "[Manufacturer #{{id}}] {{name}}" }, + "buttons": { + "sync_logos": "Sync Logos", + "clear_logo_url": "Clear URL", + "convert_logo_to_print": "Convert Logo to Print", + "convert_logo_to_print_help": "Creates a black-and-white print logo from the current Logo URL and stores it as a separate local print logo file.", + "convert_logo_to_print_help_locked": "A Print Logo URL is already set. Clear Print Logo URL to enable creating a new print logo from the current Logo URL." + }, + "logo_filter": { + "has_logo": "Has Logo", + "no_logo": "No Logo" + }, "form": { - "vendor_updated": "This manufacturer has been updated by someone/something else since you opened this page. Saving will overwrite those changes!" + "vendor_updated": "This manufacturer has been updated by someone/something else since you opened this page. Saving will overwrite those changes!", + "logo_sync_no_match": "No matching logos found for this manufacturer name.", + "logo_sync_applied": "Suggested logo paths applied.", + "logo_preview_auto_notice": "using auto-matched logo from available logo files", + "logo_preview_default_notice": "no logo defined, using default generated text logo", + "logo_convert_requires_web_logo": "Set a Logo URL first.", + "logo_convert_requires_empty_print_logo": "Clear Print Logo URL first to generate a new print logo.", + "logo_convert_success": "Generated print logo from web logo.", + "logo_convert_error": "Could not generate a print logo from this Logo URL." } }, "home": { @@ -322,6 +431,16 @@ "round_prices": { "label": "Round prices", "tooltip": "Round prices to the nearest whole number." + }, + "logo_sync": { + "title": "Global Manufacturer Logo Sync", + "description": "Syncs missing logo URLs across the whole manufacturer database using the bundled logo manifest. Existing logo URLs are not overwritten.", + "where": "Source: /vendor-logos/manifest.json (bundled static web/print logo files). Matching ignores minor punctuation/case differences in names.", + "button": "Sync Logos Now", + "not_ready": "Logo manifest is not available yet.", + "load_error": "Could not load manufacturers for logo sync.", + "done": "Logo sync complete. Matched {{matched}}, updated {{updated}}.", + "scope_note": "This action syncs the whole manufacturer database. You can sync a single manufacturer from its Edit page." } }, "extra_fields": { 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..68e43bbaa 100644 --- a/client/src/components/otherModels.tsx +++ b/client/src/components/otherModels.tsx @@ -3,7 +3,7 @@ import { Tooltip } from "antd"; import { ColumnFilterItem } from "antd/es/table/interface"; import { IFilament } from "../pages/filaments/model"; import { IVendor } from "../pages/vendors/model"; -import { getAPIURL } from "../utils/url"; +import { getAPIURL, getBasePath } from "../utils/url"; export function useSpoolmanFilamentFilter(enabled: boolean = false) { return useQuery({ @@ -134,6 +134,29 @@ export function useSpoolmanVendors(enabled: boolean = false) { }); } +export function useSpoolmanVendorExternalIds(enabled: boolean = false) { + return useQuery({ + enabled: enabled, + queryKey: ["vendorExternalIds"], + queryFn: async () => { + const response = await fetch(getAPIURL() + "/vendor"); + if (!response.ok) { + throw new Error("Network response was not ok"); + } + return response.json(); + }, + select: (data) => { + const externalIds = data + .map((vendor) => vendor.external_id) + .filter( + (externalId): externalId is string => externalId !== null && externalId !== undefined && externalId !== "", + ) + .sort(); + return [...new Set(externalIds)]; + }, + }); +} + export function useSpoolmanMaterials(enabled: boolean = false) { return useQuery({ enabled: enabled, @@ -201,3 +224,22 @@ export function useSpoolmanLocations(enabled: boolean = false) { }, }); } + +interface VendorLogoManifest { + web_files?: string[]; + print_files?: string[]; +} + +export function useVendorLogoManifest(enabled: boolean = true) { + return useQuery({ + enabled, + queryKey: ["vendor-logo-manifest"], + queryFn: async () => { + const response = await fetch(`${getBasePath()}/vendor-logos/manifest.json`, { cache: "no-store" }); + if (!response.ok) { + return { web_files: [], print_files: [] }; + } + return response.json(); + }, + }); +} diff --git a/client/src/components/qrCodeScanner.tsx b/client/src/components/qrCodeScanner.tsx index ecf97eafb..e683a7812 100644 --- a/client/src/components/qrCodeScanner.tsx +++ b/client/src/components/qrCodeScanner.tsx @@ -18,15 +18,28 @@ 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) { + 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}`); } }; diff --git a/client/src/components/vendorLogo.tsx b/client/src/components/vendorLogo.tsx new file mode 100644 index 000000000..dfeab916c --- /dev/null +++ b/client/src/components/vendorLogo.tsx @@ -0,0 +1,53 @@ +import { CSSProperties, useEffect, useMemo, useState } from "react"; +import { IVendor } from "../pages/vendors/model"; +import { getVendorLogoCandidates } from "../utils/vendorLogo"; + +interface VendorLogoProps { + vendor?: IVendor; + usePrintLogo?: boolean; + showFallbackText?: boolean; + imgStyle?: CSSProperties; + fallbackStyle?: CSSProperties; +} + +// Walk the vendor's candidate logo URLs in order, then optionally fall back to plain vendor text. +export function VendorLogo({ + vendor, + usePrintLogo = false, + showFallbackText = false, + imgStyle, + fallbackStyle, +}: VendorLogoProps) { + const candidates = useMemo(() => getVendorLogoCandidates(vendor, usePrintLogo), [vendor, usePrintLogo]); + const [currentCandidateIndex, setCurrentCandidateIndex] = useState(0); + useEffect(() => { + // Reset to the highest-priority candidate whenever the vendor or logo mode changes. + setCurrentCandidateIndex(0); + }, [vendor, usePrintLogo]); + + const currentSrc = candidates[currentCandidateIndex]; + const fallbackText = vendor?.name ?? ""; + + if (currentSrc) { + return ( + {vendor?.name { + // Try the next candidate before giving up so a stale saved path can still fall + // back to the local runtime logo filenames inferred from the vendor name. + setCurrentCandidateIndex((idx) => idx + 1); + }} + /> + ); + } + + if (showFallbackText && fallbackText) { + return
{fallbackText}
; + } + + return null; +} + +export default VendorLogo; 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} )} > - {t("filament.fields.id")} - - {t("filament.fields.vendor")} - - {t("filament.fields.registered")} - - {t("filament.fields.name")} - - {t("filament.fields.color_hex")} - {colorObj && } - {record?.color_hex && } - {t("filament.fields.material")} - - {t("filament.fields.price")} - - {t("filament.fields.density")} - - {t("filament.fields.diameter")} - - {t("filament.fields.weight")} - - {t("filament.fields.spool_weight")} - - {t("filament.fields.settings_extruder_temp")} - {!record?.settings_extruder_temp ? ( - - ) : ( - - )} - {t("filament.fields.settings_bed_temp")} - {!record?.settings_bed_temp ? ( - - ) : ( - - )} - {t("filament.fields.article_number")} - - {t("filament.fields.external_id")} - - {t("filament.fields.comment")} - - {t("settings.extra_fields.tab")} - {extraFields?.data?.map((field, index) => ( - - ))} + + + {t("filament.fields.id")} + + {t("filament.fields.registered")} + + {t("filament.fields.name")} + + {t("filament.fields.color_hex")} + {colorObj && } + {record?.color_hex && } + {t("filament.fields.material")} + + {t("filament.fields.price")} + + {t("filament.fields.density")} + + {t("filament.fields.diameter")} + + {t("filament.fields.weight")} + + {t("filament.fields.spool_weight")} + + {t("filament.fields.settings_extruder_temp")} + {!record?.settings_extruder_temp ? ( + + ) : ( + + )} + {t("filament.fields.settings_bed_temp")} + {!record?.settings_bed_temp ? ( + + ) : ( + + )} + {t("filament.fields.article_number")} + + {t("filament.fields.external_id")} + + {t("filament.fields.comment")} + + {t("settings.extra_fields.tab")} + {extraFields?.data?.map((field, index) => ( + + ))} + + + {t("filament.fields.vendor")} + + {record?.vendor && ( +
+ +
+ )} + +
); }; 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..876af2db1 --- /dev/null +++ b/client/src/pages/printing/filamentQrCodeExportDialog.tsx @@ -0,0 +1,378 @@ +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(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 titleTemplate = curPreset.titleTemplate ?? `==**{name}**== {color_hex}`; + const infoTemplate = + 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={ + <> + + + +