From 1861cbb18f750f1bb2677ffd70b6dcfc286a8c85 Mon Sep 17 00:00:00 2001 From: akira69 Date: Mon, 23 Feb 2026 23:11:40 -0600 Subject: [PATCH 01/11] feat(printing): add filament label printing foundation --- client/public/locales/en/common.json | 9 + client/src/App.tsx | 1 + client/src/components/qrCodeScanner.tsx | 25 +- client/src/pages/filamentPrinting/index.tsx | 71 ++++ client/src/pages/filaments/functions.ts | 22 ++ client/src/pages/filaments/list.tsx | 18 +- client/src/pages/filaments/show.tsx | 15 + .../printing/filamentQrCodePrintingDialog.tsx | 339 ++++++++++++++++++ .../pages/printing/filamentSelectModal.tsx | 187 ++++++++++ client/src/pages/printing/printing.tsx | 17 +- client/src/pages/printing/printingDialog.tsx | 23 +- .../pages/printing/qrCodePrintingDialog.tsx | 6 +- .../printing/spoolQrCodePrintingDialog.tsx | 4 + spoolman/settings.py | 1 + 14 files changed, 718 insertions(+), 20 deletions(-) create mode 100644 client/src/pages/filamentPrinting/index.tsx create mode 100644 client/src/pages/printing/filamentQrCodePrintingDialog.tsx create mode 100644 client/src/pages/printing/filamentSelectModal.tsx diff --git a/client/public/locales/en/common.json b/client/public/locales/en/common.json index 88ff2ae85..2e86234af 100644 --- a/client/public/locales/en/common.json +++ b/client/public/locales/en/common.json @@ -107,6 +107,7 @@ "title": "Label Printing", "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.", + "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": { @@ -133,6 +134,14 @@ "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.", + "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..696437e8d 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -208,6 +208,7 @@ function App() { /> } /> } /> + } /> } /> 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/pages/filamentPrinting/index.tsx b/client/src/pages/filamentPrinting/index.tsx new file mode 100644 index 000000000..368d2dc20 --- /dev/null +++ b/client/src/pages/filamentPrinting/index.tsx @@ -0,0 +1,71 @@ +import { PageHeader } from "@refinedev/antd"; +import { useTranslate } from "@refinedev/core"; +import { theme } from "antd"; +import { Content } from "antd/es/layout/layout"; +import dayjs from "dayjs"; +import utc from "dayjs/plugin/utc"; +import { useNavigate, useSearchParams } from "react-router"; +import FilamentQRCodePrintingDialog from "../printing/filamentQrCodePrintingDialog"; +import FilamentSelectModal from "../printing/filamentSelectModal"; + +dayjs.extend(utc); + +const { useToken } = theme; + +export const FilamentPrinting = () => { + const { token } = useToken(); + const t = useTranslate(); + const [searchParams, setSearchParams] = useSearchParams(); + const navigate = useNavigate(); + + const filamentIds = searchParams.getAll("filaments").map(Number); + const step = filamentIds.length > 0 ? 1 : 0; + + return ( + <> + { + const returnUrl = searchParams.get("return"); + if (returnUrl) { + navigate(returnUrl, { relative: "path" }); + } else { + navigate("/filament"); + } + }} + > + + {step === 0 && ( + { + setSearchParams((prev) => { + const newParams = new URLSearchParams(prev); + newParams.delete("filaments"); + filaments.forEach((filament) => newParams.append("filaments", filament.id.toString())); + newParams.set("return", "/filament/print"); + return newParams; + }); + }} + /> + )} + {step === 1 && } + + + + ); +}; + +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..e49c531fe 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/filamentQrCodePrintingDialog.tsx b/client/src/pages/printing/filamentQrCodePrintingDialog.tsx new file mode 100644 index 000000000..2128ba949 --- /dev/null +++ b/client/src/pages/printing/filamentQrCodePrintingDialog.tsx @@ -0,0 +1,339 @@ +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 { + SpoolQRCodePrintSettings, + renderLabelContents, + useGetPrintSettings as useGetPrintPresets, + useSetPrintSettings as useSetPrintPresets, +} from "./printing"; +import QRCodePrintingDialog from "./qrCodePrintingDialog"; + +const { Text } = Typography; + +interface FilamentQRCodePrintingDialogProps { + filamentIds: number[]; +} + +const FilamentQRCodePrintingDialog = ({ filamentIds }: FilamentQRCodePrintingDialogProps) => { + const t = useTranslate(); + const baseUrlSetting = useGetSetting("base_url"); + const baseUrlRoot = + baseUrlSetting.data?.value !== undefined && JSON.parse(baseUrlSetting.data?.value) !== "" + ? JSON.parse(baseUrlSetting.data?.value) + : window.location.origin; + const [messageApi, contextHolder] = message.useMessage(); + const [useHTTPUrl, setUseHTTPUrl] = useSavedState("print-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( + "selectedPresetFilament", + undefined, + ); + + const [localPresets, setLocalPresets] = useState(); + const remotePresets = useGetPrintPresets("print_presets_filament"); + const setRemotePresets = useSetPrintPresets("print_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) { + 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 { + curPreset = { + labelSettings: { + printSettings: { + id: "TEMP", + name: t("printing.generic.newSetting"), + }, + }, + }; + } + } + } + } + + 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 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}` }); + }); + } + + const templateTags = [...filamentTags, ...vendorTags]; + + return ( + <> + {contextHolder} + { + curPreset.labelSettings = newSettings; + updateCurrentPreset(curPreset); + }} + baseUrlRoot={baseUrlRoot} + useHTTPUrl={useHTTPUrl} + setUseHTTPUrl={setUseHTTPUrl} + previewValues={{ + default: "WEB+SPOOLMAN:F-{id}", + url: `${baseUrlRoot}/filament/show/{id}`, + }} + extraSettingsStart={ + <> + + + +