From cd23fce9b22440e88a1d0a7af7b0a778ceb37dd8 Mon Sep 17 00:00:00 2001 From: Rudi Szabo Date: Fri, 12 Jun 2026 20:11:39 +0200 Subject: [PATCH 01/14] feat: persist per-printer label settings (tape size, colors) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remember the tape size and foreground/background colors loaded in each printer across visits, so they aren't re-selected every page load — especially painful in kiosk / shared-tablet setups (issue #20). Storage reuses the existing state file (LABELLE_STATE_FILE), but the prior usb_power saver did an atomic *full-file* overwrite, which would clobber a second writer's keys. Introduce server/state_store.py: the single owner of that file, doing atomic read-modify-write of the whole document under a shared lock so independent features each persist their own slice safely. usb_power now routes through it (same on-disk shape and path arg; ~20 fewer lines; all its tests still pass). - server/printer_settings.py: get/save the persisted subset (tapeSizeMm, foregroundColor, backgroundColor) keyed by PrinterInfo.id under a "printers" namespace, validated server-side. - API: GET/PUT /api/printers//settings (ids carry spaces/colons, taken as a path segment). - Client: usePrinterSettings applies a printer's saved subset on selection and writes the full subset back on change (apply and save are separated so they can't loop). effectivePrinterId falls back to the sole connected printer on Auto-select, so single-printer setups persist even though the selector is hidden. Margins/justify/cutMark are deliberately out of scope (label preferences, not physical printer state). v2 follow-ups (aliases, presets, remember-last) extend this same per-printer map; the runtime capability/status cluster is tracked in #38. Tests: backend 247 pass, frontend 69 pass. Version 1.7.0 -> 1.8.0 (MINOR). Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 1 + client/src/components/SettingsBar.test.tsx | 67 ++++++++++++++++ client/src/components/SettingsBar.tsx | 40 ++++++---- client/src/lib/api.test.ts | 63 +++++++++++++++ client/src/lib/api.ts | 30 +++++++ client/src/lib/printerSettings.test.ts | 52 ++++++++++++ client/src/lib/printerSettings.ts | 41 ++++++++++ client/src/state/usePrinterSettings.ts | 56 +++++++++++++ client/src/types/label.ts | 8 ++ docs/ARCHITECTURE.md | 25 +++++- package.json | 2 +- server/app.py | 19 +++++ server/printer_settings.py | 67 ++++++++++++++++ server/state_store.py | 83 +++++++++++++++++++ server/tests/test_app.py | 61 ++++++++++++++ server/tests/test_printer_settings.py | 93 ++++++++++++++++++++++ server/tests/test_smoke.py | 2 + server/tests/test_state_store.py | 88 ++++++++++++++++++++ server/tests/test_usb_power.py | 7 +- server/usb_power.py | 49 +++--------- 20 files changed, 796 insertions(+), 58 deletions(-) create mode 100644 client/src/lib/printerSettings.test.ts create mode 100644 client/src/lib/printerSettings.ts create mode 100644 client/src/state/usePrinterSettings.ts create mode 100644 server/printer_settings.py create mode 100644 server/state_store.py create mode 100644 server/tests/test_printer_settings.py create mode 100644 server/tests/test_state_store.py diff --git a/README.md b/README.md index 45dccd4..31e6fd0 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ You can fight against AI usage or learn to embrace it as a new way of working. I - **Label settings** -- tape size, margins, minimum length, justify, foreground/background colors - **Per-widget font styles** -- each text widget can have its own font style, scale, frame, and alignment - **Multi-printer support** -- automatically detects all connected DYMO printers; select specific printer when multiple are available +- **Per-printer settings** -- the tape size and colors loaded in each printer are remembered across visits, so you don't re-pick them every time (handy for kiosk / shared-tablet setups) - **Virtual printers** -- configure virtual printers that save labels as PNG images, JSON data, or both (great for testing, archiving, and development) - **Batch print** -- print multiple labels with variable content using `{{varname}}` placeholders, with a table to fill in values per row, configurable copies and pause time, SSE progress streaming, and cancellation support - **Cut mark** -- optional dotted column painted into the trailing margin between batch labels so you can tear/cut between them; uses the existing inter-label gap, no extra tape diff --git a/client/src/components/SettingsBar.test.tsx b/client/src/components/SettingsBar.test.tsx index 38d1436..d3e2433 100644 --- a/client/src/components/SettingsBar.test.tsx +++ b/client/src/components/SettingsBar.test.tsx @@ -15,9 +15,13 @@ vi.mock("../lib/api", () => ({ }), powerOn: vi.fn(), powerOff: vi.fn(), + // usePrinterSettings (issue #20) runs inside SettingsBar. + fetchPrinterSettings: vi.fn().mockResolvedValue({}), + savePrinterSettings: vi.fn().mockResolvedValue(undefined), })); import { SettingsBar } from "./SettingsBar"; +import { fetchPrinterSettings, savePrinterSettings } from "../lib/api"; import { useLabelStore } from "../state/useLabelStore"; import type { PrinterInfo } from "../types/label"; @@ -31,6 +35,7 @@ afterEach(() => { }); beforeEach(() => { + vi.clearAllMocks(); // Reset store to defaults useLabelStore.setState({ availablePrinters: [], @@ -124,6 +129,68 @@ describe("SettingsBar printer selector", () => { }); }); +describe("SettingsBar per-printer settings persistence (issue #20)", () => { + it("applies saved settings when a printer is selected", async () => { + vi.mocked(fetchPrinterSettings).mockResolvedValueOnce({ tapeSizeMm: 6 }); + useLabelStore.setState({ + availablePrinters: twoPrinters, + settings: { ...useLabelStore.getState().settings, printerId: "usb:1" }, + }); + render(); + + await waitFor(() => { + expect(useLabelStore.getState().settings.tapeSizeMm).toBe(6); + }); + expect(fetchPrinterSettings).toHaveBeenCalledWith("usb:1"); + }); + + it("persists the full subset when the tape size changes", async () => { + useLabelStore.setState({ + availablePrinters: twoPrinters, + settings: { ...useLabelStore.getState().settings, printerId: "usb:1" }, + }); + render(); + screen.getByText("Settings").click(); + + const tapeSelect = screen.getByDisplayValue("12"); + await userEvent.selectOptions(tapeSelect, "19"); + + expect(savePrinterSettings).toHaveBeenCalledWith("usb:1", { + tapeSizeMm: 19, + foregroundColor: "black", + backgroundColor: "white", + }); + }); + + it("uses the single connected printer even on Auto-select", async () => { + useLabelStore.setState({ + availablePrinters: [twoPrinters[0]!], + settings: { ...useLabelStore.getState().settings, printerId: undefined }, + }); + render(); + + await waitFor(() => { + expect(fetchPrinterSettings).toHaveBeenCalledWith("usb:1"); + }); + }); + + it("does not persist on Auto-select with multiple printers", async () => { + useLabelStore.setState({ + availablePrinters: twoPrinters, + settings: { ...useLabelStore.getState().settings, printerId: undefined }, + }); + render(); + screen.getByText("Settings").click(); + + const tapeSelect = screen.getByDisplayValue("12"); + await userEvent.selectOptions(tapeSelect, "19"); + + expect(savePrinterSettings).not.toHaveBeenCalled(); + // Ambiguous which printer the server picks, so we don't read either. + expect(fetchPrinterSettings).not.toHaveBeenCalled(); + }); +}); + describe("SettingsBar power toggle visibility", () => { it("shows the power toggle when a real USB printer is selected", async () => { useLabelStore.setState({ diff --git a/client/src/components/SettingsBar.tsx b/client/src/components/SettingsBar.tsx index b4a16c7..1f548a2 100644 --- a/client/src/components/SettingsBar.tsx +++ b/client/src/components/SettingsBar.tsx @@ -1,4 +1,5 @@ import { useLabelStore } from "../state/useLabelStore"; +import { usePrinterSettings } from "../state/usePrinterSettings"; import { TAPE_SIZES, LABEL_COLORS } from "../lib/constants"; import { fetchPrinters } from "../lib/api"; import type { TapeSize, Alignment, LabelColor } from "../types/label"; @@ -12,6 +13,10 @@ export function SettingsBar() { const setAvailablePrinters = useLabelStore((s) => s.setAvailablePrinters); const [isRefreshing, setIsRefreshing] = useState(false); + // Per-printer settings persistence (issue #20): applies saved tape/color + // for the selected printer, and `persist` saves user changes to those. + const { persist } = usePrinterSettings(); + const handleRefreshPrinters = async () => { setIsRefreshing(true); try { @@ -24,13 +29,10 @@ export function SettingsBar() { } }; - // Only show printer selector if multiple printers are detected - // TODO: Future improvements for multi-printer UI: - // - Show printer status indicators (online/offline) - // - Display tape type/color/width for each printer - // - Allow users to set friendly aliases for printers - // - Remember last selected printer per user - // - Support printer-specific preset configurations + // Only show printer selector if multiple printers are detected. + // Tape size + colors now persist per printer (issue #20); the rest of + // the multi-printer UI cluster (status indicators, capability-aware tape + // display, aliases, presets) is tracked in #38. const showPrinterSelector = availablePrinters.length > 1; // Hide the USB power toggle when a virtual printer is selected — @@ -83,9 +85,11 @@ export function SettingsBar() { - update({ foregroundColor: e.target.value as LabelColor }) - } + onChange={(e) => { + const foregroundColor = e.target.value as LabelColor; + update({ foregroundColor }); + persist({ foregroundColor }); + }} > {LABEL_COLORS.map((c) => (