Files
com.gregkilmer.www/services/content/src/sync/jobs/homebox.ts
T
Claude 5c3ab14ba3 Homebox sync: fall back to tags when /labels is gone
Newer Homebox releases renamed labels to tags (/api/v1/tags and the
items?tags= filter), so the labels call answered 404.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aSbo2D1qP8cMFQCykf7kx
2026-10-12 02:25:12 +00:00

154 lines
6.8 KiB
TypeScript

/**
* Homebox: leather and paper materials in stock, for the Store's Leather and
* Paper options. Hourly. Read only.
*
* API (Homebox's REST API, v1):
* - `POST /api/v1/users/login` with username and password gives a token
* (already prefixed `Bearer `), sent as `Authorization`.
* - `GET /api/v1/labels` (`/api/v1/tags` on newer releases) and `GET /api/v1/locations`
* name the ids to filter on.
* - `GET /api/v1/items?labels=<id>&page=&pageSize=` (or `locations=<id>`)
* lists the items, each with name, quantity and description.
*
* Which items count is a setting: HOMEBOX_LABELS (like `leather,paper`) and
* HOMEBOX_LOCATIONS, each entry `name` or `name=Group`. Without `=Group` the
* option group is the name capitalized (leather: Leather). An item is in
* stock when its quantity is above zero; a `#rrggbb` in its description
* becomes the swatch color.
*
* Written from Homebox's API documentation; not checked against a live
* server from here (see docs/content-service.md).
*/
import type { SyncContext, SyncJob } from "../types.ts";
import { mergeOptions, normalizeHex, storeOnLines, type StockGroup, type StockOption } from "./stock.ts";
export interface HomeboxFilter {
/** Label or location name in Homebox, matched case-insensitively. */
name: string;
/** Store option group it feeds. */
group: string;
}
export interface HomeboxOptions {
url: string;
user: string;
pass: string;
labels: HomeboxFilter[];
locations?: HomeboxFilter[];
everyMinutes?: number;
healthcheckUrl?: string;
}
/** `leather,paper=Paper goods` as filters. */
export function parseFilters(raw: string | undefined): HomeboxFilter[] {
return (raw ?? "")
.split(",")
.map((s) => s.trim())
.filter(Boolean)
.map((s) => {
const [name, group] = s.split("=").map((x) => x.trim());
return { name: name!, group: group || name!.charAt(0).toUpperCase() + name!.slice(1) };
});
}
export interface HomeboxItem {
name: string;
quantity: number;
description?: string;
}
/** `GET /api/v1/items`: `{ items, page, pageSize, total }`. */
export function parseItems(json: unknown): { items: HomeboxItem[]; total: number } {
const r = json as { items?: { name?: string; quantity?: number; description?: string; archived?: boolean }[]; total?: number };
if (!Array.isArray(r?.items)) throw new Error("Homebox answered with no list of items");
const items = r.items
.filter((i) => i && typeof i.name === "string" && i.name.trim() && i.archived !== true)
.map((i) => ({ name: i.name!.trim(), quantity: Number(i.quantity ?? 0) || 0, ...(i.description ? { description: i.description } : {}) }));
return { items, total: Number(r.total ?? items.length) };
}
/** `GET /api/v1/labels` or `/locations`: `[{ id, name }]` to a name to id map. */
export function parseNamed(json: unknown): Map<string, string> {
const list = Array.isArray(json) ? json : ((json as { items?: unknown[] })?.items ?? []);
const out = new Map<string, string>();
for (const x of list as { id?: string; name?: string }[]) if (x?.id && x.name) out.set(x.name.trim().toLowerCase(), x.id);
return out;
}
export function materialOptions(items: HomeboxItem[]): StockOption[] {
return mergeOptions(
items.map((i) => {
const hex = normalizeHex(/#[0-9a-f]{6}\b/i.exec(i.description ?? "")?.[0]);
return { name: i.name, ...(hex ? { hex } : {}), inStock: i.quantity > 0 };
}),
);
}
async function call(ctx: SyncContext, url: string, init: RequestInit = {}) {
return checked(await ctx.fetch(url, init));
}
async function checked(res: Response) {
if (res.status === 401 || res.status === 403) throw new Error(`Homebox refused the request (${res.status}); check HOMEBOX_USER and HOMEBOX_PASS`);
if (!res.ok) throw new Error(`Homebox answered ${res.status}`);
return res.json() as Promise<unknown>;
}
export function homeboxJob(o: HomeboxOptions): SyncJob {
const api = (path: string) => `${o.url.replace(/\/$/, "")}/api/v1${path}`;
return {
name: "homebox",
everyMinutes: o.everyMinutes ?? 60,
healthcheckUrl: o.healthcheckUrl,
async run(ctx) {
const login = (await call(ctx, api("/users/login"), {
method: "POST",
headers: { "content-type": "application/json", accept: "application/json" },
body: JSON.stringify({ username: o.user, password: o.pass, stayLoggedIn: false }),
})) as { token?: string };
if (!login?.token) throw new Error("Homebox login gave no token");
const headers = { accept: "application/json", authorization: /^Bearer /i.test(login.token) ? login.token : `Bearer ${login.token}` };
const filters: { kind: "labels" | "locations"; f: HomeboxFilter }[] = [
...o.labels.map((f) => ({ kind: "labels" as const, f })),
...(o.locations ?? []).map((f) => ({ kind: "locations" as const, f })),
];
const ids = { labels: new Map<string, string>(), locations: new Map<string, string>() };
// Newer Homebox releases call labels "tags" (`/api/v1/tags`, `items?tags=`).
let labelParam = "labels";
if (filters.some((x) => x.kind === "labels")) {
const res = await ctx.fetch(api("/labels"), { headers });
if (res.status === 404) labelParam = "tags";
ids.labels = parseNamed(res.status === 404 ? await call(ctx, api("/tags"), { headers }) : await checked(res));
}
if (filters.some((x) => x.kind === "locations")) ids.locations = parseNamed(await call(ctx, api("/locations"), { headers }));
const byGroup = new Map<string, HomeboxItem[]>();
const unknown: string[] = [];
for (const { kind, f } of filters) {
const id = ids[kind].get(f.name.toLowerCase());
if (!id) {
unknown.push(f.name);
continue;
}
const items = byGroup.get(f.group) ?? [];
for (let page = 1; page <= 20; page++) {
const p = new URLSearchParams({ [kind === "labels" ? labelParam : kind]: id, page: String(page), pageSize: "100" });
const r = parseItems(await call(ctx, api(`/items?${p}`), { headers }));
items.push(...r.items);
if (r.items.length < 100 || page * 100 >= r.total) break;
}
byGroup.set(f.group, items);
}
// A filter naming nothing in Homebox is a setup mistake: fail rather than mark everything out.
if (unknown.length) throw new Error(`No Homebox label or location named ${unknown.join(", ")}`);
const groups: StockGroup[] = [...byGroup].map(([group, items]) => ({ group, options: materialOptions(items) }));
const lines = storeOnLines(ctx, "materialStock", groups);
const counts: Record<string, number> = { lines };
for (const g of groups) counts[g.group.toLowerCase()] = g.options.filter((x) => x.inStock).length;
return { message: `${groups.map((g) => `${g.group}: ${g.options.filter((x) => x.inStock).length} in stock`).join(", ")}; on ${lines} lines`, counts };
},
};
}