From 0aa77dd0c8bdf1746a9e994a82ee79bd6ec552b3 Mon Sep 17 00:00:00 2001 From: Logan Miller Date: Sat, 6 Jun 2026 09:29:00 -0500 Subject: [PATCH 1/3] Extract label-generator logic and add maker mode Move pure label-generation logic into frontend/lib/reports/label-generator.ts (types and functions: fmtAssetID, calculateGridData, calculateMakerGrid, makerPageSize, buildPageCss, presetFor, normalizeMeasure) and add comprehensive unit tests. Update the label-generator Vue page to import and use the new module, add an output-mode selector (sheet / label maker / custom), maker-specific inputs (labelsPerRow, labelGap), seed presets for modes, compute maker page size and inject @page CSS, and adapt page/grid calculation flow to handle maker vs sheet. Also add new localization strings for the label maker UI. --- frontend/lib/reports/label-generator.test.ts | 158 ++++++++++++++ frontend/lib/reports/label-generator.ts | 210 ++++++++++++++++++ frontend/locales/en.json | 7 + frontend/pages/reports/label-generator.vue | 217 ++++++++++--------- 4 files changed, 495 insertions(+), 97 deletions(-) create mode 100644 frontend/lib/reports/label-generator.test.ts create mode 100644 frontend/lib/reports/label-generator.ts diff --git a/frontend/lib/reports/label-generator.test.ts b/frontend/lib/reports/label-generator.test.ts new file mode 100644 index 000000000..9c10035b9 --- /dev/null +++ b/frontend/lib/reports/label-generator.test.ts @@ -0,0 +1,158 @@ +import { describe, expect, test } from "vitest"; +import { + buildPageCss, + calculateGridData, + calculateMakerGrid, + fmtAssetID, + makerPageSize, + MAKER_PRESET, + presetFor, + SHEET_PRESET, + type LabelMakerInput, + type LabelOptionInput, +} from "./label-generator"; + +const averyInput: LabelOptionInput = { + measure: "in", + page: { + width: 8.5, + height: 11, + pageTopPadding: 0.52, + pageBottomPadding: 0.42, + pageLeftPadding: 0.25, + pageRightPadding: 0.1, + }, + cardWidth: 2.63, + cardHeight: 1, +}; + +const makerInput: LabelMakerInput = { + measure: "mm", + labelWidth: 90, + labelHeight: 62, + labelsPerRow: 1, + labelGap: 0, +}; + +describe("fmtAssetID", () => { + test("pads and hyphenates a small number", () => { + expect(fmtAssetID(1)).toBe("000-001"); + }); + + test("formats a full six digit value", () => { + expect(fmtAssetID(123456)).toBe("123-456"); + }); + + test("does not truncate values longer than six digits", () => { + expect(fmtAssetID(1234567)).toBe("123-4567"); + }); + + test("accepts string input", () => { + expect(fmtAssetID("42")).toBe("000-042"); + }); +}); + +describe("calculateGridData", () => { + test("lays out the Avery 5260 sheet", () => { + const res = calculateGridData(averyInput); + expect(res.ok).toBe(true); + if (!res.ok) return; + expect(res.data.cols).toBe(3); + expect(res.data.rows).toBe(10); + expect(res.data.measure).toBe("in"); + expect(res.data.card).toEqual({ width: 2.63, height: 1 }); + }); + + test("errors when the page is too small for the card", () => { + const res = calculateGridData({ ...averyInput, cardWidth: 100 }); + expect(res).toEqual({ ok: false, error: "page_too_small_card" }); + }); + + test("single column yields gapX of 0, not NaN", () => { + const res = calculateGridData({ ...averyInput, cardWidth: 7 }); + expect(res.ok).toBe(true); + if (!res.ok) return; + expect(res.data.cols).toBe(1); + expect(res.data.gapX).toBe(0); + }); + + test("single row yields gapY of 0, not NaN", () => { + const res = calculateGridData({ ...averyInput, cardHeight: 9 }); + expect(res.ok).toBe(true); + if (!res.ok) return; + expect(res.data.rows).toBe(1); + expect(res.data.gapY).toBe(0); + }); + + test("falls back to inches for an invalid measure", () => { + const res = calculateGridData({ ...averyInput, measure: "furlong" }); + expect(res.ok).toBe(true); + if (!res.ok) return; + expect(res.data.measure).toBe("in"); + }); +}); + +describe("makerPageSize", () => { + test("single label width equals the label width", () => { + expect(makerPageSize(makerInput)).toEqual({ measure: "mm", width: 90, height: 62 }); + }); + + test("row of three includes the gaps between labels", () => { + const size = makerPageSize({ ...makerInput, labelsPerRow: 3, labelGap: 2 }); + expect(size.width).toBe(3 * 90 + 2 * 2); + expect(size.height).toBe(62); + }); + + test("normalizes the measure", () => { + expect(makerPageSize({ ...makerInput, measure: "bogus" }).measure).toBe("in"); + }); +}); + +describe("calculateMakerGrid", () => { + test("single label is a one-by-one grid with no gaps", () => { + const grid = calculateMakerGrid(makerInput); + expect(grid.cols).toBe(1); + expect(grid.rows).toBe(1); + expect(grid.gapX).toBe(0); + expect(grid.gapY).toBe(0); + expect(grid.page).toEqual({ width: 90, height: 62, pt: 0, pb: 0, pl: 0, pr: 0 }); + }); + + test("row of three uses the label gap for gapX", () => { + const grid = calculateMakerGrid({ ...makerInput, labelsPerRow: 3, labelGap: 2 }); + expect(grid.cols).toBe(3); + expect(grid.rows).toBe(1); + expect(grid.gapX).toBe(2); + expect(grid.page.width).toBe(3 * 90 + 2 * 2); + }); +}); + +describe("buildPageCss", () => { + const size = { measure: "mm" as const, width: 90, height: 62 }; + + test("maker mode emits a sized, margin-free page rule", () => { + expect(buildPageCss("maker", size)).toBe("@page { size: 90mm 62mm; margin: 0; }"); + }); + + test("sheet mode emits no rule", () => { + expect(buildPageCss("sheet", size)).toBe(""); + }); + + test("custom mode emits no rule", () => { + expect(buildPageCss("custom", size)).toBe(""); + }); +}); + +describe("presetFor", () => { + test("returns the sheet preset", () => { + expect(presetFor("sheet")).toBe(SHEET_PRESET); + }); + + test("returns the maker preset", () => { + expect(presetFor("maker")).toBe(MAKER_PRESET); + }); + + test("returns null for custom", () => { + expect(presetFor("custom")).toBeNull(); + }); +}); diff --git a/frontend/lib/reports/label-generator.ts b/frontend/lib/reports/label-generator.ts new file mode 100644 index 000000000..c08818139 --- /dev/null +++ b/frontend/lib/reports/label-generator.ts @@ -0,0 +1,210 @@ +// Pure logic for the label generator page (frontend/pages/reports/label-generator.vue). +// Kept free of Vue/i18n/toast so it can be unit tested in isolation. + +export type Measure = "in" | "cm" | "mm"; + +export type LabelMode = "sheet" | "maker" | "custom"; + +export type LabelOptionInput = { + measure: string; + page: { + height: number; + width: number; + pageTopPadding: number; + pageBottomPadding: number; + pageLeftPadding: number; + pageRightPadding: number; + }; + cardHeight: number; + cardWidth: number; +}; + +export type GridData = { + measure: Measure; + cols: number; + rows: number; + gapY: number; + gapX: number; + card: { + width: number; + height: number; + }; + page: { + width: number; + height: number; + pt: number; + pb: number; + pl: number; + pr: number; + }; +}; + +export type GridResult = { ok: true; data: GridData } | { ok: false; error: "page_too_small_card" }; + +export type LabelMakerInput = { + measure: string; + labelWidth: number; + labelHeight: number; + labelsPerRow: number; + labelGap: number; +}; + +export type MakerPageSize = { + measure: Measure; + width: number; + height: number; +}; + +export type LabelPreset = { + measure: Measure; + cardHeight: number; + cardWidth: number; + pageWidth: number; + pageHeight: number; + pageTopPadding: number; + pageBottomPadding: number; + pageLeftPadding: number; + pageRightPadding: number; + labelsPerRow: number; + labelGap: number; +}; + +export const DEFAULT_MEASURE: Measure = "in"; + +// Avery 5260 sheet of labels (the historical default). +export const SHEET_PRESET: LabelPreset = { + measure: "in", + cardHeight: 1, + cardWidth: 2.63, + pageWidth: 8.5, + pageHeight: 11, + pageTopPadding: 0.52, + pageBottomPadding: 0.42, + pageLeftPadding: 0.25, + pageRightPadding: 0.1, + labelsPerRow: 1, + labelGap: 0, +}; + +// Brother 62mm continuous tape (DK-22205). Dimensions are editable in the UI. +export const MAKER_PRESET: LabelPreset = { + measure: "mm", + cardHeight: 62, + cardWidth: 90, + pageWidth: 90, + pageHeight: 62, + pageTopPadding: 0, + pageBottomPadding: 0, + pageLeftPadding: 0, + pageRightPadding: 0, + labelsPerRow: 1, + labelGap: 0, +}; + +export function normalizeMeasure(measure: string): Measure { + return /^(in|cm|mm)$/.test(measure) ? (measure as Measure) : DEFAULT_MEASURE; +} + +// Returns the geometry preset for a mode, or null when the mode owns no preset (custom). +export function presetFor(mode: LabelMode): LabelPreset | null { + if (mode === "sheet") { + return SHEET_PRESET; + } + if (mode === "maker") { + return MAKER_PRESET; + } + return null; +} + +export function fmtAssetID(aid: number | string): string { + let aidStr = aid.toString().padStart(6, "0"); + aidStr = aidStr.slice(0, 3) + "-" + aidStr.slice(3); + return aidStr; +} + +// Lays a sheet out into a grid of cards based on the available page area. +export function calculateGridData(input: LabelOptionInput): GridResult { + const { page, cardHeight, cardWidth } = input; + const measure = normalizeMeasure(input.measure); + + const availablePageWidth = page.width - page.pageLeftPadding - page.pageRightPadding; + const availablePageHeight = page.height - page.pageTopPadding - page.pageBottomPadding; + + if (availablePageWidth < cardWidth || availablePageHeight < cardHeight) { + return { ok: false, error: "page_too_small_card" }; + } + + const cols = Math.floor(availablePageWidth / cardWidth); + const rows = Math.floor(availablePageHeight / cardHeight); + // Guard single-column / single-row layouts so the gap stays 0 instead of NaN/Infinity. + const gapX = cols > 1 ? (availablePageWidth - cols * cardWidth) / (cols - 1) : 0; + const gapY = rows > 1 ? (page.height - rows * cardHeight) / (rows - 1) : 0; + + return { + ok: true, + data: { + measure, + cols, + rows, + gapX, + gapY, + card: { + width: cardWidth, + height: cardHeight, + }, + page: { + width: page.width, + height: page.height, + pt: page.pageTopPadding, + pb: page.pageBottomPadding, + pl: page.pageLeftPadding, + pr: page.pageRightPadding, + }, + }, + }; +} + +// Width (and height) of a single label-maker segment: one row of N labels. +export function makerPageSize(input: LabelMakerInput): MakerPageSize { + const perRow = Math.max(1, Math.floor(input.labelsPerRow)); + return { + measure: normalizeMeasure(input.measure), + width: perRow * input.labelWidth + (perRow - 1) * input.labelGap, + height: input.labelHeight, + }; +} + +// A label maker prints one row of labels per tape segment: a single-row, zero-padding grid. +export function calculateMakerGrid(input: LabelMakerInput): GridData { + const cols = Math.max(1, Math.floor(input.labelsPerRow)); + const size = makerPageSize(input); + + return { + measure: size.measure, + cols, + rows: 1, + gapX: cols > 1 ? input.labelGap : 0, + gapY: 0, + card: { + width: input.labelWidth, + height: input.labelHeight, + }, + page: { + width: size.width, + height: size.height, + pt: 0, + pb: 0, + pl: 0, + pr: 0, + }, + }; +} + +// CSS @page rule. Sheet/custom keep the historical behavior (no rule, user sets printer margins). +// Label maker sizes each printed page to one tape segment so labels feed correctly. +export function buildPageCss(mode: LabelMode, size: MakerPageSize): string { + if (mode !== "maker") { + return ""; + } + return `@page { size: ${size.width}${size.measure} ${size.height}${size.measure}; margin: 0; }`; +} diff --git a/frontend/locales/en.json b/frontend/locales/en.json index 85435aff3..d9518305d 100644 --- a/frontend/locales/en.json +++ b/frontend/locales/en.json @@ -822,9 +822,16 @@ "instruction_1": "The HomeBox Label Generator is a tool to help you print labels for your HomeBox inventory. These are intended to\n be print-ahead labels so you can print many labels and have them ready to apply", "instruction_2": "As such, these labels work by printing a URL QR Code and AssetID information on a label. If you've disabled\n AssetID's in your HomeBox settings, you can still use this tool, but the AssetID's won't reference any item", "instruction_3": "This feature is in early development stages and may change in future releases, if you have feedback please\n provide it in the ''GitHub Discussion''", + "label_gap": "Label Gap", "label_height": "Label Height", "label_width": "Label Width", + "labels_per_row": "Labels Per Row", + "maker_instruction": "Each printed page is one tape segment sized to your labels. In the print dialog, choose your label maker and disable extra margins.", "measure_type": "Measure Type", + "mode": "Output Type", + "mode_custom": "Custom", + "mode_label_maker": "Label Maker", + "mode_label_sheet": "Label Sheet", "page_bottom_padding": "Page Bottom Padding", "page_height": "Page Height", "page_left_padding": "Page Left Padding", diff --git a/frontend/pages/reports/label-generator.vue b/frontend/pages/reports/label-generator.vue index c454a3932..7ac1fd0de 100644 --- a/frontend/pages/reports/label-generator.vue +++ b/frontend/pages/reports/label-generator.vue @@ -2,6 +2,16 @@ import { useI18n } from "vue-i18n"; import DOMPurify from "dompurify"; import { route } from "../../lib/api/base"; + import { + buildPageCss, + calculateGridData, + calculateMakerGrid, + fmtAssetID, + makerPageSize, + presetFor, + type GridData, + type LabelMode, + } from "../../lib/reports/label-generator"; import { Toaster, toast } from "@/components/ui/sonner"; import { Separator } from "@/components/ui/separator"; import { Button } from "@/components/ui/button"; @@ -36,6 +46,12 @@ const replaceHomeboxBehavior = ref(BEHAVIOR_SHOW); const replaceHomeboxText = ref(labelBlankLine); + // Output target: a sheet of labels, a Brother-style label maker (continuous tape), or fully custom. + const MODE_SHEET: LabelMode = "sheet"; + const MODE_MAKER: LabelMode = "maker"; + const MODE_CUSTOM: LabelMode = "custom"; + const mode = ref(MODE_SHEET); + const displayProperties = reactive({ baseURL: window.location.origin, assetRange: 1, @@ -46,6 +62,8 @@ columns: 3, cardHeight: 1, cardWidth: 2.63, + labelsPerRow: 1, + labelGap: 0, pageWidth: 8.5, pageHeight: 11, pageTopPadding: 0.52, @@ -54,90 +72,17 @@ pageRightPadding: 0.1, }); - type LabelOptionInput = { - measure: string; - page: { - height: number; - width: number; - pageTopPadding: number; - pageBottomPadding: number; - pageLeftPadding: number; - pageRightPadding: number; - }; - cardHeight: number; - cardWidth: number; - }; - - type Output = { - measure: string; - cols: number; - rows: number; - gapY: number; - gapX: number; - card: { - width: number; - height: number; - }; - page: { - width: number; - height: number; - pt: number; - pb: number; - pl: number; - pr: number; - }; - }; - - function calculateGridData(input: LabelOptionInput): Output { - const { page, cardHeight, cardWidth } = input; - - const measureRegex = /in|cm|mm/; - const measure = measureRegex.test(input.measure) ? input.measure : "in"; - - const availablePageWidth = page.width - page.pageLeftPadding - page.pageRightPadding; - const availablePageHeight = page.height - page.pageTopPadding - page.pageBottomPadding; - - if (availablePageWidth < cardWidth || availablePageHeight < cardHeight) { - toast.error(t("reports.label_generator.toast.page_too_small_card")); - return out.value; - } - - const cols = Math.floor(availablePageWidth / cardWidth); - const rows = Math.floor(availablePageHeight / cardHeight); - const gapX = (availablePageWidth - cols * cardWidth) / (cols - 1); - const gapY = (page.height - rows * cardHeight) / (rows - 1); - - return { - measure, - cols, - rows, - gapX, - gapY, - card: { - width: cardWidth, - height: cardHeight, - }, - page: { - width: page.width, - height: page.height, - pt: page.pageTopPadding, - pb: page.pageBottomPadding, - pl: page.pageLeftPadding, - pr: page.pageRightPadding, - }, - }; - } - interface InputDef { label: string; ref: keyof typeof displayProperties; type?: "number" | "text"; min?: number; step?: number; + modes?: LabelMode[]; } const propertyInputs = computed(() => { - return [ + const inputs: InputDef[] = [ { label: t("reports.label_generator.asset_start"), ref: "assetRange", @@ -165,29 +110,48 @@ label: t("reports.label_generator.label_width"), ref: "cardWidth", }, + { + label: t("reports.label_generator.labels_per_row"), + ref: "labelsPerRow", + min: 1, + step: 1, + modes: [MODE_MAKER], + }, + { + label: t("reports.label_generator.label_gap"), + ref: "labelGap", + min: 0, + modes: [MODE_MAKER], + }, { label: t("reports.label_generator.page_width"), ref: "pageWidth", + modes: [MODE_SHEET, MODE_CUSTOM], }, { label: t("reports.label_generator.page_height"), ref: "pageHeight", + modes: [MODE_SHEET, MODE_CUSTOM], }, { label: t("reports.label_generator.page_top_padding"), ref: "pageTopPadding", + modes: [MODE_SHEET, MODE_CUSTOM], }, { label: t("reports.label_generator.page_bottom_padding"), ref: "pageBottomPadding", + modes: [MODE_SHEET, MODE_CUSTOM], }, { label: t("reports.label_generator.page_left_padding"), ref: "pageLeftPadding", + modes: [MODE_SHEET, MODE_CUSTOM], }, { label: t("reports.label_generator.page_right_padding"), ref: "pageRightPadding", + modes: [MODE_SHEET, MODE_CUSTOM], }, { label: t("reports.label_generator.base_url"), @@ -195,6 +159,8 @@ type: "text", }, ]; + + return inputs.filter(input => !input.modes || input.modes.includes(mode.value)); }); type LabelData = { @@ -204,14 +170,6 @@ location: string; }; - function fmtAssetID(aid: number | string) { - aid = aid.toString(); - - let aidStr = aid.toString().padStart(6, "0"); - aidStr = aidStr.slice(0, 3) + "-" + aidStr.slice(3); - return aidStr; - } - function getQRCodeUrl(assetID: string): string { let origin = displayProperties.baseURL.trim(); @@ -307,7 +265,7 @@ const pages = ref([]); - const out = ref({ + const out = ref({ measure: "in", cols: 0, rows: 0, @@ -329,19 +287,36 @@ function calcPages() { // Set Out Dimensions - out.value = calculateGridData({ - measure: displayProperties.measure, - page: { - height: displayProperties.pageHeight, - width: displayProperties.pageWidth, - pageTopPadding: displayProperties.pageTopPadding, - pageBottomPadding: displayProperties.pageBottomPadding, - pageLeftPadding: displayProperties.pageLeftPadding, - pageRightPadding: displayProperties.pageRightPadding, - }, - cardHeight: displayProperties.cardHeight, - cardWidth: displayProperties.cardWidth, - }); + if (mode.value === MODE_MAKER) { + out.value = calculateMakerGrid({ + measure: displayProperties.measure, + labelWidth: displayProperties.cardWidth, + labelHeight: displayProperties.cardHeight, + labelsPerRow: displayProperties.labelsPerRow, + labelGap: displayProperties.labelGap, + }); + } else { + const result = calculateGridData({ + measure: displayProperties.measure, + page: { + height: displayProperties.pageHeight, + width: displayProperties.pageWidth, + pageTopPadding: displayProperties.pageTopPadding, + pageBottomPadding: displayProperties.pageBottomPadding, + pageLeftPadding: displayProperties.pageLeftPadding, + pageRightPadding: displayProperties.pageRightPadding, + }, + cardHeight: displayProperties.cardHeight, + cardWidth: displayProperties.cardWidth, + }); + + if (!result.ok) { + toast.error(t(`reports.label_generator.toast.${result.error}`)); + return; + } + + out.value = result.data; + } const calc: Page[] = []; @@ -393,6 +368,30 @@ pages.value = calc; } + // Seed the dimension fields with the preset for the chosen mode (custom keeps the current values). + watch(mode, newMode => { + const preset = presetFor(newMode); + if (preset) { + Object.assign(displayProperties, preset); + } + calcPages(); + }); + + // Size each printed page to a single tape segment so label-maker output feeds correctly. + const makerSize = computed(() => + makerPageSize({ + measure: displayProperties.measure, + labelWidth: displayProperties.cardWidth, + labelHeight: displayProperties.cardHeight, + labelsPerRow: displayProperties.labelsPerRow, + labelGap: displayProperties.labelGap, + }) + ); + + useHead(() => ({ + style: [{ innerHTML: buildPageCss(mode.value, makerSize.value) }], + })); + onMounted(() => { calcPages(); }); @@ -423,6 +422,30 @@
+
+ + +

+ {{ $t("reports.label_generator.maker_instruction") }} +

+
-
+
@@ -515,21 +533,49 @@ {{ $t("reports.label_generator.print_location_row") }}
+
+ + +

{{ $t("reports.label_generator.qr_code_example") }} {{ displayProperties.baseURL }}/a/{asset_id}

- +
+ + +
-
+
{ "@media print { .maker-label { width: 90mm; height: 62mm; transform: translate(-14mm, 14mm) rotate(90deg); transform-origin: center center; } }" ); }); + + test("270 rotation sizes and re-centers the label onto the swapped page", () => { + // Re-centering moves the box center onto the swapped page center, which is independent of rotation direction, + // so 270 shares the 90 translate offsets. + expect(buildRotateCss("maker", size, 270)).toBe( + "@media print { .maker-label { width: 90mm; height: 62mm; transform: translate(-14mm, 14mm) rotate(270deg); transform-origin: center center; } }" + ); + }); }); describe("presetFor", () => { diff --git a/frontend/lib/reports/label-generator.ts b/frontend/lib/reports/label-generator.ts index c9f5dd549..8f8c2d46e 100644 --- a/frontend/lib/reports/label-generator.ts +++ b/frontend/lib/reports/label-generator.ts @@ -138,7 +138,7 @@ export function calculateGridData(input: LabelOptionInput): GridResult { const rows = Math.floor(availablePageHeight / cardHeight); // Guard single-column / single-row layouts so the gap stays 0 instead of NaN/Infinity. const gapX = cols > 1 ? (availablePageWidth - cols * cardWidth) / (cols - 1) : 0; - const gapY = rows > 1 ? (page.height - rows * cardHeight) / (rows - 1) : 0; + const gapY = rows > 1 ? (availablePageHeight - rows * cardHeight) / (rows - 1) : 0; return { ok: true, diff --git a/frontend/pages/reports/label-generator.vue b/frontend/pages/reports/label-generator.vue index 47f453d0e..43107da37 100644 --- a/frontend/pages/reports/label-generator.vue +++ b/frontend/pages/reports/label-generator.vue @@ -317,6 +317,7 @@ if (!result.ok) { toast.error(t(`reports.label_generator.toast.${result.error}`)); + pages.value = []; return; } @@ -377,6 +378,10 @@ async function printLabels() { calcPages(); await nextTick(); + // A failed geometry recalculation clears pages (and toasts); don't open the print dialog on stale/empty output. + if (pages.value.length === 0) { + return; + } window.print(); }