@protoboardai/views 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +13 -0
  3. package/README.md +21 -2
  4. package/package.json +46 -4
  5. package/src/applies.ts +54 -0
  6. package/src/builtins.tsx +89 -0
  7. package/src/drawing/BoardDrawingView.tsx +515 -0
  8. package/src/drawing/CircuitPcbView.tsx +494 -0
  9. package/src/drawing/DrawingControls.tsx +39 -0
  10. package/src/drawing/boards.ts +82 -0
  11. package/src/drawing/circuit.ts +413 -0
  12. package/src/drawing/kicadSchematic.ts +277 -0
  13. package/src/drawing/palettes.ts +150 -0
  14. package/src/drawing/svg.ts +445 -0
  15. package/src/drawing/usePanZoom.ts +400 -0
  16. package/src/icons.tsx +69 -0
  17. package/src/index.ts +33 -0
  18. package/src/inspector/DrcPanel.tsx +302 -0
  19. package/src/inspector/SelectionInspector.tsx +392 -0
  20. package/src/inspector/index.ts +9 -0
  21. package/src/inspector/model.ts +224 -0
  22. package/src/pair/PairView.tsx +314 -0
  23. package/src/pair/model.ts +206 -0
  24. package/src/parts/BomEditor.tsx +564 -0
  25. package/src/parts/CardPages.tsx +317 -0
  26. package/src/parts/CategoryList.tsx +72 -0
  27. package/src/parts/CloudPanel.tsx +160 -0
  28. package/src/parts/GlbPreview.tsx +116 -0
  29. package/src/parts/LibrariesManager.tsx +251 -0
  30. package/src/parts/LibraryBrowser.tsx +191 -0
  31. package/src/parts/LibraryPage.tsx +168 -0
  32. package/src/parts/PartCard.tsx +274 -0
  33. package/src/parts/PartDetail.tsx +613 -0
  34. package/src/parts/PartStatus.tsx +54 -0
  35. package/src/parts/Prices.tsx +241 -0
  36. package/src/parts/ProjectParts.tsx +174 -0
  37. package/src/parts/ProjectPartsPage.tsx +264 -0
  38. package/src/parts/SettingsPage.tsx +268 -0
  39. package/src/parts/Surface.tsx +51 -0
  40. package/src/parts/Windowed.tsx +70 -0
  41. package/src/parts/carousel.tsx +344 -0
  42. package/src/parts/index.ts +32 -0
  43. package/src/parts/library-data.ts +726 -0
  44. package/src/parts/matte.ts +69 -0
  45. package/src/parts/model.ts +590 -0
  46. package/src/parts/part-pages.ts +379 -0
  47. package/src/parts/part-status.ts +88 -0
  48. package/src/parts/revision.ts +11 -0
  49. package/src/parts/three.d.ts +5 -0
  50. package/src/parts/ui.tsx +335 -0
  51. package/src/parts/views.tsx +433 -0
  52. package/src/registry.ts +107 -0
  53. package/src/tabs.ts +118 -0
  54. package/src/theme.ts +225 -0
  55. package/src/types.ts +93 -0
  56. package/src/ui/Select.tsx +99 -0
@@ -0,0 +1,726 @@
1
+ /**
2
+ * The library's data as the shells compute it (docs/specs/vscode-extension.md
3
+ * § 2.3, PB-902): the category drill-down, the result rows and a part's
4
+ * preview from the library client's answers, and their mapping onto the
5
+ * views' models (model.ts). Plain functions with no host API and no React:
6
+ * the VS Code extension (`@protoboardai/views/parts-data`) and `protoboard dev`
7
+ * run the same code, and the unit tests run it directly.
8
+ */
9
+ import type { LibraryHit, MergedTaxonomyNode } from "@protoboardai/core/library-client";
10
+ import { LIBRARY_SORTS, compareHits, isLibrarySort, localSorts, type LibrarySort } from "@protoboardai/core/library-sort";
11
+ import { PART_TRUST_TEXT, partTrust, type PartTrust, type VerificationRecord } from "@protoboardai/core/part-trust";
12
+ import type { PartRevisionEnvelope, PartSummary, RevisionRow, SearchFacets } from "@protoboardai/cloud/client";
13
+ import type { AccountModel, CategoryRow as ViewCategory, LibraryModel, LibraryQuery, LibrarySortModel, LibrarySource, PartCardModel, PartDetailModel, PartPages, ProjectPartModel, BomModel } from "./model.js";
14
+ import { rev, revChange } from "./revision.js";
15
+
16
+ export { rev, revChange };
17
+ export * from "./part-pages.js";
18
+
19
+ export type { LibraryQuery };
20
+
21
+ /** A part the project has vendored (parts/origins.json): the pinned revision and the library it came from. */
22
+ export interface Usage {
23
+ revision?: number;
24
+ library?: string;
25
+ tier?: string;
26
+ /** The vendored definition file, project-relative. */
27
+ file?: string;
28
+ }
29
+
30
+ export const EMPTY_QUERY: LibraryQuery = { text: "", category: "", domain: "", protocol: "", sort: "relevance" };
31
+
32
+ export function parseLibraryQuery(v: unknown, fallback: LibraryQuery = EMPTY_QUERY): LibraryQuery {
33
+ const o = (v && typeof v === "object" ? v : {}) as Record<string, unknown>;
34
+ const s = (k: keyof LibraryQuery) => (typeof o[k] === "string" ? (o[k] as string).slice(0, 200) : fallback[k]);
35
+ return { text: s("text"), category: s("category"), domain: s("domain"), protocol: s("protocol"), sort: isLibrarySort(o.sort) ? o.sort : fallback.sort };
36
+ }
37
+
38
+ // ---------------------------------------------------------------- the order of the results (beta test 1, F14)
39
+
40
+ export const SORT_LABEL: Record<LibrarySort, string> = { relevance: "Relevance", name: "Name", manufacturer: "Manufacturer", updated: "Recently updated" };
41
+
42
+ /**
43
+ * The order the Library shows and the orders its control offers, so the
44
+ * control never offers one that cannot work:
45
+ *
46
+ * - an order every library asked sorts by itself (`sorts`, from their
47
+ * discovery documents) is offered, and holds over every page;
48
+ * - by name and by manufacturer are always offered: when the libraries do
49
+ * not sort, the parts loaded so far are ordered here (`local`);
50
+ * - by relevance is offered without a sorting library only when the parts
51
+ * carry a relevance; "recently updated" only when the libraries sort by it
52
+ * (a part summary has no date to order by).
53
+ *
54
+ * The order asked for is kept as the request; what is shown falls back to
55
+ * relevance, then name, while the libraries cannot make it.
56
+ */
57
+ export function sortState(requested: LibrarySort, sorts: readonly LibrarySort[], hits: readonly { relevance?: number }[]): LibrarySortModel {
58
+ const local = localSorts(hits);
59
+ const options = LIBRARY_SORTS.filter((s) => sorts.includes(s) || local.includes(s));
60
+ const value = options.includes(requested) ? requested : options.includes("relevance") ? "relevance" : "name";
61
+ return { value, options, local: !sorts.includes(value) };
62
+ }
63
+
64
+ /** The loaded parts in an order made here (`sortState` said `local`); an order that cannot be made leaves them as they came. */
65
+ export function sortLocally<T extends { name: string; partId: string; manufacturer?: string; relevance?: number }>(items: T[], sort: LibrarySort): T[] {
66
+ const cmp = compareHits(sort) as ((a: T, b: T) => number) | undefined;
67
+ return cmp ? [...items].sort(cmp) : items;
68
+ }
69
+
70
+ // ---------------------------------------------------------------- category drill-down
71
+
72
+ export interface Crumb {
73
+ path: string;
74
+ name: string;
75
+ }
76
+
77
+ export interface CategoryRow {
78
+ path: string;
79
+ name: string;
80
+ count: number;
81
+ /** It has sub-categories to drill into. */
82
+ more: boolean;
83
+ description?: string;
84
+ }
85
+
86
+ /** One level of the taxonomy (Alpha's TaxonomyFilter): where we are, the level the list shows, and its categories. */
87
+ export interface DrillDown {
88
+ /** From the top ("All categories") to the current category. */
89
+ crumbs: Crumb[];
90
+ /** The "All in …" row: the current category itself (every part at or under it). */
91
+ all: { path: string; name: string; count: number };
92
+ /**
93
+ * The category whose sub-categories the list shows: the current one, or
94
+ * its parent when the current one has none (a leaf stays listed among its
95
+ * siblings, selected, as in the alpha).
96
+ */
97
+ level: { path: string; name: string; count: number };
98
+ /** The level's sub-categories with parts (an empty one is left out unless it is the one chosen). */
99
+ rows: CategoryRow[];
100
+ /** The chosen path is not in any library's taxonomy (a library's own, or a stale filter). */
101
+ unknown?: boolean;
102
+ }
103
+
104
+ const lastName = (path: string) => {
105
+ const id = path.split(".").pop() ?? path;
106
+ return id.replace(/_/g, " ").replace(/^./, (c) => c.toUpperCase());
107
+ };
108
+
109
+ /**
110
+ * The drill-down at `path`: its ancestors as crumbs, the node's own count as
111
+ * "All in …", and one level of categories with counts: the node's children,
112
+ * or its siblings when it has none. Counts come from the taxonomy route (the
113
+ * other filters applied, the category filter not), so siblings keep their
114
+ * counts while drilling. Category paths that parts use but no taxonomy lists
115
+ * (facets) are shown under their parent too.
116
+ */
117
+ export function drillDown(nodes: MergedTaxonomyNode[], path: string, extra: { facets?: Record<string, number>; total?: number } = {}): DrillDown {
118
+ const rootCount = extra.total ?? nodes.reduce((n, x) => n + x.count, 0);
119
+ const crumbs: Crumb[] = [{ path: "", name: "All categories" }];
120
+ // each step down: the category and the list of its children
121
+ const steps: { path: string; name: string; count: number; children: MergedTaxonomyNode[] }[] = [{ path: "", name: "All categories", count: rootCount, children: nodes }];
122
+ let unknown = false;
123
+ if (path) {
124
+ const ids = path.split(".");
125
+ for (let i = 0; i < ids.length; i++) {
126
+ const p = ids.slice(0, i + 1).join(".");
127
+ const n = unknown ? undefined : steps[steps.length - 1].children.find((x) => x.path === p);
128
+ if (!n) {
129
+ unknown = true;
130
+ crumbs.push({ path: p, name: lastName(p) });
131
+ steps.push({ path: p, name: lastName(p), count: extra.facets?.[p] ?? 0, children: [] });
132
+ continue;
133
+ }
134
+ crumbs.push({ path: n.path, name: n.name });
135
+ steps.push({ path: n.path, name: n.name, count: n.count, children: n.children });
136
+ }
137
+ }
138
+ const rowsAt = (step: (typeof steps)[number]): CategoryRow[] => {
139
+ const rows: CategoryRow[] = step.children
140
+ .filter((n) => n.count > 0 || n.path === path)
141
+ .map((n) => ({ path: n.path, name: n.name, count: n.count, more: n.children.some((c) => c.count > 0), ...(n.description ? { description: n.description } : {}) }));
142
+ // paths parts carry that the taxonomy does not have (one level below this one)
143
+ const depth = step.path ? step.path.split(".").length + 1 : 1;
144
+ for (const [p, count] of Object.entries(extra.facets ?? {})) {
145
+ if (!count || p.split(".").length !== depth || (step.path && !p.startsWith(`${step.path}.`)) || rows.some((r) => r.path === p) || step.children.some((n) => n.path === p)) continue;
146
+ rows.push({ path: p, name: lastName(p), count, more: Object.keys(extra.facets!).some((q) => q.startsWith(`${p}.`)) });
147
+ }
148
+ return rows;
149
+ };
150
+ const current = steps[steps.length - 1];
151
+ let level = current;
152
+ let rows = rowsAt(current);
153
+ // a category with nothing below it: the list stays on its parent's level, the category selected in it
154
+ if (!rows.length && steps.length > 1) {
155
+ level = steps[steps.length - 2];
156
+ rows = rowsAt(level);
157
+ if (!rows.some((r) => r.path === current.path)) rows.push({ path: current.path, name: current.name, count: current.count, more: false });
158
+ }
159
+ const pick = (x: (typeof steps)[number]) => ({ path: x.path, name: x.name, count: x.count });
160
+ return { crumbs, all: pick(current), level: pick(level), rows, ...(unknown ? { unknown } : {}) };
161
+ }
162
+
163
+ // ---------------------------------------------------------------- results
164
+
165
+ export interface ItemModel {
166
+ /** Unique in the list: library and part id. */
167
+ key: string;
168
+ partId: string;
169
+ name: string;
170
+ manufacturer?: string;
171
+ mpn?: string;
172
+ /** The revision `Add` takes (the recommended one, else the latest). */
173
+ revision: number;
174
+ latest: number;
175
+ library: string;
176
+ /** An earlier library has this id: `parts add <id>` alone would take that one. */
177
+ shadowedBy?: string;
178
+ /** The project's vendored copy, when it has one. */
179
+ used?: { revision?: number; library?: string; tier?: string; elsewhere: boolean };
180
+ /** A newer revision than the project's pin, from the same library. */
181
+ update?: number;
182
+ deprecated?: boolean;
183
+ thumbnail?: { sha256: string; size: number; mediaType: string };
184
+ categories: string[];
185
+ domains: string[];
186
+ protocols: string[];
187
+ description?: string;
188
+ tags?: string[];
189
+ /** The part's relevance in its library, when the library ranks. */
190
+ relevance?: number;
191
+ }
192
+
193
+ export function itemModel(hit: LibraryHit, usage: Map<string, Usage>): ItemModel {
194
+ const revision = hit.recommendedRevision ?? hit.latestRevision;
195
+ const u = usage.get(hit.partId);
196
+ const used = u ? { ...(u.revision !== undefined ? { revision: u.revision } : {}), ...(u.library ? { library: u.library } : {}), ...(u.tier ? { tier: u.tier } : {}), elsewhere: !!u.library && u.library !== hit.library } : undefined;
197
+ const update = used && !used.elsewhere && used.revision !== undefined && hit.recommendedRevision !== undefined && hit.recommendedRevision > used.revision ? hit.recommendedRevision : undefined;
198
+ return {
199
+ key: `${hit.library}/${hit.partId}`,
200
+ partId: hit.partId,
201
+ name: hit.name,
202
+ ...(hit.manufacturer ? { manufacturer: hit.manufacturer } : {}),
203
+ ...(hit.partNumber ? { mpn: hit.partNumber } : {}),
204
+ revision,
205
+ latest: hit.latestRevision,
206
+ library: hit.library,
207
+ ...(hit.shadowedBy ? { shadowedBy: hit.shadowedBy } : {}),
208
+ ...(used ? { used } : {}),
209
+ ...(update !== undefined ? { update } : {}),
210
+ ...(hit.deprecated ? { deprecated: true } : {}),
211
+ ...(hit.thumbnail ? { thumbnail: hit.thumbnail } : {}),
212
+ categories: hit.categories ?? [],
213
+ domains: hit.domains ?? [],
214
+ protocols: hit.protocols ?? [],
215
+ ...(hit.description ? { description: hit.description } : {}),
216
+ ...(hit.tags?.length ? { tags: hit.tags } : {}),
217
+ ...(typeof hit.relevance === "number" ? { relevance: hit.relevance } : {}),
218
+ };
219
+ }
220
+
221
+ /** A facet's values with counts, the most common first; the chosen one is kept even at zero. */
222
+ export function facetOptions(counts: Record<string, number> | undefined, chosen = ""): { value: string; count: number }[] {
223
+ const out = Object.entries(counts ?? {})
224
+ .filter(([, n]) => n > 0)
225
+ .map(([value, count]) => ({ value, count }))
226
+ .sort((a, b) => b.count - a.count || a.value.localeCompare(b.value));
227
+ if (chosen && !out.some((o) => o.value === chosen)) out.unshift({ value: chosen, count: 0 });
228
+ return out;
229
+ }
230
+
231
+ /** The facet counts the filters show: those of a search that did not filter on that facet would be best, but the page's own are close enough when the facet is unset. */
232
+ export function filterOptions(facets: SearchFacets | undefined, q: LibraryQuery) {
233
+ return { domain: facetOptions(facets?.domain, q.domain), protocol: facetOptions(facets?.protocol, q.protocol) };
234
+ }
235
+
236
+ // ---------------------------------------------------------------- preview
237
+
238
+ export interface Fact {
239
+ label: string;
240
+ value: string;
241
+ }
242
+
243
+ export interface InterfaceRow {
244
+ id: string;
245
+ name: string;
246
+ domain?: string;
247
+ protocols: string;
248
+ count: number;
249
+ }
250
+
251
+ export interface PreviewModel {
252
+ key: string;
253
+ partId: string;
254
+ library: string;
255
+ libraryUrl: string;
256
+ name: string;
257
+ description?: string;
258
+ manufacturer?: string;
259
+ mpn?: string;
260
+ /** The revision shown. */
261
+ revision: number;
262
+ recommended?: number;
263
+ latest: number;
264
+ categories: { path: string; name: string }[];
265
+ domains: string[];
266
+ protocols: string[];
267
+ facts: Fact[];
268
+ interfaces: InterfaceRow[];
269
+ /** Interfaces in the definition (rows group pins that share a name pattern). */
270
+ interfaceCount: number;
271
+ datasheet?: { label: string; url: string };
272
+ links: { label: string; url: string }[];
273
+ revisions: { revision: number; createdAt: string; publisher: string; deprecated?: string; recommended: boolean; current: boolean; pinned: boolean }[];
274
+ /** The body GLB of the revision, when it has one. */
275
+ glb?: { sha256: string; size: number; path: string };
276
+ thumbnail?: { sha256: string; size: number; mediaType: string };
277
+ used?: ItemModel["used"];
278
+ /** What the main action does: add, update to a newer revision, switch to the shown one, or nothing (already pinned here). */
279
+ action: { kind: "add" | "update" | "switch" | "pinned" | "elsewhere"; revision: number };
280
+ dependencies: string[];
281
+ evidence: { kind: string; title?: string }[];
282
+ /** The revision's verification record, when it carries one as a file: the host reads it to tell the part's status. */
283
+ verification?: { sha256: string; size: number; mediaType: string };
284
+ }
285
+
286
+ type Def = Record<string, any>;
287
+
288
+ const fmtRange = (v: unknown, unit: string): string | undefined => {
289
+ if (typeof v === "number") return `${v} ${unit}`;
290
+ if (Array.isArray(v) && v.length === 2 && v.every((x) => typeof x === "number")) return v[0] === v[1] ? `${v[0]} ${unit}` : `${v[0]} to ${v[1]} ${unit}`;
291
+ return undefined;
292
+ };
293
+
294
+ /** Key facts a definition states, in a fixed order; only those it has. */
295
+ export function keyFacts(def: Def): Fact[] {
296
+ const facts: Fact[] = [];
297
+ const domains: Def[] = Array.isArray(def.domains) ? def.domains.filter((d: unknown) => d && typeof d === "object") : [];
298
+ const of = (name: string) => domains.find((d) => d.domain === name);
299
+ const mech = of("mechanical");
300
+ const elec = of("electrical");
301
+ const therm = of("thermal");
302
+ const dims = mech?.dimensions_mm;
303
+ if (dims && typeof dims === "object") {
304
+ const parts = ["length", "width", "height"].map((k) => dims[k]).filter((x) => typeof x === "number");
305
+ if (parts.length) facts.push({ label: "Size", value: `${parts.join(" × ")} mm` });
306
+ }
307
+ const mass = mech?.mass_g ?? mech?.metadata?.mass_g ?? def.mass_g;
308
+ if (typeof mass === "number") facts.push({ label: "Mass", value: `${mass} g` });
309
+ const supply = fmtRange(elec?.metadata?.supply_voltage_V ?? elec?.supply_voltage_V, "V");
310
+ if (supply) facts.push({ label: "Supply", value: supply });
311
+ const pkg = elec?.metadata?.package_type ?? mech?.metadata?.package_type;
312
+ if (typeof pkg === "string") facts.push({ label: "Package", value: pkg });
313
+ const pins = elec?.metadata?.pin_count;
314
+ if (typeof pins === "number") facts.push({ label: "Pins", value: String(pins) });
315
+ const mount = mech?.metadata?.mounting_method;
316
+ if (typeof mount === "string") facts.push({ label: "Mounting", value: mount.replace(/_/g, " ") });
317
+ const temp = fmtRange(therm?.operating_temperature_C, "°C");
318
+ if (temp) facts.push({ label: "Operating temperature", value: temp });
319
+ for (const pd of Array.isArray(elec?.power_domains) ? elec.power_domains.slice(0, 3) : []) {
320
+ const v = fmtRange(pd?.voltage_range_V, "V") ?? fmtRange(pd?.nominal_voltage_V, "V");
321
+ if (v && typeof pd.name === "string") facts.push({ label: pd.name, value: v });
322
+ }
323
+ if (typeof def.version === "string") facts.push({ label: "Definition version", value: def.version });
324
+ return facts;
325
+ }
326
+
327
+ /** Interfaces, one row each; pins with the same protocols and domain fold into one row past twelve. */
328
+ export function interfaceRows(def: Def): { rows: InterfaceRow[]; count: number } {
329
+ const list: Def[] = Array.isArray(def.interfaces) ? def.interfaces.filter((i: unknown) => i && typeof i === "object") : [];
330
+ const row = (i: Def): InterfaceRow => ({
331
+ id: String(i.id ?? ""),
332
+ name: String(i.name ?? i.id ?? ""),
333
+ ...(typeof i.domain === "string" ? { domain: i.domain } : {}),
334
+ protocols: (Array.isArray(i.protocols) ? i.protocols : [])
335
+ .map((p: Def) => [p?.type, ...(Array.isArray(p?.roles) ? [`(${p.roles.join(", ")})`] : [])].filter(Boolean).join(" "))
336
+ .join(", "),
337
+ count: 1,
338
+ });
339
+ if (list.length <= 12) return { rows: list.map(row), count: list.length };
340
+ const groups = new Map<string, InterfaceRow>();
341
+ for (const i of list) {
342
+ const r = row(i);
343
+ const k = `${r.domain}|${r.protocols}`;
344
+ const g = groups.get(k);
345
+ if (g) {
346
+ g.count++;
347
+ g.name = `${g.count} interfaces`;
348
+ } else groups.set(k, r);
349
+ }
350
+ return { rows: [...groups.values()].map((g) => (g.count > 1 ? { ...g, id: "" } : g)), count: list.length };
351
+ }
352
+
353
+ /** The datasheet link, and the other documents' links. */
354
+ export function documents(def: Def): { datasheet?: { label: string; url: string }; links: { label: string; url: string }[] } {
355
+ const arts: Def[] = Array.isArray(def.artifacts) ? def.artifacts : [];
356
+ const web = arts.filter((a) => typeof a?.url === "string" && /^https?:\/\//.test(a.url));
357
+ const ds = web.find((a) => a.type === "datasheet") ?? web.find((a) => /datasheet/i.test(`${a.id} ${a.name}`));
358
+ const links = web.filter((a) => a !== ds).map((a) => ({ label: String(a.name ?? a.id), url: a.url as string }));
359
+ for (const l of Array.isArray(def.links) ? def.links : []) if (typeof l?.url === "string" && /^https?:\/\//.test(l.url)) links.push({ label: String(l.label ?? l.url), url: l.url });
360
+ return { ...(ds ? { datasheet: { label: String(ds.name ?? "Datasheet"), url: ds.url as string } } : {}), links };
361
+ }
362
+
363
+ /** The GLB to preview: the body mesh (artifact role "body", glb), else any GLB that is not an interface's. */
364
+ export function bodyGlb(env: Pick<PartRevisionEnvelope, "artifacts" | "definition">): PreviewModel["glb"] {
365
+ const defArts: Def[] = Array.isArray((env.definition as Def).artifacts) ? (env.definition as Def).artifacts : [];
366
+ const glbs = env.artifacts.filter((a) => a.mediaType === "model/gltf-binary" || a.path.toLowerCase().endsWith(".glb"));
367
+ const role = (a: (typeof glbs)[number]) => a.role ?? defArts.find((d) => d.id === a.artifactId)?.role;
368
+ const pick = glbs.find((a) => role(a) === "body") ?? glbs.find((a) => role(a) !== "interface");
369
+ return pick ? { sha256: pick.sha256, size: pick.size, path: pick.path } : undefined;
370
+ }
371
+
372
+ /** The preview of one revision of a part from one library. */
373
+ export function previewModel(p: {
374
+ part: PartSummary;
375
+ revisions: RevisionRow[];
376
+ envelope: PartRevisionEnvelope;
377
+ library: { id: string; url: string };
378
+ usage?: Usage;
379
+ categoryName?: (path: string) => string | undefined;
380
+ }): PreviewModel {
381
+ const def = p.envelope.definition as Def;
382
+ const { rows, count } = interfaceRows(def);
383
+ const docs = documents(def);
384
+ const u = p.usage;
385
+ const used = u ? { ...(u.revision !== undefined ? { revision: u.revision } : {}), ...(u.library ? { library: u.library } : {}), ...(u.tier ? { tier: u.tier } : {}), elsewhere: !!u.library && u.library !== p.library.id } : undefined;
386
+ const shown = p.envelope.revision;
387
+ const action: PreviewModel["action"] = !used
388
+ ? { kind: "add", revision: shown }
389
+ : used.elsewhere || used.revision === undefined
390
+ ? { kind: "elsewhere", revision: shown }
391
+ : used.revision === shown
392
+ ? { kind: "pinned", revision: shown }
393
+ : shown > used.revision
394
+ ? { kind: "update", revision: shown }
395
+ : { kind: "switch", revision: shown };
396
+ const categories = (p.part.categories ?? []).map((path) => ({ path, name: p.categoryName?.(path) ?? lastName(path) }));
397
+ return {
398
+ key: `${p.library.id}/${p.part.partId}`,
399
+ partId: p.part.partId,
400
+ library: p.library.id,
401
+ libraryUrl: p.library.url,
402
+ name: typeof def.name === "string" ? def.name : p.part.name,
403
+ ...((def.description ?? p.part.description) ? { description: String(def.description ?? p.part.description) } : {}),
404
+ ...(p.part.manufacturer ? { manufacturer: p.part.manufacturer } : {}),
405
+ ...(p.part.partNumber ? { mpn: p.part.partNumber } : {}),
406
+ revision: shown,
407
+ ...(p.part.recommendedRevision !== undefined ? { recommended: p.part.recommendedRevision } : {}),
408
+ latest: p.part.latestRevision,
409
+ categories,
410
+ domains: p.part.domains ?? [],
411
+ protocols: p.part.protocols ?? [],
412
+ facts: keyFacts(def),
413
+ interfaces: rows,
414
+ interfaceCount: count,
415
+ ...(docs.datasheet ? { datasheet: docs.datasheet } : {}),
416
+ links: docs.links,
417
+ revisions: [...p.revisions]
418
+ .sort((a, b) => b.revision - a.revision)
419
+ .map((r) => ({
420
+ revision: r.revision,
421
+ createdAt: r.createdAt,
422
+ publisher: r.publisher,
423
+ ...(r.deprecated ? { deprecated: r.deprecated.reason } : {}),
424
+ recommended: r.revision === p.part.recommendedRevision,
425
+ current: r.revision === shown,
426
+ pinned: !!used && !used.elsewhere && used.revision === r.revision,
427
+ })),
428
+ ...(bodyGlb(p.envelope) ? { glb: bodyGlb(p.envelope) } : {}),
429
+ ...(p.part.thumbnail ? { thumbnail: p.part.thumbnail } : {}),
430
+ ...(used ? { used } : {}),
431
+ action,
432
+ dependencies: p.envelope.dependencies.map((d) => `${d.partId} ${rev(d.revision)}`),
433
+ evidence: p.envelope.evidence.map((e) => ({ kind: e.kind, ...(e.title ? { title: e.title } : {}) })),
434
+ ...(verificationFile(p.envelope) ? { verification: verificationFile(p.envelope) } : {}),
435
+ };
436
+ }
437
+
438
+ /** The revision's verification record among its evidence, when it is a carried file. */
439
+ function verificationFile(env: Pick<PartRevisionEnvelope, "evidence">): PreviewModel["verification"] {
440
+ const e = env.evidence.find((x) => x.kind === "verification" && x.sha256 && typeof x.size === "number");
441
+ return e ? { sha256: e.sha256!, size: e.size!, mediaType: e.mediaType ?? "application/json" } : undefined;
442
+ }
443
+
444
+ /**
445
+ * A library part's status from its revision (PB-932): `record` is the
446
+ * verification record the host read (`null`: the revision carries one that
447
+ * could not be read; leave it out while the revision carries none).
448
+ */
449
+ export function previewTrust(p: Pick<PreviewModel, "verification" | "evidence">, record?: VerificationRecord | null): { trust: PartTrust; trustBasis: string } {
450
+ const carries = !!p.verification || p.evidence.some((e) => e.kind === "verification");
451
+ const r = partTrust({ inLibrary: true, ...(carries ? { verification: record ?? null } : {}) });
452
+ return { trust: r.trust, trustBasis: r.basis };
453
+ }
454
+
455
+ /** A taxonomy path's name, from the merged tree. */
456
+ export function categoryNamer(nodes: MergedTaxonomyNode[]): (path: string) => string | undefined {
457
+ const names = new Map<string, string>();
458
+ const walk = (list: MergedTaxonomyNode[]) => {
459
+ for (const n of list) {
460
+ names.set(n.path, n.name);
461
+ walk(n.children);
462
+ }
463
+ };
464
+ walk(nodes);
465
+ return (path) => names.get(path);
466
+ }
467
+
468
+ // ---------------------------------------------------------------- onto the views' models
469
+
470
+ const PROTOCOL_LABEL: Record<string, string> = { i2c: "I2C", spi: "SPI", uart: "UART", usb: "USB", can: "CAN", gpio: "GPIO", pwm: "PWM", i2s: "I2S", power: "Power", analog: "Analog", ethernet: "Ethernet", crsf: "CRSF", dshot: "DShot" };
471
+
472
+ /** A protocol's short label: `I2C`, `SPI`; others as they are, capitalised. */
473
+ export const protocolLabel = (p: string) => PROTOCOL_LABEL[p.toLowerCase()] ?? (p.length <= 4 ? p.toUpperCase() : p.charAt(0).toUpperCase() + p.slice(1));
474
+
475
+ /** The first sentence of a description, short enough for a card. */
476
+ export function shortDescription(text: string | undefined, max = 120): string | undefined {
477
+ if (!text) return undefined;
478
+ const first = text.trim().split(/(?<=[.!?])\s+/)[0] ?? text;
479
+ return first.length > max ? `${first.slice(0, max - 1).trimEnd()}…` : first;
480
+ }
481
+
482
+ /** "Environmental · Gas": the last two names of a part's first category. */
483
+ export function categoryTrail(paths: string[], name: (path: string) => string | undefined = () => undefined): string | undefined {
484
+ const path = paths[0];
485
+ if (!path) return undefined;
486
+ const ids = path.split(".");
487
+ const names = ids.map((_, i) => {
488
+ const p = ids.slice(0, i + 1).join(".");
489
+ return name(p) ?? lastName(p);
490
+ });
491
+ return names.slice(-2).join(" · ");
492
+ }
493
+
494
+ /** A result row as a card. */
495
+ /** Protocols that tell parts apart on a card (buses and links); power, digital, mechanical and the like say little. */
496
+ const CARD_PROTOCOLS = /^(i2c|i3c|spi|qspi|uart|usb|can|canfd|i2s|pwm|crsf|sbus|dshot|ethernet|wifi|ble|bluetooth|lora|sdio|mipi[_-]?csi|csi|dsi|hdmi|pcie|onewire|1-wire|jtag|swd|rs485|rs232|modbus|gnss|ppm)$/i;
497
+
498
+ export function cardModel(it: ItemModel, o: { thumb?: string; busy?: string; categoryName?: (path: string) => string | undefined } = {}): PartCardModel {
499
+ const cats = new Set(it.categories);
500
+ const free = (it.tags ?? []).filter((t) => !cats.has(t) && !t.includes(".") && !t.includes("_") && !it.protocols.includes(t));
501
+ const tags = [...it.protocols.filter((p) => CARD_PROTOCOLS.test(p)).map(protocolLabel), ...free].filter((t, i, a) => a.indexOf(t) === i).slice(0, 3);
502
+ return {
503
+ key: it.key,
504
+ partId: it.partId,
505
+ name: it.name,
506
+ ...(it.manufacturer ? { manufacturer: it.manufacturer } : {}),
507
+ ...(it.mpn ? { mpn: it.mpn } : {}),
508
+ ...(categoryTrail(it.categories, o.categoryName) ? { category: categoryTrail(it.categories, o.categoryName) } : {}),
509
+ ...(shortDescription(it.description) ? { description: shortDescription(it.description) } : {}),
510
+ tags,
511
+ library: it.library,
512
+ revision: it.revision,
513
+ ...(o.thumb ? { thumbnail: o.thumb } : {}),
514
+ ...(it.used ? { used: { ...(it.used.revision !== undefined ? { revision: it.used.revision } : {}), elsewhere: it.used.elsewhere } } : {}),
515
+ ...(it.update !== undefined ? { update: it.update } : {}),
516
+ ...(it.deprecated ? { deprecated: true } : {}),
517
+ ...(it.shadowedBy ? { shadowedBy: it.shadowedBy } : {}),
518
+ ...(o.busy ? { busy: o.busy } : {}),
519
+ ...(it.relevance !== undefined ? { relevance: it.relevance } : {}),
520
+ pages: { key: it.key, revision: it.revision },
521
+ };
522
+ }
523
+
524
+ /** The size of the whole catalog: the host's figure, or this search's total when nothing filters it. Never the sum of the categories, which counts a part once per category. */
525
+ function catalogTotalOf(input: { query: LibraryQuery; total: number; catalogTotal?: number; loading: boolean; error?: string }): number | undefined {
526
+ if (input.catalogTotal !== undefined) return input.catalogTotal;
527
+ const q = input.query;
528
+ return !q.text && !q.category && !q.domain && !q.protocol && !input.loading && !input.error ? input.total : undefined;
529
+ }
530
+
531
+ /** The Library's model from a search, the taxonomy and what the host has fetched. */
532
+ export function libraryViewModel(input: {
533
+ query: LibraryQuery;
534
+ drill?: DrillDown;
535
+ /** The merged taxonomy's top level (the full page's categories). */
536
+ top?: MergedTaxonomyNode[];
537
+ items: ItemModel[];
538
+ total: number;
539
+ /** The unfiltered search's total, when the host has seen it. */
540
+ catalogTotal?: number;
541
+ more: boolean;
542
+ sources: LibrarySource[];
543
+ thumbs?: Record<string, string>;
544
+ busy?: Map<string, string> | Record<string, string>;
545
+ loading: boolean;
546
+ loadingMore?: boolean;
547
+ error?: string;
548
+ setupError?: string;
549
+ noProject?: boolean;
550
+ categoryName?: (path: string) => string | undefined;
551
+ /** The orders every library searched sorts by itself (`searchLibraries`' `sorts`). */
552
+ sorts?: readonly LibrarySort[];
553
+ }): LibraryModel {
554
+ const busy = input.busy instanceof Map ? Object.fromEntries(input.busy) : (input.busy ?? {});
555
+ const cat = (r: { path: string; name: string; count: number; more?: boolean; description?: string }): ViewCategory => ({ path: r.path, name: r.name, count: r.count, ...(r.more ? { more: true } : {}), ...(r.description ? { description: r.description } : {}) });
556
+ const sort = sortState(input.query.sort, input.sorts ?? [], input.items);
557
+ const ordered = sort.local ? sortLocally(input.items, sort.value) : input.items;
558
+ return {
559
+ query: input.query,
560
+ crumbs: input.drill?.crumbs ?? [{ path: "", name: "All categories" }],
561
+ categories: (input.drill?.rows ?? []).map(cat),
562
+ level: input.drill?.level ?? { path: "", name: "All categories", count: input.total },
563
+ sort,
564
+ topCategories: (input.top ?? []).filter((n) => n.count > 0).map((n) => cat({ path: n.path, name: n.name, count: n.count, more: n.children.some((c) => c.count > 0), ...(n.description ? { description: n.description } : {}) })),
565
+ total: input.total,
566
+ ...(catalogTotalOf(input) !== undefined ? { catalogTotal: catalogTotalOf(input) } : {}),
567
+ libraries: input.sources,
568
+ items: ordered.map((it) => cardModel(it, { ...(it.thumbnail && input.thumbs?.[it.thumbnail.sha256] ? { thumb: input.thumbs[it.thumbnail.sha256] } : {}), ...(busy[it.key] ? { busy: busy[it.key] } : {}), ...(input.categoryName ? { categoryName: input.categoryName } : {}) })),
569
+ more: input.more,
570
+ loading: input.loading,
571
+ ...(input.loadingMore ? { loadingMore: true } : {}),
572
+ ...(input.error ? { error: input.error } : {}),
573
+ ...(input.setupError ? { setupError: input.setupError } : {}),
574
+ ...(input.noProject ? { noProject: true } : {}),
575
+ };
576
+ }
577
+
578
+ /** A part's preview as the detail view's model. */
579
+ export function detailModel(
580
+ p: PreviewModel,
581
+ o: { thumb?: string; model?: string; modelError?: string; busy?: string; noProject?: boolean; trust?: PartTrust; trustBasis?: string; /** The revision's pages (`partPages`). */ pages?: PartPages; /** The model comes when the CAD section asks. */ modelLazy?: boolean; /** The host can ask the library for prices. */ hasPrices?: boolean } = {},
582
+ ): PartDetailModel {
583
+ // the buses and links that tell parts apart (as on a card); "power", "digital" or "bolt_pattern" say little beside a name
584
+ const buses = p.protocols.filter((x) => CARD_PROTOCOLS.test(x)).map(protocolLabel);
585
+ const identifiers = buses.length ? [buses.join(" / ")] : [];
586
+ return {
587
+ key: p.key,
588
+ partId: p.partId,
589
+ name: p.name,
590
+ crumbs: p.categories.length ? p.categories[0].path.split(".").map((_, i, ids) => {
591
+ const path = ids.slice(0, i + 1).join(".");
592
+ return p.categories.find((c) => c.path === path)?.name ?? lastName(path);
593
+ }) : [],
594
+ ...(p.manufacturer ? { manufacturer: p.manufacturer } : {}),
595
+ ...(p.mpn ? { mpn: p.mpn } : {}),
596
+ identifiers,
597
+ ...(p.description ? { description: p.description } : {}),
598
+ library: p.library,
599
+ libraryUrl: p.libraryUrl,
600
+ revision: p.revision,
601
+ ...(p.recommended !== undefined ? { recommended: p.recommended } : {}),
602
+ latest: p.latest,
603
+ ...(o.thumb ? { thumbnail: o.thumb } : {}),
604
+ ...(o.model ? { model: o.model } : {}),
605
+ ...(o.modelError ? { modelError: o.modelError } : {}),
606
+ facts: p.facts,
607
+ interfaces: p.interfaces,
608
+ interfaceCount: p.interfaceCount,
609
+ ...(p.datasheet ? { datasheet: p.datasheet } : {}),
610
+ links: p.links,
611
+ revisions: p.revisions,
612
+ evidence: p.evidence,
613
+ dependencies: p.dependencies,
614
+ ...(p.used ? { used: { ...(p.used.revision !== undefined ? { revision: p.used.revision } : {}), elsewhere: p.used.elsewhere } } : {}),
615
+ action: p.action,
616
+ ...(o.busy ? { busy: o.busy } : {}),
617
+ ...(o.noProject ? { noProject: true } : {}),
618
+ ...(o.trust ? { trust: o.trust, ...(o.trustBasis ? { trustBasis: o.trustBasis } : {}) } : {}),
619
+ ...(o.pages ? { pages: o.pages } : {}),
620
+ ...(o.modelLazy ? { modelLazy: true } : {}),
621
+ ...(o.hasPrices ? { hasPrices: true } : {}),
622
+ };
623
+ }
624
+
625
+ export type { AccountModel };
626
+
627
+ // ---------------------------------------------------------------- project parts (PB-903, PB-945)
628
+
629
+ export interface PartGroup {
630
+ id: string;
631
+ title: string;
632
+ parts: ProjectPartModel[];
633
+ }
634
+
635
+ /** The side bar's groups, in its order: updates, each library, researched, designed. */
636
+ export function partGroups(parts: ProjectPartModel[]): PartGroup[] {
637
+ const updates = parts.filter((p) => p.status === "update-available");
638
+ const libs = [...new Set(parts.filter((p) => p.tier === "catalog" && p.status !== "update-available").map((p) => p.library ?? "library"))];
639
+ return [
640
+ { id: "updates", title: "Updates available", parts: updates },
641
+ ...libs.map((l) => ({ id: `library:${l}`, title: `Library · ${l}`, parts: parts.filter((p) => p.tier === "catalog" && p.status !== "update-available" && (p.library ?? "library") === l) })),
642
+ { id: "researched", title: "Project · researched", parts: parts.filter((p) => p.tier === "project-researched") },
643
+ { id: "designed", title: "Project · designed", parts: parts.filter((p) => p.tier === "project-specific") },
644
+ ].filter((g) => g.parts.length);
645
+ }
646
+
647
+ /** The Project Parts page's groups: the project's parts by tier (library, researched, designed), each with what the tier means. */
648
+ export function tierGroups(parts: ProjectPartModel[]): (PartGroup & { hint: string })[] {
649
+ return [
650
+ { id: "library", title: "From libraries", hint: "Vendored from a library and pinned to a revision. Updates flow one way, from the library to the project.", parts: parts.filter((p) => p.tier === "catalog") },
651
+ { id: "researched", title: "Researched in the project", hint: "Written in parts/local from datasheets. They can be submitted to a library for verification later.", parts: parts.filter((p) => p.tier === "project-researched") },
652
+ { id: "designed", title: "Designed in the project", hint: "The project's own modules under src/: circuits, CAD parts, harnesses.", parts: parts.filter((p) => p.tier === "project-specific") },
653
+ ].filter((g) => g.parts.length);
654
+ }
655
+
656
+ export type PartSyncKind = "in-sync" | "update" | "diverged" | "local" | "pinned";
657
+
658
+ /** A project part against its library: the one wording and tone of every view. */
659
+ export interface PartSync {
660
+ kind: PartSyncKind;
661
+ /** "In sync", "Update available · v4", "Diverged", "Local only", "Pinned · v2". */
662
+ label: string;
663
+ /** What the state means, for the tooltip. */
664
+ meaning: string;
665
+ tone: "ok" | "info" | "warning" | "neutral";
666
+ icon: string;
667
+ }
668
+
669
+ /**
670
+ * A part's sync status with its library: in sync, update available (with
671
+ * the revision), diverged (vendored files edited), local only. A library
672
+ * part not compared with its library yet (offline) is "pinned".
673
+ */
674
+ export function partSync(p: Pick<ProjectPartModel, "tier" | "status" | "revision" | "recommended" | "edited" | "library">): PartSync {
675
+ if (p.tier !== "catalog") return { kind: "local", label: "Local only", meaning: "Only in this project: no library holds it, so there is nothing to sync with.", tone: "neutral", icon: "hard-drive" };
676
+ const from = p.library ? ` in ${p.library}` : "";
677
+ if (p.status === "diverged") {
678
+ const files = p.edited?.length ? `: ${p.edited.join(", ")}` : "";
679
+ return { kind: "diverged", label: "Diverged", meaning: `Its vendored files were edited in the project since ${rev(p.revision)} was added${files}. Restore the revision, or keep the edits as a part of your own.${p.recommended !== undefined ? ` ${rev(p.recommended)} is recommended${from}.` : ""}`, tone: "warning", icon: "git-compare-arrows" };
680
+ }
681
+ if (p.status === "update-available") return { kind: "update", label: `Update available · ${rev(p.recommended)}`, meaning: `The project pins ${rev(p.revision)}; the library recommends ${rev(p.recommended)}. Updating replaces the vendored files with that revision; compare the two first to see what changes.`, tone: "info", icon: "download" };
682
+ if (p.status === "up-to-date") return { kind: "in-sync", label: "In sync", meaning: `The project has ${rev(p.revision)}, the revision the library recommends, and its files are untouched.`, tone: "ok", icon: "check" };
683
+ return { kind: "pinned", label: `Pinned · ${rev(p.revision)}`, meaning: `Pinned to ${rev(p.revision)} with its files untouched; not compared with the library yet (check for updates).`, tone: "neutral", icon: "pin" };
684
+ }
685
+
686
+ /** A part's short mono note: `v2 → v3`, `v3 · edited`, `local`, `circuit · tscircuit`. */
687
+ export function partNote(p: ProjectPartModel): string {
688
+ const r = p.revision !== undefined ? rev(p.revision) : "";
689
+ if (p.status === "update-available") return revChange(p.revision, p.recommended);
690
+ if (p.status === "diverged") return `${r} · edited`;
691
+ if (p.tier === "project-researched") return p.published ? `local · published ${rev(p.published.revision)}` : "local";
692
+ if (p.tier === "project-specific") {
693
+ if (!p.capability) return "module";
694
+ const tool = p.capability.provider.replace(/^@[^/]+\//, "").replace(/^ext-/, "");
695
+ return tool === p.capability.capability ? tool : `${p.capability.capability} · ${tool}`;
696
+ }
697
+ return r;
698
+ }
699
+
700
+ /** A project part as a library card draws it (the Project Parts page uses the library's cards). */
701
+ export function projectCard(p: ProjectPartModel, categoryName: (path: string) => string | undefined = () => undefined): PartCardModel {
702
+ const sub = p.tier === "catalog" ? undefined : partNote(p);
703
+ return {
704
+ key: p.id,
705
+ partId: p.id,
706
+ name: p.name,
707
+ ...(p.manufacturer ? { manufacturer: p.manufacturer } : {}),
708
+ ...(p.partNumber ? { mpn: p.partNumber } : {}),
709
+ ...(p.category ? { category: categoryTrail([p.category], categoryName) } : sub ? { category: sub } : {}),
710
+ ...(shortDescription(p.description) ? { description: shortDescription(p.description) } : {}),
711
+ tags: [],
712
+ library: p.library ?? "",
713
+ revision: typeof p.revision === "number" ? p.revision : 0,
714
+ ...(p.thumbnail ? { thumbnail: p.thumbnail } : {}),
715
+ ...(p.busy ? { busy: p.busy } : {}),
716
+ // a library part's pages are its pinned revision's, from its library; the project's own parts have their overview
717
+ ...(p.tier === "catalog" && p.library ? { pages: { key: `${p.library}/${p.id}`, ...(typeof p.revision === "number" ? { revision: p.revision } : {}) } } : {}),
718
+ };
719
+ }
720
+
721
+ export { PART_TRUST_TEXT };
722
+
723
+ // ---------------------------------------------------------------- bill of materials (PB-904)
724
+
725
+ /** The bill as CSV (Export CSV): core's export of the structured model. */
726
+ export { bomCsv } from "@protoboardai/core/bom";