Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 58 additions & 2 deletions client/public/locales/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,26 @@
"button": "Print Labels",
"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.",
"templateHelp": "Use {} to insert spool values and optional |modifiers for datetime or numeric formatting. Use Available Tags... to copy ready-to-paste values, or open Detailed template help for examples. Enclose text with double asterisks ** to make it bold.",
"templateTagButton": "Available Tags...",
"templateHelpLink": "Detailed template help",
"templateTagDialog": {
"title": "Template Tags and Modifiers",
"copyHint": "Click a tag or modifier to copy a complete template value and close.",
"hoverHint": "Hover a tag or modifier to preview the copied value.",
"hoverCopyPrefix": "Copy",
"hoverCopySuffix": "to clipboard",
"copySuccess": "Copied {{value}}",
"copyError": "Unable to copy. Copy manually.",
"columns": {
"tag": "Tag",
"modifiers": "Available |Modifiers"
},
"modifiers": {
"none": "None",
"dateOrderPrefix": "Date-order suffixes:"
}
},
"textSize": "Label Text Size",
"showContent": "Print Label",
"useHTTPUrl": {
Expand Down Expand Up @@ -302,7 +321,44 @@
"spool": "Individual physical spools of a specific filament.",
"vendor": "The companies that make the filament."
},
"description": "<title>Help</title><p>Here are some tips to get you started.</p><p>Spoolman holds 3 different types of data:</p><itemsHelp/><p>To add a new spool to the database, start by creating a <filamentCreateLink>Filament</filamentCreateLink> object. Once that's done, proceed to create a <spoolCreateLink>Spool</spoolCreateLink> object for that specific spool. If you acquire additional spools of the same filament later on, simply generate additional Spool objects and reuse the same Filament object.</p><p>Optionally, you can also generate a <vendorCreateLink>Manufacturer</vendorCreateLink> object for the company manufacturing the filament if you wish to track that information.</p><p>You have the option to link other 3D printer services to Spoolman, like Moonraker, which can automatically monitor filament usage and update the Spool objects for you. Refer to the <readmeLink>Spoolman README</readmeLink> for guidance on how to set that up.</p>"
"description": "<title>Help</title><p>Here are some tips to get you started.</p><p>Spoolman holds 3 different types of data:</p><itemsHelp/><p>To add a new spool to the database, start by creating a <filamentCreateLink>Filament</filamentCreateLink> object. Once that's done, proceed to create a <spoolCreateLink>Spool</spoolCreateLink> object for that specific spool. If you acquire additional spools of the same filament later on, simply generate additional Spool objects and reuse the same Filament object.</p><p>Optionally, you can also generate a <vendorCreateLink>Manufacturer</vendorCreateLink> object for the company manufacturing the filament if you wish to track that information.</p><p>You have the option to link other 3D printer services to Spoolman, like Moonraker, which can automatically monitor filament usage and update the Spool objects for you. Refer to the <readmeLink>Spoolman README</readmeLink> for guidance on how to set that up.</p>",
"templateSyntax": {
"title": "Template Syntax",
"intro": "Template syntax is used for label text today and is intended to be reusable anywhere Spoolman accepts tag-based templates.",
"basicsHeading": "Basics",
"basicsInsertBefore": "Wrap a tag in {} to insert a value, such as",
"basicsInsertMiddle": "or",
"basicsInsertAfter": "Missing values resolve to",
"basicsCopyBefore": "In spool label printing, use the",
"basicsCopyAfter": "to copy ready-to-paste values like",
"basicsMissingBefore": "Use a second set of braces to hide missing values. For example,",
"basicsMissingAfter": "removes the placeholder entirely if the value does not exist.",
"basicsConditionalBefore": "You can also make surrounding text conditional. For example,",
"basicsConditionalAfter": "only renders when the inner tag has a value.",
"basicsBoldBefore": "Wrap text in",
"basicsBoldAfter": "to render it in bold on the label.",
"datetimeHeading": "Datetime Modifiers",
"datetimeSupportedBefore": "Datetime tags can be formatted by appending a modifier after |. Supported modifiers are",
"datetimeSupportedAfter": ".",
"datetimeOrderBefore": "Date-based modifiers can also define output order with",
"datetimeOrderAfter": ". Use",
"datetimeLocal": "variants when you want the rendered value converted to the browser's local time zone.",
"numberHeading": "Number Modifiers",
"numberSupportedBefore": "Numeric tags can also be formatted by appending a modifier after |. Supported modifiers are",
"numberSupportedAfter": ".",
"numberPurpose": "Use these when a raw value has more precision than you want on a label, such as weight fields that need to fit in limited space.",
"examplesHeading": "Examples",
"examplesDatetimeBefore": "If the stored datetime is 2026-03-04T18:27:53Z, then",
"examplesDatetimeDate": "renders as",
"examplesDatetimeDmy": "renders as",
"examplesDatetimeTime": "renders as",
"examplesDatetimeLocal": "renders in the browser's local time zone.",
"examplesNumberBefore": "If a numeric value is 1234.56789, then",
"examplesNumberRound": "renders as",
"examplesNumberFixed1": "renders as",
"examplesNumberFixed2": "renders as",
"and": "and"
}
},
"table": {
"actions": "Actions"
Expand Down
111 changes: 109 additions & 2 deletions client/src/pages/help/index.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,52 @@
import { FileOutlined, HighlightOutlined, UserOutlined } from "@ant-design/icons";
import { useTranslate } from "@refinedev/core";
import { List, theme } from "antd";
import { Divider, List, Typography, theme } from "antd";
import { Content } from "antd/es/layout/layout";
import Title from "antd/es/typography/Title";
import dayjs from "dayjs";
import utc from "dayjs/plugin/utc";
import { useEffect } from "react";
import { Trans } from "react-i18next";
import { Link } from "react-router";
import { Link, useLocation } from "react-router";

dayjs.extend(utc);

const { useToken } = theme;
const { Paragraph, Text } = Typography;

export const Help = () => {
const { token } = useToken();
const t = useTranslate();
const location = useLocation();

useEffect(() => {
if (!location.hash) {
return;
}

const targetId = decodeURIComponent(location.hash.replace(/^#/, ""));
let attempts = 0;
const maxAttempts = 12;

const focusHashTarget = () => {
const element = document.getElementById(targetId);
if (!element) {
attempts += 1;
if (attempts < maxAttempts) {
window.setTimeout(focusHashTarget, 100);
}
return;
}

element.scrollIntoView({ behavior: "auto", block: "start" });
if (!element.hasAttribute("tabindex")) {
element.setAttribute("tabindex", "-1");
}
(element as HTMLElement).focus({ preventScroll: true });
};

focusHashTarget();
}, [location.hash]);

return (
<Content
Expand Down Expand Up @@ -70,6 +102,81 @@ export const Help = () => {
),
}}
/>
<Divider />
<section id="template-syntax">
<Title level={2} style={{ marginBottom: 8 }}>
{t("help.templateSyntax.title")}
</Title>
<Paragraph type="secondary" style={{ fontSize: token.fontSizeSM, lineHeight: 1.7 }}>
{t("help.templateSyntax.intro")}
</Paragraph>
<Divider orientation="left" plain>
{t("help.templateSyntax.basicsHeading")}
</Divider>
<Paragraph type="secondary" style={{ fontSize: token.fontSizeSM, lineHeight: 1.7 }}>
{t("help.templateSyntax.basicsInsertBefore")} <Text code>{`{id}`}</Text>{" "}
{t("help.templateSyntax.basicsInsertMiddle")} <Text code>{`{filament.material}`}</Text>{" "}
{t("help.templateSyntax.basicsInsertAfter")} <Text code>?</Text>.
</Paragraph>
<Paragraph type="secondary" style={{ fontSize: token.fontSizeSM, lineHeight: 1.7 }}>
{t("help.templateSyntax.basicsCopyBefore")} <Text code>{t("printing.qrcode.templateTagButton")}</Text>{" "}
{t("help.templateSyntax.basicsCopyAfter")} <Text code>{`{tag}`}</Text> {t("help.templateSyntax.and")}{" "}
<Text code>{`{tag|modifier}`}</Text>.
</Paragraph>
<Paragraph type="secondary" style={{ fontSize: token.fontSizeSM, lineHeight: 1.7 }}>
{t("help.templateSyntax.basicsMissingBefore")} <Text code>{`{{comment}}`}</Text>{" "}
{t("help.templateSyntax.basicsMissingAfter")}
</Paragraph>
<Paragraph type="secondary" style={{ fontSize: token.fontSizeSM, lineHeight: 1.7 }}>
{t("help.templateSyntax.basicsConditionalBefore")} <Text code>{`{Lot Nr: {lot_nr}}`}</Text>{" "}
{t("help.templateSyntax.basicsConditionalAfter")}
</Paragraph>
<Paragraph type="secondary" style={{ fontSize: token.fontSizeSM, lineHeight: 1.7 }}>
{t("help.templateSyntax.basicsBoldBefore")} <Text code>**text**</Text>{" "}
{t("help.templateSyntax.basicsBoldAfter")}
</Paragraph>
<Divider orientation="left" plain>
{t("help.templateSyntax.datetimeHeading")}
</Divider>
<Paragraph type="secondary" style={{ fontSize: token.fontSizeSM, lineHeight: 1.7 }}>
{t("help.templateSyntax.datetimeSupportedBefore")} <Text code>date</Text>, <Text code>time</Text>,{" "}
<Text code>date_local</Text>, <Text code>time_local</Text>, <Text code>datetime_short</Text>,{" "}
<Text code>datetime_short_local</Text> {t("help.templateSyntax.datetimeSupportedAfter")}
</Paragraph>
<Paragraph type="secondary" style={{ fontSize: token.fontSizeSM, lineHeight: 1.7 }}>
{t("help.templateSyntax.datetimeOrderBefore")} <Text code>:ymd</Text>, <Text code>:mdy</Text>,{" "}
<Text code>:dmy</Text> {t("help.templateSyntax.datetimeOrderAfter")} <Text code>_local</Text>{" "}
{t("help.templateSyntax.datetimeLocal")}
</Paragraph>
<Divider orientation="left" plain>
{t("help.templateSyntax.numberHeading")}
</Divider>
<Paragraph type="secondary" style={{ fontSize: token.fontSizeSM, lineHeight: 1.7 }}>
{t("help.templateSyntax.numberSupportedBefore")} <Text code>round</Text>, <Text code>fixed1</Text>,{" "}
<Text code>fixed2</Text> {t("help.templateSyntax.numberSupportedAfter")}
</Paragraph>
<Paragraph type="secondary" style={{ fontSize: token.fontSizeSM, lineHeight: 1.7 }}>
{t("help.templateSyntax.numberPurpose")}
</Paragraph>
<Divider orientation="left" plain>
{t("help.templateSyntax.examplesHeading")}
</Divider>
<Paragraph type="secondary" style={{ fontSize: token.fontSizeSM, lineHeight: 1.7 }}>
{t("help.templateSyntax.examplesDatetimeBefore")} <Text code>{`{first_used|date}`}</Text>{" "}
{t("help.templateSyntax.examplesDatetimeDate")} <Text code>2026-03-04</Text>,{" "}
<Text code>{`{first_used|date:dmy}`}</Text> {t("help.templateSyntax.examplesDatetimeDmy")}{" "}
<Text code>04/03/2026</Text>, {t("help.templateSyntax.and")} <Text code>{`{first_used|time}`}</Text>{" "}
{t("help.templateSyntax.examplesDatetimeTime")} <Text code>18:27</Text>.{" "}
<Text code>{`{first_used|datetime_short_local:mdy}`}</Text> {t("help.templateSyntax.examplesDatetimeLocal")}.
</Paragraph>
<Paragraph type="secondary" style={{ fontSize: token.fontSizeSM, lineHeight: 1.7, marginBottom: 0 }}>
{t("help.templateSyntax.examplesNumberBefore")} <Text code>{`{remaining_weight|round}`}</Text>{" "}
{t("help.templateSyntax.examplesNumberRound")} <Text code>1235</Text>,{" "}
<Text code>{`{remaining_weight|fixed1}`}</Text> {t("help.templateSyntax.examplesNumberFixed1")}{" "}
<Text code>1234.6</Text>, {t("help.templateSyntax.and")} <Text code>{`{remaining_weight|fixed2}`}</Text>{" "}
{t("help.templateSyntax.examplesNumberFixed2")} <Text code>1234.57</Text>.
</Paragraph>
</section>
</Content>
);
};
Expand Down
123 changes: 107 additions & 16 deletions client/src/pages/printing/printing.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
import dayjs from "dayjs";
import utc from "dayjs/plugin/utc";
import { ReactElement } from "react";
import { v4 as uuidv4 } from "uuid";
import { useGetSetting, useSetSetting } from "../../utils/querySettings";
import { ISpool } from "../spools/model";

dayjs.extend(utc);

export interface PrintSettings {
id: string;
name?: string;
Expand Down Expand Up @@ -35,7 +39,6 @@ export function useGetPrintSettings(): SpoolQRCodePrintSettings[] | undefined {
if (!data) return;
const parsed: SpoolQRCodePrintSettings[] =
data && data.value ? JSON.parse(data.value) : ([] as SpoolQRCodePrintSettings[]);
// Loop through all parsed and generate a new ID field if it's not set
return parsed.map((settings) => {
if (!settings.labelSettings.printSettings.id) {
settings.labelSettings.printSettings.id = uuidv4();
Expand All @@ -58,9 +61,92 @@ interface GenericObject {
extra: { [key: string]: string };
}

type DateOrder = "ymd" | "mdy" | "dmy";

function getDatePattern(order: DateOrder): string {
switch (order) {
case "mdy":
return "MM/DD/YYYY";
case "dmy":
return "DD/MM/YYYY";
case "ymd":
default:
return "YYYY-MM-DD";
}
}

function parseDateModifier(modifier?: string): { baseModifier?: string; dateOrder: DateOrder } {
if (!modifier) {
return { dateOrder: "ymd" };
}

const [baseModifier, rawOrder] = modifier.split(":", 2);
if (rawOrder === "mdy" || rawOrder === "dmy" || rawOrder === "ymd") {
return { baseModifier, dateOrder: rawOrder };
}

return { baseModifier, dateOrder: "ymd" };
}

function formatNumberValue(value: unknown, modifier?: string): unknown {
if (!modifier || value === "?") {
return undefined;
}

const numericValue =
typeof value === "number" ? value : typeof value === "string" && value.trim() !== "" ? Number(value) : Number.NaN;

if (!Number.isFinite(numericValue)) {
return undefined;
}

switch (modifier) {
case "round":
return Math.round(numericValue);
case "fixed1":
return numericValue.toFixed(1);
case "fixed2":
return numericValue.toFixed(2);
default:
return undefined;
}
}

function formatDateTimeValue(value: unknown, modifier?: string): unknown {
const { baseModifier, dateOrder } = parseDateModifier(modifier);
if (!baseModifier || value === "?") {
return value;
}

if (typeof value !== "string") {
return value;
}

const parsed = dayjs.utc(value);
if (!parsed.isValid()) {
return value;
}

switch (baseModifier) {
case "date":
return parsed.format(getDatePattern(dateOrder));
case "time":
return parsed.format("HH:mm");
case "date_local":
return parsed.local().format(getDatePattern(dateOrder));
case "time_local":
return parsed.local().format("HH:mm");
case "datetime_short":
return parsed.format(`${getDatePattern(dateOrder)} HH:mm`);
case "datetime_short_local":
return parsed.local().format(`${getDatePattern(dateOrder)} HH:mm`);
default:
return value;
}
}

// eslint-disable-next-line @typescript-eslint/no-explicit-any
function getTagValue(tag: string, obj: GenericObject): any {
// Split tag by .
function getBaseTagValue(tag: string, obj: GenericObject): any {
const tagParts = tag.split(".");
if (tagParts[0] === "extra") {
const extraValue = obj.extra[tagParts[1]];
Expand All @@ -71,13 +157,23 @@ function getTagValue(tag: string, obj: GenericObject): any {
}

const value = obj[tagParts[0]] ?? "?";
// check if value is itself an object. If so, recursively call this and remove the first part of the tag
if (typeof value === "object") {
return getTagValue(tagParts.slice(1).join("."), value);
return getBaseTagValue(tagParts.slice(1).join("."), value);
}
return value;
}

// eslint-disable-next-line @typescript-eslint/no-explicit-any
function getTagValue(tag: string, obj: GenericObject): any {
const [baseTag, modifier] = tag.split("|", 2);
const tagValue = getBaseTagValue(baseTag, obj);
const numericValue = formatNumberValue(tagValue, modifier);
if (numericValue !== undefined) {
return numericValue;
}
return formatDateTimeValue(tagValue, modifier);
}

function applyNewline(text: string): ReactElement[] {
return text.split("\n").map((line, idx, arr) => (
<span key={idx}>
Expand All @@ -90,38 +186,33 @@ function applyNewline(text: string): ReactElement[] {
function applyTextFormatting(text: string): ReactElement[] {
const regex = /\*\*([\w\W]*?)\*\*/g;
const parts = text.split(regex);
// Map over the parts and wrap matched text with <b> tags
const elements = parts.map((part, index) => {
// Even index: outside asterisks, odd index: inside asterisks (to be bolded)
return parts.map((part, index) => {
const node = applyNewline(part);
return index % 2 === 0 ? <span key={index}>{node}</span> : <b key={index}>{node}</b>;
});
return elements;
}

export function renderLabelContents(template: string, spool: ISpool): ReactElement {
// Find all {tags} in the template string and loop over them
const matches = [...template.matchAll(/{(?:[^}{]|{[^}{]*})*}/gs)];
let label_text = template;
let labelText = template;
matches.forEach((match) => {
if ((match[0].match(/{/g) || []).length == 1) {
const tag = match[0].replace(/[{}]/g, "");
const tagValue = getTagValue(tag, spool);
label_text = label_text.replace(match[0], tagValue);
labelText = labelText.replace(match[0], tagValue);
} else if ((match[0].match(/{/g) || []).length == 2) {
const structure = match[0].match(/{(.*?){(.*?)}(.*?)}/);
if (structure != null) {
const tag = structure[2];
const tagValue = getTagValue(tag, spool);
if (tagValue == "?") {
label_text = label_text.replace(match[0], "");
labelText = labelText.replace(match[0], "");
} else {
label_text = label_text.replace(match[0], structure[1] + tagValue + structure[3]);
labelText = labelText.replace(match[0], structure[1] + tagValue + structure[3]);
}
}
}
});

// Split string on \n into individual lines
return <>{applyTextFormatting(label_text)}</>;
return <>{applyTextFormatting(labelText)}</>;
}
Loading