altium-toolkit 0.1.23 → 1.0.2

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.
@@ -0,0 +1,732 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ import { ComponentGrouping, MPN_MISSING_NOTE } from './ComponentGrouping.mjs'
6
+ import { CircuitTraversal } from './CircuitTraversal.mjs'
7
+ import { QueryNetlistBuilder } from './QueryNetlistBuilder.mjs'
8
+ import { RegexPattern } from './RegexPattern.mjs'
9
+
10
+ /**
11
+ * Builds query responses from loaded toolkit document models.
12
+ */
13
+ export class LoadedDesignNetlistService {
14
+ /** @type {() => object[]} */
15
+ #getDocuments
16
+
17
+ /**
18
+ * @param {{ getDocuments: () => object[] }} dependencies Dependencies.
19
+ */
20
+ constructor(dependencies = {}) {
21
+ this.#getDocuments =
22
+ typeof dependencies.getDocuments === 'function'
23
+ ? dependencies.getDocuments
24
+ : () => []
25
+ }
26
+
27
+ /**
28
+ * Lists loaded session designs.
29
+ * @param {{ pattern?: string, max_results?: number }} [args] Query args.
30
+ * @returns {object[] | { error: string }}
31
+ */
32
+ listDesigns(args = {}) {
33
+ const loaded = this.#loadedEntries()
34
+ const pattern = String(args.pattern || '.*')
35
+ const parsed = RegexPattern.parse(pattern)
36
+ if (parsed.error) return parsed
37
+
38
+ return loaded
39
+ .filter((entry) => parsed.regex.test(entry.name))
40
+ .slice(0, LoadedDesignNetlistService.#maxResults(args.max_results))
41
+ .map((entry) => ({
42
+ id: entry.id,
43
+ name: entry.name,
44
+ fileName: entry.fileName,
45
+ kind: entry.kind,
46
+ active: entry.active,
47
+ hasConnectivity: entry.hasConnectivity
48
+ }))
49
+ }
50
+
51
+ /**
52
+ * Lists components matching one reference-designator prefix.
53
+ * @param {{ design?: string, type?: string, include_dns?: boolean }} [args] Query args.
54
+ * @returns {{ components: object[] } | { error: string }}
55
+ */
56
+ listComponents(args = {}) {
57
+ const resolved = this.#resolveDesign(args.design)
58
+ if (resolved.error) return resolved
59
+
60
+ const prefix = String(args.type || '')
61
+ .trim()
62
+ .toUpperCase()
63
+ if (!prefix) {
64
+ return { error: 'Component type prefix is required.' }
65
+ }
66
+
67
+ const entries = Object.entries(resolved.netlist.components).filter(
68
+ ([refdes]) =>
69
+ LoadedDesignNetlistService.#refdesPrefix(refdes) === prefix
70
+ )
71
+ if (!entries.length) {
72
+ return {
73
+ error:
74
+ "No components with prefix '" +
75
+ prefix +
76
+ "' found in design '" +
77
+ resolved.entry.name +
78
+ "'. Available prefixes: [" +
79
+ this.#availablePrefixes(resolved.netlist).join(', ') +
80
+ ']'
81
+ }
82
+ }
83
+
84
+ return {
85
+ components: ComponentGrouping.groupComponentsByMpn(
86
+ entries,
87
+ args.include_dns === true
88
+ )
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Lists net names for one loaded design.
94
+ * @param {{ design?: string }} [args] Query args.
95
+ * @returns {{ nets: string[] } | { error: string }}
96
+ */
97
+ listNets(args = {}) {
98
+ const resolved = this.#resolveDesignWithConnectivity(args.design)
99
+ if (resolved.error) return resolved
100
+
101
+ return {
102
+ nets: Object.keys(resolved.netlist.nets).sort((left, right) =>
103
+ left.localeCompare(right)
104
+ )
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Searches net names by regex.
110
+ * @param {{ design?: string, pattern?: string }} [args] Query args.
111
+ * @returns {{ results: Record<string, string[]>, notes?: string[] } | { error: string }}
112
+ */
113
+ searchNets(args = {}) {
114
+ const resolved = this.#resolveDesignWithConnectivity(args.design)
115
+ if (resolved.error) return resolved
116
+
117
+ const parsed = RegexPattern.parse(args.pattern)
118
+ if (parsed.error) return parsed
119
+
120
+ const allNets = Object.keys(resolved.netlist.nets)
121
+ if (RegexPattern.rejectsBroadMatch(args.pattern, allNets)) {
122
+ return {
123
+ error: 'Pattern matches every net. Use list_nets for full net lists.'
124
+ }
125
+ }
126
+
127
+ const matches = allNets
128
+ .filter((net) => {
129
+ parsed.regex.lastIndex = 0
130
+ return parsed.regex.test(net)
131
+ })
132
+ .sort((left, right) => left.localeCompare(right))
133
+ const response = {
134
+ results: {
135
+ [resolved.entry.name]: matches
136
+ }
137
+ }
138
+
139
+ if (!matches.length) {
140
+ response.notes = [
141
+ "No nets matched pattern '" + String(args.pattern || '') + "'"
142
+ ]
143
+ }
144
+
145
+ return response
146
+ }
147
+
148
+ /**
149
+ * Searches components by reference designator.
150
+ * @param {{ design?: string, pattern?: string, include_dns?: boolean }} [args] Query args.
151
+ * @returns {{ results: Record<string, object[]>, notes?: string[] } | { error: string }}
152
+ */
153
+ searchComponentsByRefdes(args = {}) {
154
+ return this.#searchComponents(args, 'refdes')
155
+ }
156
+
157
+ /**
158
+ * Searches components by MPN.
159
+ * @param {{ design?: string, pattern?: string, include_dns?: boolean }} [args] Query args.
160
+ * @returns {{ results: Record<string, object[]>, notes?: string[] } | { error: string }}
161
+ */
162
+ searchComponentsByMpn(args = {}) {
163
+ return this.#searchComponents(args, 'mpn')
164
+ }
165
+
166
+ /**
167
+ * Searches components by description.
168
+ * @param {{ design?: string, pattern?: string, include_dns?: boolean }} [args] Query args.
169
+ * @returns {{ results: Record<string, object[]>, notes?: string[] } | { error: string }}
170
+ */
171
+ searchComponentsByDescription(args = {}) {
172
+ return this.#searchComponents(args, 'description')
173
+ }
174
+
175
+ /**
176
+ * Queries one component and all known pin connections.
177
+ * @param {{ design?: string, refdes?: string }} [args] Query args.
178
+ * @returns {object | { error: string }}
179
+ */
180
+ queryComponent(args = {}) {
181
+ const resolved = this.#resolveDesign(args.design)
182
+ if (resolved.error) return resolved
183
+
184
+ const refdes = String(args.refdes || '').trim()
185
+ const entry = Object.entries(resolved.netlist.components).find(
186
+ ([candidate]) => candidate.toLowerCase() === refdes.toLowerCase()
187
+ )
188
+ if (!entry) {
189
+ return {
190
+ error:
191
+ "Component '" +
192
+ refdes +
193
+ "' not found in design '" +
194
+ resolved.entry.name +
195
+ "'. Use list_components or search_components_by_refdes."
196
+ }
197
+ }
198
+
199
+ return LoadedDesignNetlistService.#componentDetails(entry)
200
+ }
201
+
202
+ /**
203
+ * Queries extended connectivity starting from a net name.
204
+ * @param {{ design?: string, net_name?: string, skip_types?: string[], include_dns?: boolean }} [args] Query args.
205
+ * @returns {object | { error: string }}
206
+ */
207
+ queryXnetByNetName(args = {}) {
208
+ const resolved = this.#resolveDesignWithConnectivity(args.design)
209
+ if (resolved.error) return resolved
210
+
211
+ const netName = this.#resolveNetName(resolved.netlist, args.net_name)
212
+ if (!netName) {
213
+ return {
214
+ error:
215
+ "Net '" +
216
+ String(args.net_name || '') +
217
+ "' not found in design '" +
218
+ resolved.entry.name +
219
+ "'. Use search_nets to find available nets."
220
+ }
221
+ }
222
+ if (CircuitTraversal.isStopNet(netName)) {
223
+ return {
224
+ error:
225
+ netName + ' is a power or ground net and cannot be queried.'
226
+ }
227
+ }
228
+
229
+ return this.#buildTraversalResponse(
230
+ netName,
231
+ netName,
232
+ '',
233
+ resolved.netlist,
234
+ args
235
+ )
236
+ }
237
+
238
+ /**
239
+ * Queries extended connectivity starting from a component pin.
240
+ * @param {{ design?: string, pin_name?: string, skip_types?: string[], include_dns?: boolean }} [args] Query args.
241
+ * @returns {object | { error: string }}
242
+ */
243
+ queryXnetByPinName(args = {}) {
244
+ const resolved = this.#resolveDesignWithConnectivity(args.design)
245
+ if (resolved.error) return resolved
246
+
247
+ const pinSpec = LoadedDesignNetlistService.#parsePinSpec(args.pin_name)
248
+ if (pinSpec.error) return pinSpec
249
+
250
+ const componentEntry = Object.entries(resolved.netlist.components).find(
251
+ ([refdes]) => refdes.toLowerCase() === pinSpec.refdes.toLowerCase()
252
+ )
253
+ if (!componentEntry) {
254
+ return {
255
+ error:
256
+ "Component '" +
257
+ pinSpec.refdes +
258
+ "' not found in design '" +
259
+ resolved.entry.name +
260
+ "'. Use list_components or search_components_by_refdes."
261
+ }
262
+ }
263
+
264
+ const [resolvedRefdes, component] = componentEntry
265
+ const pinKey = Object.keys(component.pins || {}).find((candidate) => {
266
+ return candidate.toLowerCase() === pinSpec.pin.toLowerCase()
267
+ })
268
+ if (!pinKey) {
269
+ return {
270
+ error:
271
+ "Pin '" +
272
+ resolvedRefdes +
273
+ '.' +
274
+ pinSpec.pin +
275
+ "' not found. Component " +
276
+ resolvedRefdes +
277
+ ' has pins: [' +
278
+ Object.keys(component.pins || {})
279
+ .sort(ComponentGrouping.naturalSort)
280
+ .join(', ') +
281
+ ']'
282
+ }
283
+ }
284
+
285
+ const netName = LoadedDesignNetlistService.#pinNet(
286
+ component.pins[pinKey]
287
+ )
288
+ if (netName === 'NC') {
289
+ const startingPoint = resolvedRefdes + '.' + pinKey
290
+ return {
291
+ starting_point: startingPoint,
292
+ net: netName,
293
+ total_components: 0,
294
+ unique_configurations: 0,
295
+ components_by_mpn: [],
296
+ visited_nets: ['NC'],
297
+ circuit_hash: 'nc-' + startingPoint
298
+ }
299
+ }
300
+ if (CircuitTraversal.isStopNet(netName)) {
301
+ return {
302
+ error:
303
+ 'Pin ' +
304
+ resolvedRefdes +
305
+ '.' +
306
+ pinKey +
307
+ ' is connected to ' +
308
+ netName +
309
+ ' and cannot be queried.'
310
+ }
311
+ }
312
+
313
+ return this.#buildTraversalResponse(
314
+ resolvedRefdes + '.' + pinKey,
315
+ netName,
316
+ netName,
317
+ resolved.netlist,
318
+ args
319
+ )
320
+ }
321
+
322
+ /**
323
+ * Searches components on one metadata field.
324
+ * @param {{ design?: string, pattern?: string, include_dns?: boolean }} args Query args.
325
+ * @param {'refdes' | 'mpn' | 'description'} field Search field.
326
+ * @returns {{ results: Record<string, object[]>, notes?: string[] } | { error: string }}
327
+ */
328
+ #searchComponents(args, field) {
329
+ const resolved = this.#resolveDesign(args.design)
330
+ if (resolved.error) return resolved
331
+
332
+ const parsed = RegexPattern.parse(args.pattern)
333
+ if (parsed.error) return parsed
334
+
335
+ const allEntries = Object.entries(resolved.netlist.components)
336
+ const searchableEntries = allEntries.filter(([refdes, component]) => {
337
+ return LoadedDesignNetlistService.#searchValue(
338
+ refdes,
339
+ component,
340
+ field
341
+ )
342
+ })
343
+ const matches = searchableEntries.filter(([refdes, component]) => {
344
+ parsed.regex.lastIndex = 0
345
+ return parsed.regex.test(
346
+ LoadedDesignNetlistService.#searchValue(
347
+ refdes,
348
+ component,
349
+ field
350
+ )
351
+ )
352
+ })
353
+
354
+ if (
355
+ RegexPattern.rejectsBroadMatch(
356
+ args.pattern,
357
+ searchableEntries.map(([refdes, component]) =>
358
+ LoadedDesignNetlistService.#searchValue(
359
+ refdes,
360
+ component,
361
+ field
362
+ )
363
+ )
364
+ )
365
+ ) {
366
+ return {
367
+ error: 'Pattern matches every component. Use list_components for prefix-based lists.'
368
+ }
369
+ }
370
+
371
+ const response = {
372
+ results: {
373
+ [resolved.entry.name]: ComponentGrouping.groupComponentsByMpn(
374
+ matches,
375
+ args.include_dns === true
376
+ )
377
+ }
378
+ }
379
+
380
+ if (!response.results[resolved.entry.name].length) {
381
+ response.notes = [
382
+ "No components matched pattern '" +
383
+ String(args.pattern || '') +
384
+ "'."
385
+ ]
386
+ }
387
+
388
+ return response
389
+ }
390
+
391
+ /**
392
+ * Resolves one design and requires schematic connectivity.
393
+ * @param {string | undefined} design Design selector.
394
+ * @returns {object | { error: string }}
395
+ */
396
+ #resolveDesignWithConnectivity(design) {
397
+ const resolved = this.#resolveDesign(design)
398
+ if (resolved.error) return resolved
399
+ if (!Object.keys(resolved.netlist.nets).length) {
400
+ return {
401
+ error: 'No schematic connectivity is available for this loaded design.'
402
+ }
403
+ }
404
+
405
+ return resolved
406
+ }
407
+
408
+ /**
409
+ * Builds an aggregated traversal response.
410
+ * @param {string} startingPoint Response starting point.
411
+ * @param {string} netName Starting net.
412
+ * @param {string} responseNet Optional response net field.
413
+ * @param {{ nets: object, components: object }} netlist Query netlist.
414
+ * @param {{ skip_types?: string[], include_dns?: boolean }} args Query args.
415
+ * @returns {object}
416
+ */
417
+ #buildTraversalResponse(
418
+ startingPoint,
419
+ netName,
420
+ responseNet,
421
+ netlist,
422
+ args
423
+ ) {
424
+ const traversal = CircuitTraversal.traverseCircuitFromNet(
425
+ netName,
426
+ netlist.nets,
427
+ netlist.components,
428
+ {
429
+ skipTypes: Array.isArray(args.skip_types)
430
+ ? args.skip_types
431
+ : [],
432
+ includeDns: args.include_dns === true
433
+ }
434
+ )
435
+ const componentsByMpn = ComponentGrouping.aggregateCircuitByMpn(
436
+ traversal.components
437
+ )
438
+ const response = {
439
+ starting_point: startingPoint,
440
+ net: responseNet || undefined,
441
+ total_components: traversal.components.length,
442
+ unique_configurations:
443
+ LoadedDesignNetlistService.#uniqueConfigurations(
444
+ componentsByMpn
445
+ ),
446
+ components_by_mpn: componentsByMpn,
447
+ visited_nets: traversal.visited_nets,
448
+ circuit_hash: CircuitTraversal.computeCircuitHash(
449
+ traversal.components
450
+ )
451
+ }
452
+
453
+ if (Object.keys(traversal.skipped || {}).length) {
454
+ response.skipped = traversal.skipped
455
+ }
456
+
457
+ return LoadedDesignNetlistService.#withoutUndefined(response)
458
+ }
459
+
460
+ /**
461
+ * Resolves a loaded design selector.
462
+ * @param {string | undefined} design Design selector.
463
+ * @returns {object | { error: string }}
464
+ */
465
+ #resolveDesign(design) {
466
+ const entries = this.#loadedEntries()
467
+ if (!entries.length) {
468
+ return { error: 'No design is loaded in the current session.' }
469
+ }
470
+
471
+ const selector = String(design || 'active').trim()
472
+ if (!selector || selector.toLowerCase() === 'active') {
473
+ const activeEntry =
474
+ entries.find((entry) => entry.active) || entries[0]
475
+ return {
476
+ entry: activeEntry,
477
+ netlist: QueryNetlistBuilder.build(activeEntry.documentModel)
478
+ }
479
+ }
480
+
481
+ const matches = entries.filter((entry) =>
482
+ LoadedDesignNetlistService.#entryMatchesSelector(entry, selector)
483
+ )
484
+ if (matches.length > 1) {
485
+ return {
486
+ error:
487
+ "Design selector '" +
488
+ selector +
489
+ "' is ambiguous. Use a loaded document id."
490
+ }
491
+ }
492
+ if (!matches.length) {
493
+ return {
494
+ error:
495
+ "Design selector '" +
496
+ selector +
497
+ "' did not match a loaded design."
498
+ }
499
+ }
500
+
501
+ return {
502
+ entry: matches[0],
503
+ netlist: QueryNetlistBuilder.build(matches[0].documentModel)
504
+ }
505
+ }
506
+
507
+ /**
508
+ * Returns loaded design entries with derived metadata.
509
+ * @returns {object[]}
510
+ */
511
+ #loadedEntries() {
512
+ return (Array.isArray(this.#getDocuments()) ? this.#getDocuments() : [])
513
+ .map((entry) => LoadedDesignNetlistService.#normalizeEntry(entry))
514
+ .filter((entry) => entry.id && entry.documentModel)
515
+ .map((entry) => {
516
+ const netlist = QueryNetlistBuilder.build(entry.documentModel)
517
+ return {
518
+ ...entry,
519
+ name:
520
+ String(entry.documentModel?.summary?.title || '') ||
521
+ LoadedDesignNetlistService.#baseName(
522
+ entry.documentModel?.fileName
523
+ ),
524
+ fileName: String(entry.documentModel?.fileName || ''),
525
+ baseName: LoadedDesignNetlistService.#baseName(
526
+ entry.documentModel?.fileName
527
+ ),
528
+ kind: String(entry.documentModel?.kind || 'document'),
529
+ hasConnectivity: Boolean(Object.keys(netlist.nets).length)
530
+ }
531
+ })
532
+ }
533
+
534
+ /**
535
+ * Returns available reference-designator prefixes.
536
+ * @param {{ components: object }} netlist Query netlist.
537
+ * @returns {string[]}
538
+ */
539
+ #availablePrefixes(netlist) {
540
+ return [
541
+ ...new Set(
542
+ Object.keys(netlist.components)
543
+ .map((refdes) =>
544
+ LoadedDesignNetlistService.#refdesPrefix(refdes)
545
+ )
546
+ .filter(Boolean)
547
+ )
548
+ ].sort()
549
+ }
550
+
551
+ /**
552
+ * Resolves an exact net name case-insensitively.
553
+ * @param {{ nets: object }} netlist Query netlist.
554
+ * @param {string | undefined} netName Requested net.
555
+ * @returns {string}
556
+ */
557
+ #resolveNetName(netlist, netName) {
558
+ const requested = String(netName || '')
559
+ .trim()
560
+ .toLowerCase()
561
+ return (
562
+ Object.keys(netlist.nets).find((candidate) => {
563
+ return candidate.toLowerCase() === requested
564
+ }) || ''
565
+ )
566
+ }
567
+
568
+ /**
569
+ * Normalizes a loaded document entry.
570
+ * @param {object} entry Raw entry.
571
+ * @returns {object}
572
+ */
573
+ static #normalizeEntry(entry) {
574
+ const documentModel = entry?.documentModel || entry
575
+ return {
576
+ id: String(entry?.id || documentModel?.id || ''),
577
+ active: entry?.active === true,
578
+ documentModel
579
+ }
580
+ }
581
+
582
+ /**
583
+ * Returns one component query response from an entry.
584
+ * @param {[string, object]} entry Component entry.
585
+ * @returns {object}
586
+ */
587
+ static #componentDetails(entry) {
588
+ const [refdes, component] = entry
589
+ const dns = ComponentGrouping.isDnsComponent(component)
590
+ const result = {
591
+ refdes,
592
+ mpn: LoadedDesignNetlistService.#trim(component.mpn),
593
+ description: LoadedDesignNetlistService.#trim(
594
+ component.description
595
+ ),
596
+ comment: LoadedDesignNetlistService.#trim(component.comment),
597
+ value: LoadedDesignNetlistService.#trim(component.value),
598
+ dns: dns || undefined,
599
+ pins: component.pins || {},
600
+ notes: component.mpn ? undefined : [MPN_MISSING_NOTE]
601
+ }
602
+
603
+ return LoadedDesignNetlistService.#withoutUndefined(result)
604
+ }
605
+
606
+ /**
607
+ * Returns the searchable field value for a component.
608
+ * @param {string} refdes Component refdes.
609
+ * @param {object} component Component metadata.
610
+ * @param {'refdes' | 'mpn' | 'description'} field Search field.
611
+ * @returns {string}
612
+ */
613
+ static #searchValue(refdes, component, field) {
614
+ if (field === 'refdes') return String(refdes || '')
615
+ return String(component?.[field] || '')
616
+ }
617
+
618
+ /**
619
+ * Returns true when an entry matches a design selector.
620
+ * @param {object} entry Loaded entry.
621
+ * @param {string} selector Selector.
622
+ * @returns {boolean}
623
+ */
624
+ static #entryMatchesSelector(entry, selector) {
625
+ const normalized = selector.toLowerCase()
626
+ return (
627
+ entry.id.toLowerCase() === normalized ||
628
+ entry.fileName.toLowerCase() === normalized ||
629
+ entry.baseName.toLowerCase() === normalized
630
+ )
631
+ }
632
+
633
+ /**
634
+ * Parses a `REFDES.PIN` pin spec.
635
+ * @param {string | undefined} value Pin spec.
636
+ * @returns {{ refdes: string, pin: string } | { error: string }}
637
+ */
638
+ static #parsePinSpec(value) {
639
+ const raw = String(value || '').trim()
640
+ const separator = raw.indexOf('.')
641
+ if (separator <= 0 || separator === raw.length - 1) {
642
+ return {
643
+ error: "Invalid pin name '" + raw + "'. Expected 'REFDES.PIN'."
644
+ }
645
+ }
646
+
647
+ return {
648
+ refdes: raw.slice(0, separator),
649
+ pin: raw.slice(separator + 1)
650
+ }
651
+ }
652
+
653
+ /**
654
+ * Extracts a net name from a pin entry.
655
+ * @param {string | { net?: string }} entry Pin entry.
656
+ * @returns {string}
657
+ */
658
+ static #pinNet(entry) {
659
+ return typeof entry === 'string' ? entry : String(entry?.net || '')
660
+ }
661
+
662
+ /**
663
+ * Counts unique component orientation configurations.
664
+ * @param {object[]} groups Aggregated groups.
665
+ * @returns {number}
666
+ */
667
+ static #uniqueConfigurations(groups) {
668
+ return (groups || []).reduce((count, group) => {
669
+ return (
670
+ count +
671
+ (Array.isArray(group.orientations)
672
+ ? group.orientations.length
673
+ : 1)
674
+ )
675
+ }, 0)
676
+ }
677
+
678
+ /**
679
+ * Resolves a file base name.
680
+ * @param {string | undefined} fileName File name.
681
+ * @returns {string}
682
+ */
683
+ static #baseName(fileName) {
684
+ return String(fileName || '').replace(/\.[^.]+$/, '')
685
+ }
686
+
687
+ /**
688
+ * Resolves a reference-designator prefix.
689
+ * @param {string} refdes Reference designator.
690
+ * @returns {string}
691
+ */
692
+ static #refdesPrefix(refdes) {
693
+ return (
694
+ String(refdes || '')
695
+ .match(/^[A-Za-z]+/)?.[0]
696
+ ?.toUpperCase() || ''
697
+ )
698
+ }
699
+
700
+ /**
701
+ * Resolves a max-results limit.
702
+ * @param {unknown} value Raw value.
703
+ * @returns {number}
704
+ */
705
+ static #maxResults(value) {
706
+ const parsed = Number.parseInt(String(value || ''), 10)
707
+ return Number.isInteger(parsed) && parsed > 0 ? parsed : 50
708
+ }
709
+
710
+ /**
711
+ * Returns a trimmed string or undefined.
712
+ * @param {unknown} value Raw value.
713
+ * @returns {string | undefined}
714
+ */
715
+ static #trim(value) {
716
+ const trimmed = String(value || '').trim()
717
+ return trimmed || undefined
718
+ }
719
+
720
+ /**
721
+ * Removes undefined values from an object.
722
+ * @param {object} value Object value.
723
+ * @returns {object}
724
+ */
725
+ static #withoutUndefined(value) {
726
+ return Object.fromEntries(
727
+ Object.entries(value).filter(([, entryValue]) => {
728
+ return entryValue !== undefined
729
+ })
730
+ )
731
+ }
732
+ }