From 19f1bc080b268304ce351bca9603597ae3b5de96 Mon Sep 17 00:00:00 2001 From: Dave Date: Sat, 24 Oct 2026 22:47:37 +0200 Subject: [PATCH] feat(item-size): parse the size out of a description MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The POS shortlist ranked by shared words only, so "BOLT HEX M16X60 GR8.8 ZP" put the M22x65 above the M16x60 — every bolt shares "BOLT 8.8 ZP" and scores 0.75. The right size never reached the AI and the price check showed the wrong item. parseItemSize pulls the type, thread, length, grade, finish and other dimensions out of free text; compareSizes says whether a candidate is the same size and, when it is not, in plain words what differs. Pure and client-safe: no PocketBase, no AI. Co-Authored-By: Claude Opus 5.5 --- src/lib/item-size.test.ts | 332 ++++++++++++++++++++++++++++ src/lib/item-size.ts | 442 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 774 insertions(+) create mode 100644 src/lib/item-size.test.ts create mode 100644 src/lib/item-size.ts diff --git a/src/lib/item-size.test.ts b/src/lib/item-size.test.ts new file mode 100644 index 0000000..857375b --- /dev/null +++ b/src/lib/item-size.test.ts @@ -0,0 +1,332 @@ +/** + * item-size — the size parser and the line ↔ candidate comparison. + * + * The descriptions in the "real descriptions" block below are the ones + * that came off live data on 2026-10-24, where the POS shortlist + * ranked an M22 bolt above the M16 the customer asked for because + * they share the words "BOLT 8.8 ZP". + */ + +import { describe, expect, it } from "vitest"; + +import { + compareSizes, + describeSize, + parseItemSize, + SIZE_RANK_EXACT, + SIZE_RANK_KIND, + SIZE_RANK_NEAR_BASE, + SIZE_RANK_OTHER, + SIZE_RANK_THREAD, +} from "./item-size"; + +describe("parseItemSize — the real descriptions", () => { + it("reads a shorthand bolt", () => { + expect(parseItemSize("BOLT HEX M16X60 GR8.8 ZP")).toEqual({ + kind: "bolt", + variant: null, + thread: "M16", + length: 60, + grade: "8.8", + finish: "zp", + dims: [], + }); + }); + + it("reads a spelled-out bolt", () => { + expect(parseItemSize("Hex bolt M16 x 60 grade 8.8 zinc plated")).toEqual({ + kind: "bolt", + variant: null, + thread: "M16", + length: 60, + grade: "8.8", + finish: "zp", + dims: [], + }); + }); + + it("reads a bare grade and a different size", () => { + expect(parseItemSize("M22x65 BOLT 8.8 ZP")).toEqual({ + kind: "bolt", + variant: null, + thread: "M22", + length: 65, + grade: "8.8", + finish: "zp", + dims: [], + }); + }); + + it("reads a nyloc nut in both word orders", () => { + for (const text of ["NUT NYLOC M16 ZINC", "Nyloc nut M16 zinc plated"]) { + expect(parseItemSize(text)).toEqual({ + kind: "nut", + variant: "nyloc", + thread: "M16", + length: null, + grade: null, + finish: "zp", + dims: [], + }); + } + }); + + it("reads a flat washer", () => { + expect(parseItemSize("WASHER FLAT M16 ZINC PLATED")).toEqual({ + kind: "washer", + variant: "flat", + thread: "M16", + length: null, + grade: null, + finish: "zp", + dims: [], + }); + }); + + it("reads a spring washer", () => { + const s = parseItemSize("SPRING WASHER M16 ZP"); + expect(s.kind).toBe("washer"); + expect(s.variant).toBe("spring"); + }); + + it("reads a funnel with two dimensions and no kind", () => { + expect(parseItemSize('Funnel 150mm PVC round 2 piece Flexible ("6") Round')).toEqual({ + kind: null, + variant: null, + thread: null, + length: null, + grade: null, + finish: null, + dims: [ + { value: 150, unit: "mm" }, + { value: 6, unit: "in" }, + ], + }); + }); + + it("reads a welding rod", () => { + expect(parseItemSize("Welding rod 2.5mm")).toEqual({ + kind: null, + variant: null, + thread: null, + length: null, + grade: null, + finish: null, + dims: [{ value: 2.5, unit: "mm" }], + }); + }); + + it("reads a U bolt with a bracketed length", () => { + expect(parseItemSize("U BOLT M24(400) HDG")).toEqual({ + kind: "bolt", + variant: null, + thread: "M24", + length: 400, + grade: null, + finish: "hdg", + dims: [], + }); + }); +}); + +describe("parseItemSize — the attributes", () => { + it("keeps the type word in either order", () => { + expect(parseItemSize("hex bolt M16").kind).toBe("bolt"); + expect(parseItemSize("bolt hex M16").kind).toBe("bolt"); + }); + + it("knows the other fastener types", () => { + expect(parseItemSize("M6x30 SET SCR A2").kind).toBe("setscrew"); + expect(parseItemSize("M6x30 set screw A2").kind).toBe("setscrew"); + expect(parseItemSize("M8 cap screw 40").kind).toBe("cap_screw"); + expect(parseItemSize("M10 threaded stud 100").kind).toBe("stud"); + expect(parseItemSize("Chemical anchor 300").kind).toBe("anchor"); + expect(parseItemSize("4.8 rivet 16").kind).toBe("rivet"); + }); + + it("leaves an unknown type alone", () => { + expect(parseItemSize("Funnel 150mm PVC").kind).toBeNull(); + expect(parseItemSize("Welding rod 2.5mm").kind).toBeNull(); + }); + + it("reads the thread from every spelling", () => { + expect(parseItemSize("M16").thread).toBe("M16"); + expect(parseItemSize("M16X60").thread).toBe("M16"); + expect(parseItemSize("M16 x 60").thread).toBe("M16"); + expect(parseItemSize("m16x60mm").thread).toBe("M16"); + expect(parseItemSize("M 16").thread).toBe("M16"); + }); + + it("reads an imperial thread", () => { + expect(parseItemSize('1/2" bolt').thread).toBe('1/2"'); + expect(parseItemSize("1/2 UNC bolt").thread).toBe("1/2UNC"); + }); + + it("reads the length that follows the thread", () => { + expect(parseItemSize("M16x60").length).toBe(60); + expect(parseItemSize("M16 X 60MM").length).toBe(60); + expect(parseItemSize("M16 × 60").length).toBe(60); + expect(parseItemSize("M16x60mm").length).toBe(60); + expect(parseItemSize("M16").length).toBeNull(); + }); + + it("reads the grade in every spelling", () => { + expect(parseItemSize("bolt GR8.8").grade).toBe("8.8"); + expect(parseItemSize("bolt grade 8.8").grade).toBe("8.8"); + expect(parseItemSize("bolt GR 10.9").grade).toBe("10.9"); + expect(parseItemSize("bolt 12.9").grade).toBe("12.9"); + expect(parseItemSize("bolt 4.6").grade).toBe("4.6"); + }); + + it("does not read a plain number as a grade", () => { + expect(parseItemSize("bolt 60mm").grade).toBeNull(); + expect(parseItemSize("washer 150").grade).toBeNull(); + }); + + it("reads the finish", () => { + expect(parseItemSize("bolt zp").finish).toBe("zp"); + expect(parseItemSize("bolt zinc").finish).toBe("zp"); + expect(parseItemSize("bolt zinc plated").finish).toBe("zp"); + expect(parseItemSize("bolt hdg").finish).toBe("hdg"); + expect(parseItemSize("bolt galv").finish).toBe("hdg"); + expect(parseItemSize("bolt hot dip galvanised").finish).toBe("hdg"); + expect(parseItemSize("bolt ss").finish).toBe("ss"); + expect(parseItemSize("bolt stainless").finish).toBe("ss"); + expect(parseItemSize("bolt 304").finish).toBe("ss"); + expect(parseItemSize("bolt 316").finish).toBe("ss"); + expect(parseItemSize("bolt black").finish).toBe("black"); + expect(parseItemSize("bolt self colour").finish).toBe("black"); + expect(parseItemSize("bolt plain").finish).toBeNull(); + }); + + it("reads a dimension with its unit", () => { + expect(parseItemSize("pipe 150mm").dims).toEqual([{ value: 150, unit: "mm" }]); + expect(parseItemSize("drum 20L").dims).toEqual([{ value: 20, unit: "l" }]); + expect(parseItemSize("plate 2.5mm").dims).toEqual([{ value: 2.5, unit: "mm" }]); + expect(parseItemSize('plate 6"').dims).toEqual([{ value: 6, unit: "in" }]); + }); + + it("does not read the thread length as a dimension", () => { + expect(parseItemSize("M16x60mm").dims).toEqual([]); + expect(parseItemSize("M16 X 60MM").dims).toEqual([]); + expect(parseItemSize("U BOLT M24(400) HDG").dims).toEqual([]); + }); + + it("survives rubbish input", () => { + expect(parseItemSize("")).toEqual({ + kind: null, + variant: null, + thread: null, + length: null, + grade: null, + finish: null, + dims: [], + }); + expect(parseItemSize("the and of").kind).toBeNull(); + }); +}); + +describe("compareSizes", () => { + const line = parseItemSize("BOLT HEX M16X60 GR8.8 ZP"); + + it("is exact when every attribute the line gives matches", () => { + const cand = parseItemSize("HEX BOLT M16X60 8.8 ZP"); + const cmp = compareSizes(line, cand); + expect(cmp.exact).toBe(true); + expect(cmp.differs).toEqual([]); + expect(cmp.rank).toBe(SIZE_RANK_EXACT); + }); + + it("spells the differences out in plain words", () => { + const cmp = compareSizes(line, parseItemSize("M22x65 BOLT 8.8 ZP")); + expect(cmp.exact).toBe(false); + expect(cmp.differs).toContain("thread M22 vs M16"); + expect(cmp.differs).toContain("length 65 vs 60"); + }); + + it("spells a finish difference out", () => { + const cmp = compareSizes(line, parseItemSize("M16X60 BOLT 8.8 HDG")); + expect(cmp.differs).toContain("finish HDG vs ZP"); + }); + + it("never counts an attribute the line does not mention", () => { + // The line says nothing about the finish — a candidate with a + // different finish must not be marked down for it. + const bare = parseItemSize("BOLT M16X60"); + const cmp = compareSizes(bare, parseItemSize("BOLT M16X60 HDG")); + expect(cmp.differs).toEqual([]); + expect(cmp.exact).toBe(true); + }); + + it("counts an attribute the candidate does not mention", () => { + const cmp = compareSizes(line, parseItemSize("HEX BOLT M16")); + expect(cmp.exact).toBe(false); + expect(cmp.differs).toContain("length ? vs 60"); + expect(cmp.differs).toContain("grade ? vs 8.8"); + expect(cmp.differs).toContain("finish ? vs zp"); + }); + + it("is never exact when the line says nothing about size", () => { + const bare = parseItemSize("Widget"); + const cmp = compareSizes(bare, parseItemSize("Widget")); + expect(cmp.exact).toBe(false); + expect(cmp.rank).toBe(SIZE_RANK_OTHER); + }); + + it("is exact when the line only names a type and the candidate is the same type", () => { + const cmp = compareSizes(parseItemSize("Hex bolt"), parseItemSize("HEX BOLT M16")); + expect(cmp.exact).toBe(true); + }); + + it("ranks same kind + thread + length first", () => { + const cmp = compareSizes(line, parseItemSize("HEX BOLT M16X60 8.8 ZP")); + expect(cmp.rank).toBe(SIZE_RANK_EXACT); + }); + + it("ranks same kind + thread by how near the length is", () => { + const near = compareSizes(line, parseItemSize("HEX BOLT M16X65 8.8 ZP")); + const far = compareSizes(line, parseItemSize("HEX BOLT M16X170 8.8 ZP")); + expect(near.rank).toBe(SIZE_RANK_NEAR_BASE + 5); + expect(far.rank).toBe(SIZE_RANK_NEAR_BASE + 110); + expect(near.rank).toBeLessThan(far.rank); + }); + + it("ranks the same thread above the same kind with a different thread", () => { + const sameThread = compareSizes(line, parseItemSize("NUT M16 8.8 ZP")); + const sameKind = compareSizes(line, parseItemSize("M22X60 BOLT 8.8 ZP")); + expect(sameThread.rank).toBe(SIZE_RANK_THREAD); + expect(sameKind.rank).toBe(SIZE_RANK_KIND); + expect(sameThread.rank).toBeLessThan(sameKind.rank); + }); + + it("ranks an M16x60 bolt above an M22x65 bolt", () => { + const right = compareSizes(line, parseItemSize("HEX BOLT M16X60 8.8 ZP")); + const wrong = compareSizes(line, parseItemSize("HEX BOLT M22X65 8.8 ZP")); + expect(right.rank).toBeLessThan(wrong.rank); + }); + + it("ranks everything else last", () => { + const cmp = compareSizes(line, parseItemSize("Funnel 150mm PVC")); + expect(cmp.rank).toBe(SIZE_RANK_OTHER); + }); + + it("compares dimensions", () => { + const funnel = parseItemSize('Funnel 150mm PVC round 2 piece Flexible ("6") Round'); + const same = parseItemSize("Funnel 150mm PVC"); + const other = parseItemSize("Funnel 100mm PVC"); + expect(compareSizes(funnel, same).differs).toContain("size ? vs 6in"); + expect(compareSizes(funnel, other).differs).toContain("size 100mm vs 150mm"); + }); +}); + +describe("describeSize", () => { + it("writes one short line", () => { + expect(describeSize(parseItemSize("BOLT HEX M16X60 GR8.8 ZP"))).toBe( + "bolt, thread M16, length 60, grade 8.8, finish zp", + ); + }); + + it("says nothing when there is nothing to say", () => { + expect(describeSize(parseItemSize("Widget"))).toBe(""); + }); +}); diff --git a/src/lib/item-size.ts b/src/lib/item-size.ts new file mode 100644 index 0000000..fec8333 --- /dev/null +++ b/src/lib/item-size.ts @@ -0,0 +1,442 @@ +/** + * item-size — parse the size out of a free-text item description. + * + * Pure and client-safe: no PocketBase, no AI, no DOM. Both the server + * (POS matcher, quote assistant) and the client (price check popover) + * can use it. + * + * ## Why + * + * The POS shortlist used to rank candidates by *shared words* only. For + * "BOLT HEX M16X60 GR8.8 ZP" every bolt in the catalogue shares + * "BOLT 8.8 ZP" and scores 0.75 — the M16 that the customer actually + * asked for never reached the AI, and the price check showed the price + * of an M22. + * + * Size is the thing that tells two otherwise identical items apart. + * This module pulls it out of the text so the matcher can compare it. + * + * ## What comes out + * + * parseItemSize("BOLT HEX M16X60 GR8.8 ZP") + * → { kind: "bolt", variant: null, thread: "M16", length: 60, + * grade: "8.8", finish: "zp", dims: [] } + * + * parseItemSize("NUT NYLOC M16 ZINC") + * → { kind: "nut", variant: "nyloc", thread: "M16", …, finish: "zp" } + * + * parseItemSize('Funnel 150mm PVC round 2 piece Flexible ("6") Round') + * → { kind: null, …, dims: [{ value: 150, unit: "mm" }, + * { value: 6, unit: "in" }] } + * + * `compareSizes(line, candidate)` then says whether a candidate is the + * same size as the line, and in plain words what differs when it is + * not. + */ + +/** The normalised type of the item. `null` when the text names no type. */ +export type ItemSizeKind = + | "bolt" + | "nut" + | "washer" + | "setscrew" + | "cap_screw" + | "stud" + | "anchor" + | "rivet" + | null; + +/** A qualifier of the kind — "nyloc" nut, "flat"/"spring" washer. */ +export type ItemSizeVariant = "nyloc" | "flat" | "spring" | null; + +/** The surface finish. */ +export type ItemSizeFinish = "zp" | "hdg" | "ss" | "black" | null; + +/** A number with its unit — "150mm", "20L", 6". */ +export interface ItemSizeDim { + value: number; + /** Normalised unit: "mm", "cm", "m", "l", "kg", "g", "in", "ft". */ + unit: string; +} + +/** Everything the parser could tell about one description. */ +export interface ItemSize { + kind: ItemSizeKind; + variant: ItemSizeVariant; + /** Metric ("M16") or imperial ("1/2UNC", "1/2\"") thread. */ + thread: string | null; + /** The length that follows the thread — 60 in "M16x60". */ + length: number | null; + /** Property class / grade — "8.8", "10.9". */ + grade: string | null; + finish: ItemSizeFinish; + /** Other numbers with units, for non-fasteners. */ + dims: ItemSizeDim[]; +} + +/** The result of comparing a line's size with a candidate's size. */ +export interface SizeCompare { + /** True when every attribute the LINE states is matched by the candidate. */ + exact: boolean; + /** Plain words: "length 65 vs 60", "thread M22 vs M16". */ + differs: string[]; + /** Sort key — lower is a better match. */ + rank: number; +} + +// ─── Ranks ───────────────────────────────────────────────────────────── + +/** Same kind, same thread, same length. */ +export const SIZE_RANK_EXACT = 0; +/** Same kind + thread, different length — the bucket starts here and the + * length distance is added, so the nearest length sorts first. */ +export const SIZE_RANK_NEAR_BASE = 10; +/** Same thread, but a different kind (or no kind at all). */ +export const SIZE_RANK_THREAD = 1000; +/** Same kind, but a different thread. */ +export const SIZE_RANK_KIND = 2000; +/** Nothing comparable. */ +export const SIZE_RANK_OTHER = 3000; + +// ─── Word lists ──────────────────────────────────────────────────────── + +/** + * Type words, longest phrase first so "hex head bolt" wins over "bolt". + * The phrase only decides the *kind* — the variant ("flat", "spring", + * "nyloc") is worked out afterwards from the kind. + */ +const KIND_PHRASES: Array<{ re: RegExp; kind: ItemSizeKind }> = [ + { re: /\block\s*nuts?\b/, kind: "nut" }, + { re: /\bhex\s*nuts?\b/, kind: "nut" }, + { re: /\bnuts?\b/, kind: "nut" }, + { re: /\bhex\s*head\s*bolts?\b/, kind: "bolt" }, + { re: /\bhex\s*bolts?\b/, kind: "bolt" }, + { re: /\bbolts?\b/, kind: "bolt" }, + { re: /\bwashers?\b/, kind: "washer" }, + { re: /\bset\s*screws?\b/, kind: "setscrew" }, + { re: /\bset\s*scr\b/, kind: "setscrew" }, + { re: /\bgrub\s*screws?\b/, kind: "setscrew" }, + { re: /\bcap\s*screws?\b/, kind: "cap_screw" }, + { re: /\bsocket\s*head\s*cap\s*screws?\b/, kind: "cap_screw" }, + { re: /\bstuds?\b/, kind: "stud" }, + { re: /\bchemical\s*anchors?\b/, kind: "anchor" }, + { re: /\bwedge\s*anchors?\b/, kind: "anchor" }, + { re: /\banchors?\b/, kind: "anchor" }, + { re: /\brivets?\b/, kind: "rivet" }, +]; + +/** The word used to pull same-kind rows out of the POS catalogue. */ +export const KIND_WORDS: Record = { + bolt: "bolt", + nut: "nut", + washer: "washer", + setscrew: "screw", + cap_screw: "screw", + stud: "stud", + anchor: "anchor", + rivet: "rivet", +}; + +/** Property classes we recognise. A bare number only counts when it is + * one of these — "8.8" is a grade, "60" is a length, "150" is a size. */ +const KNOWN_GRADES = new Set(["4.6", "4.8", "5.6", "6.8", "8.8", "9.8", "10.9", "12.9"]); + +/** + * Units we read a dimension in. The word boundary after the unit is + * what keeps "8.8 GRADE" from being read as 8.8 grams — after the "g" + * of GRADE comes an "r", so there is no boundary and the clause fails. + */ +const DIM_RE = /(\d+(?:\.\d+)?)\s*(mm|cm|litre|liter|inch|in|ft|kg|lt|l|g|m)\b/gi; + +/** An inch mark: 6" */ +const INCH_RE = /(\d+(?:\.\d+)?)\s*"/g; + +/** Finish words, in the order we test them. */ +const FINISH_PATTERNS: Array<{ re: RegExp; finish: ItemSizeFinish }> = [ + { re: /\bzinc\s*plated\b/, finish: "zp" }, + { re: /\bhot\s*dip\s*galv(?:anised|anized)?\b/, finish: "hdg" }, + { re: /\bgalv(?:anised|anized)?\b/, finish: "hdg" }, + { re: /\bhdg\b/, finish: "hdg" }, + { re: /\bstainless\b/, finish: "ss" }, + { re: /\bss\b/, finish: "ss" }, + { re: /\b304\b/, finish: "ss" }, + { re: /\b316\b/, finish: "ss" }, + { re: /\bzinc\b/, finish: "zp" }, + { re: /\bzp\b/, finish: "zp" }, + { re: /\bself\s*colou?r\b/, finish: "black" }, + { re: /\bblack\b/, finish: "black" }, +]; + +// ─── Helpers ─────────────────────────────────────────────────────────── + +/** Replace a span of the text with spaces so later passes ignore it. */ +function mask(text: string, from: number, to: number): string { + if (from < 0 || to <= from) return text; + return text.slice(0, from) + " ".repeat(to - from) + text.slice(to); +} + +function parseKind(text: string): { kind: ItemSizeKind; variant: ItemSizeVariant } { + // "nyloc" names the type by itself — "NYLOC M16" is a nut. + if (/\bnyloc?k?\b/.test(text)) return { kind: "nut", variant: "nyloc" }; + + let kind: ItemSizeKind = null; + for (const entry of KIND_PHRASES) { + if (entry.re.test(text)) { + kind = entry.kind; + break; + } + } + if (kind === null) return { kind: null, variant: null }; + + // The variant of the kind — the word may come before or after the + // type word ("flat washer" and "washer flat" are the same thing). + if (kind === "washer") { + if (/\bspring\b/.test(text)) return { kind, variant: "spring" }; + if (/\bflat\b/.test(text)) return { kind, variant: "flat" }; + } + return { kind, variant: null }; +} + +/** + * The metric thread and the length that follows it. + * + * Handles "M16", "M16X60", "M16 x 60", "m16x60mm" and the bracketed + * form "M24(400)". + */ +function parseThread( + text: string, +): { thread: string | null; length: number | null; spanStart: number; spanEnd: number } { + const metric = /\bM\s*(\d{1,3}(?:\.\d)?)/i.exec(text); + if (metric) { + const thread = `M${metric[1]}`; + const rest = text.slice(metric.index + metric[0].length); + let length: number | null = null; + let spanEnd = metric.index + metric[0].length; + const byX = /^\s*[x×]\s*(\d+(?:\.\d+)?)/i.exec(rest); + if (byX) { + length = Number(byX[1]); + spanEnd += byX[0].length; + } else { + const byBracket = /^\s*\(\s*(\d+(?:\.\d+)?)/.exec(rest); + if (byBracket) { + length = Number(byBracket[1]); + spanEnd += byBracket[0].length; + } + } + return { thread, length, spanStart: metric.index, spanEnd }; + } + + // Imperial: 1/2", 1/2 UNC, 1/4-20 UNC + const imperial = /(\d{1,2})\s*\/\s*(\d{1,2})\s*(UNC|UNF|BSW|BSF)?/i.exec(text); + if (imperial) { + const suffix = imperial[3] ? imperial[3].toUpperCase() : ""; + const after = text.slice(imperial.index + imperial[0].length); + const inchMark = /^\s*"/.test(after) ? '"' : ""; + return { + thread: `${imperial[1]}/${imperial[2]}${suffix}${inchMark}`, + length: null, + spanStart: imperial.index, + spanEnd: imperial.index + imperial[0].length, + }; + } + + return { thread: null, length: null, spanStart: 0, spanEnd: 0 }; +} + +function parseGrade(text: string): string | null { + // "GR8.8", "grade 8.8", "class 10.9" + const labelled = /\b(?:gr|grade|class)\s*\.?\s*(\d{1,2}(?:\.\d)?)\b/i.exec(text); + if (labelled) { + const value = labelled[1]; + if (KNOWN_GRADES.has(value)) return value; + } + // A bare number only counts when it is a known property class. + for (const m of text.matchAll(/\b(\d{1,2}\.\d)\b/g)) { + if (KNOWN_GRADES.has(m[1])) return m[1]; + } + return null; +} + +function parseFinish(text: string): ItemSizeFinish { + for (const entry of FINISH_PATTERNS) { + if (entry.re.test(text)) return entry.finish; + } + return null; +} + +function parseDims(text: string): ItemSizeDim[] { + const found: Array<{ dim: ItemSizeDim; at: number }> = []; + for (const m of text.matchAll(DIM_RE)) { + const value = Number(m[1]); + if (!Number.isFinite(value)) continue; + found.push({ dim: { value, unit: m[2].toLowerCase() }, at: m.index ?? 0 }); + } + for (const m of text.matchAll(INCH_RE)) { + const value = Number(m[1]); + if (!Number.isFinite(value)) continue; + found.push({ dim: { value, unit: "in" }, at: m.index ?? 0 }); + } + // Keep the order the numbers appeared in. + return found.sort((a, b) => a.at - b.at).map((f) => f.dim); +} + +/** + * Parse the size out of a description. + * + * Never throws and never guesses: an attribute the text does not state + * stays `null` so it never counts against a candidate later. + */ +export function parseItemSize(text: string): ItemSize { + const raw = typeof text === "string" ? text : ""; + const lower = raw.toLowerCase(); + + const { kind, variant } = parseKind(lower); + const { thread, length, spanStart, spanEnd } = parseThread(raw); + + // The thread + length run is not a dimension — "M16x60mm" is one bolt, + // not a 60 mm pipe. Blank it out before the dimension pass. + const dimText = thread ? mask(raw, spanStart, spanEnd) : raw; + + return { + kind, + variant, + thread, + length, + grade: parseGrade(lower), + finish: parseFinish(lower), + dims: parseDims(dimText), + }; +} + +// ─── Comparison ──────────────────────────────────────────────────────── + +/** Does the line say anything about size at all? */ +export function hasSizeInfo(size: ItemSize): boolean { + return ( + size.kind !== null || + size.variant !== null || + size.thread !== null || + size.length !== null || + size.grade !== null || + size.finish !== null || + size.dims.length > 0 + ); +} + +/** "length ? vs 60" — the candidate simply does not say. */ +const UNKNOWN = "?"; + +/** + * Compare the line's size with a candidate's. + * + * - `exact` is true only when every attribute the LINE states is + * matched by the candidate. Attributes the line does not mention never + * count against the candidate. + * - `differs` spells the differences out in plain words, candidate + * first: "length 65 vs 60". + * - `rank` sorts the candidates: same kind + thread + length first, + * then same kind + thread with the nearest length, then the same + * thread, then the rest. + */ +export function compareSizes(line: ItemSize, cand: ItemSize): SizeCompare { + const differs: string[] = []; + + if (line.kind !== null) { + if (cand.kind === null) differs.push(`kind ${UNKNOWN} vs ${line.kind}`); + else if (cand.kind !== line.kind) differs.push(`kind ${cand.kind} vs ${line.kind}`); + } + + if (line.variant !== null) { + if (cand.variant === null) differs.push(`variant ${UNKNOWN} vs ${line.variant}`); + else if (cand.variant !== line.variant) { + differs.push(`variant ${cand.variant} vs ${line.variant}`); + } + } + + if (line.thread !== null) { + if (cand.thread === null) differs.push(`thread ${UNKNOWN} vs ${line.thread}`); + else if (cand.thread !== line.thread) differs.push(`thread ${cand.thread} vs ${line.thread}`); + } + + if (line.length !== null) { + if (cand.length === null) differs.push(`length ${UNKNOWN} vs ${line.length}`); + else if (cand.length !== line.length) { + differs.push(`length ${cand.length} vs ${line.length}`); + } + } + + if (line.grade !== null) { + if (cand.grade === null) differs.push(`grade ${UNKNOWN} vs ${line.grade}`); + else if (cand.grade !== line.grade) differs.push(`grade ${cand.grade} vs ${line.grade}`); + } + + if (line.finish !== null) { + if (cand.finish === null) differs.push(`finish ${UNKNOWN} vs ${line.finish}`); + else if (cand.finish !== line.finish) { + differs.push(`finish ${cand.finish.toUpperCase()} vs ${line.finish.toUpperCase()}`); + } + } + + for (const dim of line.dims) { + const sameUnit = cand.dims.find((d) => d.unit === dim.unit); + if (sameUnit) { + if (sameUnit.value !== dim.value) { + differs.push(`size ${sameUnit.value}${sameUnit.unit} vs ${dim.value}${dim.unit}`); + } + } else { + // The candidate does not state a dimension in this unit at all. + differs.push(`size ${UNKNOWN} vs ${dim.value}${dim.unit}`); + } + } + + const exact = hasSizeInfo(line) && differs.length === 0; + + return { exact, differs, rank: sizeRank(line, cand) }; +} + +/** + * The sort key. Lower is better: + * + * 0 same kind + thread + length + * 10 + Δlen same kind + thread, nearest length first + * 1000 same thread + * 2000 same kind, different thread + * 3000 nothing comparable + */ +export function sizeRank(line: ItemSize, cand: ItemSize): number { + if (!hasSizeInfo(line)) return SIZE_RANK_OTHER; + + const kindSame = line.kind !== null && cand.kind !== null && cand.kind === line.kind; + const threadSame = + line.thread !== null && cand.thread !== null && cand.thread === line.thread; + const lengthSame = + line.length !== null && cand.length !== null && cand.length === line.length; + + if (kindSame && threadSame && lengthSame) return SIZE_RANK_EXACT; + if (kindSame && threadSame) { + const gap = + line.length !== null && cand.length !== null + ? Math.min(999, Math.abs(line.length - cand.length)) + : 0; + return SIZE_RANK_NEAR_BASE + gap; + } + if (threadSame) return SIZE_RANK_THREAD; + if (kindSame) return SIZE_RANK_KIND; + return SIZE_RANK_OTHER; +} + +/** + * One short line describing a parsed size — for the AI prompt and the + * popover. Empty when the text said nothing about size. + */ +export function describeSize(size: ItemSize): string { + const bits: string[] = []; + if (size.kind) bits.push(size.kind); + if (size.variant) bits.push(size.variant); + if (size.thread) bits.push(`thread ${size.thread}`); + if (size.length !== null) bits.push(`length ${size.length}`); + if (size.grade) bits.push(`grade ${size.grade}`); + if (size.finish) bits.push(`finish ${size.finish}`); + for (const dim of size.dims) bits.push(`size ${dim.value}${dim.unit}`); + return bits.join(", "); +}