Skip to content
Merged
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,7 @@ On desktop, **Connect data** also reads an Excel worksheet. **File → Settings

- The **Objects** group selects the fonts and graphics the **Setup Script** uploads.
- It lists cached images and deletes the unused ones.
- On desktop it also reads what the printer holds. Show a stored graphic or let the printer draw a font sample. Delete objects on the printer.
- On desktop it reads what the printer holds, so every row says where its file stands. Show a stored graphic or let the printer draw a font sample. Delete objects on the printer.
- Saved designs and label exports exclude **Setup Script** values such as printer name and locale.
- **Clear** resets the **Setup Script** values and keeps the uploads.

Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/lib/customFonts.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { CustomFontMapping, LabelConfig } from "../types/LabelConfig";
import { getFontBytes, holdsOtherBytes, isFontFile } from "./fontCache";
import { DEFAULT_FONT_DEVICE, sanitizeStorageName, STORAGE_DEVICES, storageRefMatchesPath } from "./storagePath";
import { DEFAULT_FONT_DEVICE, isWritableDevice, sanitizeStorageName, storageRefMatchesPath } from "./storagePath";
import { stripZplParamChars } from "./zplParams";
/** Strip-pattern; schema's regex `/^[A-Z0-9]$/` is the inverse. */
export const ALIAS_CHAR_RE = /[^A-Z0-9]/g;
Expand Down Expand Up @@ -66,7 +66,7 @@ export const ZPL_BUILTIN_FONT_IDS = [
* ^A@ and ^CW can reference (spec p.63, p.168). .TTE stays, .OTF and the rest become .TTF (p.184). */
export function printerFontFileName(fileName: string): string | undefined {
const first = fileName[0]?.toUpperCase() ?? "";
const typedDevice = fileName[1] === ":" && (STORAGE_DEVICES as readonly string[]).includes(first) ? `${first}:` : undefined;
const typedDevice = fileName[1] === ":" && isWritableDevice(first) ? `${first}:` : undefined;
const rest = typedDevice ? fileName.slice(2) : fileName;
const device = typedDevice ?? `${DEFAULT_FONT_DEVICE}:`;
const dot = rest.lastIndexOf(".");
Expand Down
4 changes: 4 additions & 0 deletions packages/core/src/lib/hostDirectory.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { FONT_FORMAT_BY_EXT } from "./customFonts";
import { replyLines, unframed } from "./hostStatus";
import { STORAGE_DEVICES } from "./storagePath";

export interface HostObject {
device: string;
Expand All @@ -16,6 +17,9 @@ export interface HostDirectory {

export const hostObjectPath = (o: HostObject): string => `${o.device}:${o.name}.${o.ext}`;

/** ^HW also lists the firmware drive Z:, which no upload can name (spec p.240). */
export const HOST_LISTING_DEVICES = [...STORAGE_DEVICES, "Z"] as const;

export type HostObjectKind = "graphic" | "font" | "format" | "other";

/** Extensions by what the object is, after the object lists of spec p.243 and p.182. */
Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/lib/storagePath.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ export type StorageDevice = (typeof STORAGE_DEVICES)[number];
/** Where a locally picked font goes. */
export const DEFAULT_FONT_DEVICE = "E";

export const isWritableDevice = (device: string): boolean =>
(STORAGE_DEVICES as readonly string[]).includes(device.toUpperCase());

/** DOS-style 8.3 (8-char uppercase alnum + underscore). */
export const MAX_STORAGE_NAME_LEN = 8;
export const STORAGE_NAME_FILTER_RE = /[^A-Z0-9_]/g;
Expand Down
113 changes: 113 additions & 0 deletions packages/core/src/lib/storedObjectOrigins.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { describe, it, expect } from "vitest";
import { deleteKnowledge, hostListing, listingHolds, originState, storedObjectOrigins } from "./storedObjectOrigins";
import type { HostDirectory } from "./hostDirectory";

const object = (device: string, name: string, ext: string, size = 100) => ({ device, name, ext, size });
const dir = (device: string, objects: HostDirectory["objects"], bytesFree?: number): HostDirectory => ({ device, objects, bytesFree });
const bytes = (paths: string[]) => paths.map((path) => ({ path, hasBytes: true }));

describe("hostListing", () => {
it("folds one drive answering twice into one block and lists an aliased file once", () => {
const listing = hostListing([
dir("R", [object("R", "LOGO", "GRF")], 100),
dir("R", [object("R", "LOGO", "GRF")], 100),
dir("A", [object("E", "ARIAL", "TTF")], 200),
dir("E", [object("E", "ARIAL", "TTF")], 200),
]);
expect(listing.objects).toHaveLength(2);
});

it("reads neither absence nor free space off a drive whose block names another drive's files", () => {
const listing = hostListing([dir("A", [object("E", "ARIAL", "TTF")], 200), dir("E", [object("E", "ARIAL", "TTF")], 200)]);
expect(listing.devices).toEqual(["E"]);
expect(listing.free).toEqual([{ device: "E", bytesFree: 200 }]);
expect(storedObjectOrigins({ kind: "font", setupPaths: ["A:ARIAL.TTF"], local: [], listing }).map((r) => [r.key, r.host])).toEqual([
["A:ARIAL.TTF", "unknown"],
["E:ARIAL.TTF", "present"],
]);
});

it("still answers for a drive that holds nothing at all", () => {
const listing = hostListing([dir("R", [], 100)]);
expect(listing.devices).toEqual(["R"]);
expect(storedObjectOrigins({ kind: "graphic", setupPaths: ["R:A.GRF"], local: [], listing }).map((r) => r.host)).toEqual(["absent"]);
});

it("puts firmware objects behind the ones an upload could have written and names no room on their drive", () => {
const listing = hostListing([dir("Z", [object("Z", "TT0003M_", "FNT")], 0), dir("E", [object("E", "ARIAL", "TTF")], 200)]);
expect(listing.objects.map((o) => o.device)).toEqual(["E", "Z"]);
expect(listing.free).toEqual([{ device: "E", bytesFree: 200 }]);
});
});

describe("listingHolds", () => {
it("answers for a path spelled another way than the listing spells it", () => {
const listing = hostListing([dir("E", [object("E", "ARIAL", "TTF")])]);
expect(listingHolds(listing, "e:arial.ttf")).toBe(true);
expect(listingHolds(listing, "R:ARIAL.TTF")).toBe(false);
});
});

describe("storedObjectOrigins", () => {
it("folds the three sources into one row per file, local rows first", () => {
const listing = hostListing([dir("E", [object("E", "ARIAL", "TTF"), object("E", "CG_TIMES", "TTF")]), dir("R", [])]);
const rows = storedObjectOrigins({ kind: "font", setupPaths: ["E:ARIAL.TTF", "R:GONE.TTF"], local: bytes(["E:ARIAL.TTF", "E:LOCAL.TTF"]), listing });
expect(rows.map((r) => [r.key, r.inSetup, r.hasCopy, r.host])).toEqual([
["E:ARIAL.TTF", true, true, "present"],
["E:LOCAL.TTF", false, true, "absent"],
["R:GONE.TTF", true, false, "absent"],
["E:CG_TIMES.TTF", false, false, "present"],
]);
});

it("keeps the spelling of the source that named the file and carries the listed object", () => {
const listing = hostListing([dir("E", [object("E", "ARIAL", "TTF", 4096)])]);
const [row] = storedObjectOrigins({ kind: "font", setupPaths: [], local: bytes(["e:arial.ttf"]), listing });
expect(row?.path).toBe("e:arial.ttf");
expect(row?.hostObject?.size).toBe(4096);
});

it("claims absence only for a drive that answered", () => {
const listing = hostListing([dir("R", [])]);
const rows = storedObjectOrigins({ kind: "graphic", setupPaths: ["R:A.GRF", "E:B.GRF"], local: [], listing });
expect(rows.map((r) => r.host)).toEqual(["absent", "unknown"]);
expect(storedObjectOrigins({ kind: "graphic", setupPaths: ["R:A.GRF"], local: [], listing: undefined }).map((r) => r.host)).toEqual(["unknown"]);
});

it("takes only the listed objects of its own kind", () => {
const listing = hostListing([dir("R", [object("R", "LOGO", "GRF"), object("R", "LBL", "ZPL"), object("R", "SOME", "FNT")])]);
expect(storedObjectOrigins({ kind: "graphic", setupPaths: [], local: [], listing }).map((r) => r.key)).toEqual(["R:LOGO.GRF"]);
expect(storedObjectOrigins({ kind: "font", setupPaths: [], local: [], listing }).map((r) => r.key)).toEqual(["R:SOME.FNT"]);
});
});

describe("originState", () => {
const row = (inSetup: boolean, hasCopy: boolean, host: "present" | "absent" | "unknown") => ({ key: "R:X.GRF", path: "R:X.GRF", inSetup, hasCopy, host });

it("says nothing about the printer before a listing", () => {
expect(originState(row(true, true, "unknown"))).toBe("unread");
});

it("separates a setup entry that never arrived from a local file nothing provisions", () => {
expect(originState(row(true, true, "absent"))).toBe("missingOnPrinter");
expect(originState(row(false, true, "absent"))).toBe("notOnPrinter");
});

it("marks a listed file as the printer's own whenever nothing here can supply it", () => {
expect(originState(row(false, false, "present"))).toBe("printerOnly");
expect(originState(row(true, false, "present"))).toBe("printerOnly");
expect(originState(row(false, true, "present"))).toBe("onPrinter");
expect(originState(row(true, true, "present"))).toBe("onPrinter");
});
});

describe("deleteKnowledge", () => {
const row = (inSetup: boolean, hasCopy: boolean) => ({ key: "R:X.GRF", path: "R:X.GRF", inSetup, hasCopy, host: "present" as const });

it("names the copy a delete leaves behind, and promises no re-upload without the file", () => {
expect(deleteKnowledge(row(true, true))).toBe("setupReuploads");
expect(deleteKnowledge(row(true, false))).toBe("noCopy");
expect(deleteKnowledge(row(false, true))).toBe("localCopy");
expect(deleteKnowledge(row(false, false))).toBe("noCopy");
});
});
108 changes: 108 additions & 0 deletions packages/core/src/lib/storedObjectOrigins.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { hostObjectKind, hostObjectPath, type HostDirectory, type HostObject, type HostObjectKind } from "./hostDirectory";
import { isWritableDevice, splitStorageKey, storageKey } from "./storagePath";

export type StoredObjectKind = Extract<HostObjectKind, "graphic" | "font">;

export interface HostListing {
/** Only the drives that answered for themselves, so absence on them is real. */
devices: string[];
objects: HostObject[];
free: { device: string; bytesFree: number }[];
}

export function hostListing(directories: readonly HostDirectory[]): HostListing {
// Two DIR blocks from one drive would name its free space twice.
const perDevice = new Map<string, HostDirectory>();
for (const dir of directories) if (!perDevice.has(dir.device)) perDevice.set(dir.device, dir);
const blocks = [...perDevice.values()];
return {
// A block listing another drive's objects is an alias: it answers for the target, not for itself.
devices: blocks.filter(namesItsOwn).map((d) => d.device),
objects: [...new Map(blocks.flatMap((d) => d.objects).map((o) => [storageKey(hostObjectPath(o)), o])).values()].sort(firmwareLast),
// Room is only worth naming where an upload may write.
free: blocks.flatMap((d) =>
d.bytesFree === undefined || !isWritableDevice(d.device) || !namesItsOwn(d) ? [] : [{ device: d.device, bytesFree: d.bytesFree }],
),
};
}

const namesItsOwn = (d: HostDirectory): boolean => d.objects.every((o) => o.device === d.device);

function firmwareLast(a: HostObject, b: HostObject): number {
return Number(!isWritableDevice(a.device)) - Number(!isWritableDevice(b.device));
}

export const listingHolds = (listing: HostListing, path: string): boolean =>
listing.objects.some((o) => storageKey(hostObjectPath(o)) === storageKey(path));

export type HostPresence = "present" | "absent" | "unknown";

export interface StoredObjectOrigin {
/** Keyed per drive: one name on two drives is two files. */
key: string;
/** The owner's own spelling, not the normalised key. */
path: string;
inSetup: boolean;
/** The bytes exist off the printer too. */
hasCopy: boolean;
host: HostPresence;
hostObject?: HostObject;
}

/** A file the app names. The bytes may be gone, as with a recall whose cache was cleared. */
export interface LocalObject {
path: string;
hasBytes: boolean;
}

export interface StoredObjectSources {
kind: StoredObjectKind;
setupPaths: readonly string[];
local: readonly LocalObject[];
listing: HostListing | undefined;
}

/** Rows run local, then setup-only, then printer-only. */
export function storedObjectOrigins({ kind, setupPaths, local, listing }: StoredObjectSources): StoredObjectOrigin[] {
const rows = new Map<string, StoredObjectOrigin>();
const row = (path: string): StoredObjectOrigin => {
const key = storageKey(path);
const found = rows.get(key);
if (found) return found;
const fresh: StoredObjectOrigin = { key, path, inSetup: false, hasCopy: false, host: "unknown" };
rows.set(key, fresh);
return fresh;
};
for (const object of local) row(object.path).hasCopy ||= object.hasBytes;
// Bytes are the caller's claim, so a setup entry sets no `hasCopy`.
for (const path of setupPaths) row(path).inSetup = true;
for (const object of listing?.objects ?? []) {
if (hostObjectKind(object.ext) !== kind) continue;
const found = row(hostObjectPath(object));
found.host = "present";
found.hostObject = object;
}
if (listing) {
for (const found of rows.values()) {
if (found.host === "unknown" && listing.devices.includes(splitStorageKey(found.key).device)) found.host = "absent";
}
}
return [...rows.values()];
}

/** `unread` means the drive never answered, so no row may claim anything. */
export type OriginState = "unread" | "missingOnPrinter" | "notOnPrinter" | "onPrinter" | "printerOnly";

export function originState(o: StoredObjectOrigin): OriginState {
if (o.host === "unknown") return "unread";
if (o.host === "absent") return o.inSetup ? "missingOnPrinter" : "notOnPrinter";
return o.hasCopy ? "onPrinter" : "printerOnly";
}

export type DeleteKnowledge = "setupReuploads" | "localCopy" | "noCopy";

export function deleteKnowledge(o: StoredObjectOrigin): DeleteKnowledge {
// A setup entry without its file restores nothing, so it counts as no copy.
if (!o.hasCopy) return "noCopy";
return o.inSetup ? "setupReuploads" : "localCopy";
}
5 changes: 5 additions & 0 deletions packages/core/src/registry/image.ts
Original file line number Diff line number Diff line change
Expand Up @@ -363,6 +363,11 @@ export function setupGraphicState(
return entry.gfa === cached ? 'current' : 'stale';
}

/** A verbatim ^GF in the document is a copy like any other. */
export function hasLocalGraphicBytes(p: ImageProps): boolean {
return getImage(p.imageId) !== undefined || p._gfaCache !== undefined || p.rawGf !== undefined;
}

/** Whether an encode is worth attempting. The encode itself may still refuse its result. */
export function canSendSetupGraphic(p: ImageProps): boolean {
if (uploadKey(p) === undefined || cacheTooLarge(p)) return false;
Expand Down
Loading
Loading