/** * 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=&page=&pageSize=` (or `locations=`) * 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 { const list = Array.isArray(json) ? json : ((json as { items?: unknown[] })?.items ?? []); const out = new Map(); 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; } 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(), locations: new Map() }; // 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(); 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 = { 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 }; }, }; }