@jupyterlab/settingregistry 4.0.0-alpha.9 → 4.0.0-beta.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.
@@ -0,0 +1,1489 @@
1
+ // Copyright (c) Jupyter Development Team.
2
+ // Distributed under the terms of the Modified BSD License.
3
+
4
+ import { IDataConnector } from '@jupyterlab/statedb';
5
+ import { CommandRegistry } from '@lumino/commands';
6
+ import {
7
+ JSONExt,
8
+ JSONObject,
9
+ JSONValue,
10
+ PartialJSONArray,
11
+ PartialJSONObject,
12
+ PartialJSONValue,
13
+ ReadonlyJSONObject,
14
+ ReadonlyPartialJSONObject,
15
+ ReadonlyPartialJSONValue
16
+ } from '@lumino/coreutils';
17
+ import { DisposableDelegate, IDisposable } from '@lumino/disposable';
18
+ import { ISignal, Signal } from '@lumino/signaling';
19
+ import Ajv, { Options as AjvOptions } from 'ajv';
20
+ import * as json5 from 'json5';
21
+ import SCHEMA from './plugin-schema.json';
22
+ import { ISettingRegistry } from './tokens';
23
+
24
+ /**
25
+ * An alias for the JSON deep copy function.
26
+ */
27
+ const copy = JSONExt.deepCopy;
28
+
29
+ /** Default arguments for Ajv instances.
30
+ *
31
+ * https://ajv.js.org/options.html
32
+ */
33
+ const AJV_DEFAULT_OPTIONS: Partial<AjvOptions> = {
34
+ /**
35
+ * @todo the implications of enabling strict mode are beyond the scope of
36
+ * the initial PR
37
+ */
38
+ strict: false
39
+ };
40
+
41
+ /**
42
+ * The default number of milliseconds before a `load()` call to the registry
43
+ * will wait before timing out if it requires a transformation that has not been
44
+ * registered.
45
+ */
46
+ const DEFAULT_TRANSFORM_TIMEOUT = 1000;
47
+
48
+ /**
49
+ * The ASCII record separator character.
50
+ */
51
+ const RECORD_SEPARATOR = String.fromCharCode(30);
52
+
53
+ /**
54
+ * An implementation of a schema validator.
55
+ */
56
+ export interface ISchemaValidator {
57
+ /**
58
+ * Validate a plugin's schema and user data; populate the `composite` data.
59
+ *
60
+ * @param plugin - The plugin being validated. Its `composite` data will be
61
+ * populated by reference.
62
+ *
63
+ * @param populate - Whether plugin data should be populated, defaults to
64
+ * `true`.
65
+ *
66
+ * @returns A list of errors if either the schema or data fail to validate or
67
+ * `null` if there are no errors.
68
+ */
69
+ validateData(
70
+ plugin: ISettingRegistry.IPlugin,
71
+ populate?: boolean
72
+ ): ISchemaValidator.IError[] | null;
73
+ }
74
+
75
+ /**
76
+ * A namespace for schema validator interfaces.
77
+ */
78
+ export namespace ISchemaValidator {
79
+ /**
80
+ * A schema validation error definition.
81
+ */
82
+ export interface IError {
83
+ /**
84
+ * The keyword whose validation failed.
85
+ */
86
+ keyword: string | string[];
87
+
88
+ /**
89
+ * The error message.
90
+ */
91
+ message?: string;
92
+
93
+ /**
94
+ * Optional parameter metadata that might be included in an error.
95
+ */
96
+ params?: ReadonlyJSONObject;
97
+
98
+ /**
99
+ * The path in the schema where the error occurred.
100
+ */
101
+ schemaPath: string;
102
+
103
+ /**
104
+ * @todo handle new fields from ajv8
105
+ **/
106
+ schema?: unknown;
107
+
108
+ /**
109
+ * @todo handle new fields from ajv8
110
+ **/
111
+ instancePath: string;
112
+
113
+ /**
114
+ * @todo handle new fields from ajv8
115
+ **/
116
+ propertyName?: string;
117
+
118
+ /**
119
+ * @todo handle new fields from ajv8
120
+ **/
121
+ data?: unknown;
122
+
123
+ /**
124
+ * @todo handle new fields from ajv8
125
+ **/
126
+ parentSchema?: unknown;
127
+ }
128
+ }
129
+
130
+ /**
131
+ * The default implementation of a schema validator.
132
+ */
133
+ export class DefaultSchemaValidator implements ISchemaValidator {
134
+ /**
135
+ * Instantiate a schema validator.
136
+ */
137
+ constructor() {
138
+ this._composer.addSchema(SCHEMA, 'jupyterlab-plugin-schema');
139
+ this._validator.addSchema(SCHEMA, 'jupyterlab-plugin-schema');
140
+ }
141
+
142
+ /**
143
+ * Validate a plugin's schema and user data; populate the `composite` data.
144
+ *
145
+ * @param plugin - The plugin being validated. Its `composite` data will be
146
+ * populated by reference.
147
+ *
148
+ * @param populate - Whether plugin data should be populated, defaults to
149
+ * `true`.
150
+ *
151
+ * @returns A list of errors if either the schema or data fail to validate or
152
+ * `null` if there are no errors.
153
+ */
154
+ validateData(
155
+ plugin: ISettingRegistry.IPlugin,
156
+ populate = true
157
+ ): ISchemaValidator.IError[] | null {
158
+ const validate = this._validator.getSchema(plugin.id);
159
+ const compose = this._composer.getSchema(plugin.id);
160
+
161
+ // If the schemas do not exist, add them to the validator and continue.
162
+ if (!validate || !compose) {
163
+ if (plugin.schema.type !== 'object') {
164
+ const keyword = 'schema';
165
+ const message =
166
+ `Setting registry schemas' root-level type must be ` +
167
+ `'object', rejecting type: ${plugin.schema.type}`;
168
+
169
+ return [{ instancePath: 'type', keyword, schemaPath: '', message }];
170
+ }
171
+
172
+ const errors = this._addSchema(plugin.id, plugin.schema);
173
+
174
+ return errors || this.validateData(plugin);
175
+ }
176
+
177
+ // Parse the raw commented JSON into a user map.
178
+ let user: JSONObject;
179
+ try {
180
+ user = json5.parse(plugin.raw) as JSONObject;
181
+ } catch (error) {
182
+ if (error instanceof SyntaxError) {
183
+ return [
184
+ {
185
+ instancePath: '',
186
+ keyword: 'syntax',
187
+ schemaPath: '',
188
+ message: error.message
189
+ }
190
+ ];
191
+ }
192
+
193
+ const { column, description } = error;
194
+ const line = error.lineNumber;
195
+
196
+ return [
197
+ {
198
+ instancePath: '',
199
+ keyword: 'parse',
200
+ schemaPath: '',
201
+ message: `${description} (line ${line} column ${column})`
202
+ }
203
+ ];
204
+ }
205
+
206
+ if (!validate(user)) {
207
+ return validate.errors as ISchemaValidator.IError[];
208
+ }
209
+
210
+ // Copy the user data before merging defaults into composite map.
211
+ const composite = copy(user);
212
+
213
+ if (!compose(composite)) {
214
+ return compose.errors as ISchemaValidator.IError[];
215
+ }
216
+
217
+ if (populate) {
218
+ plugin.data = { composite, user };
219
+ }
220
+
221
+ return null;
222
+ }
223
+
224
+ /**
225
+ * Add a schema to the validator.
226
+ *
227
+ * @param plugin - The plugin ID.
228
+ *
229
+ * @param schema - The schema being added.
230
+ *
231
+ * @returns A list of errors if the schema fails to validate or `null` if there
232
+ * are no errors.
233
+ *
234
+ * #### Notes
235
+ * It is safe to call this function multiple times with the same plugin name.
236
+ */
237
+ private _addSchema(
238
+ plugin: string,
239
+ schema: ISettingRegistry.ISchema
240
+ ): ISchemaValidator.IError[] | null {
241
+ const composer = this._composer;
242
+ const validator = this._validator;
243
+ const validate = validator.getSchema('jupyterlab-plugin-schema')!;
244
+
245
+ // Validate against the main schema.
246
+ if (!(validate!(schema) as boolean)) {
247
+ return validate!.errors as ISchemaValidator.IError[];
248
+ }
249
+
250
+ // Validate against the JSON schema meta-schema.
251
+ if (!(validator.validateSchema(schema) as boolean)) {
252
+ return validator.errors as ISchemaValidator.IError[];
253
+ }
254
+
255
+ // Remove if schema already exists.
256
+ composer.removeSchema(plugin);
257
+ validator.removeSchema(plugin);
258
+
259
+ // Add schema to the validator and composer.
260
+ composer.addSchema(schema, plugin);
261
+ validator.addSchema(schema, plugin);
262
+
263
+ return null;
264
+ }
265
+
266
+ private _composer: Ajv = new Ajv({
267
+ useDefaults: true,
268
+ ...AJV_DEFAULT_OPTIONS
269
+ });
270
+ private _validator: Ajv = new Ajv({ ...AJV_DEFAULT_OPTIONS });
271
+ }
272
+
273
+ /**
274
+ * The default concrete implementation of a setting registry.
275
+ */
276
+ export class SettingRegistry implements ISettingRegistry {
277
+ /**
278
+ * Create a new setting registry.
279
+ */
280
+ constructor(options: SettingRegistry.IOptions) {
281
+ this.connector = options.connector;
282
+ this.validator = options.validator || new DefaultSchemaValidator();
283
+ this._timeout = options.timeout || DEFAULT_TRANSFORM_TIMEOUT;
284
+
285
+ // Preload with any available data at instantiation-time.
286
+ if (options.plugins) {
287
+ this._ready = this._preload(options.plugins);
288
+ }
289
+ }
290
+
291
+ /**
292
+ * The data connector used by the setting registry.
293
+ */
294
+ readonly connector: IDataConnector<ISettingRegistry.IPlugin, string, string>;
295
+
296
+ /**
297
+ * The schema of the setting registry.
298
+ */
299
+ readonly schema = SCHEMA as ISettingRegistry.ISchema;
300
+
301
+ /**
302
+ * The schema validator used by the setting registry.
303
+ */
304
+ readonly validator: ISchemaValidator;
305
+
306
+ /**
307
+ * A signal that emits the name of a plugin when its settings change.
308
+ */
309
+ get pluginChanged(): ISignal<this, string> {
310
+ return this._pluginChanged;
311
+ }
312
+
313
+ /**
314
+ * The collection of setting registry plugins.
315
+ */
316
+ readonly plugins: {
317
+ [name: string]: ISettingRegistry.IPlugin;
318
+ } = Object.create(null);
319
+
320
+ /**
321
+ * Get an individual setting.
322
+ *
323
+ * @param plugin - The name of the plugin whose settings are being retrieved.
324
+ *
325
+ * @param key - The name of the setting being retrieved.
326
+ *
327
+ * @returns A promise that resolves when the setting is retrieved.
328
+ */
329
+ async get(
330
+ plugin: string,
331
+ key: string
332
+ ): Promise<{
333
+ composite: PartialJSONValue | undefined;
334
+ user: PartialJSONValue | undefined;
335
+ }> {
336
+ // Wait for data preload before allowing normal operation.
337
+ await this._ready;
338
+
339
+ const plugins = this.plugins;
340
+
341
+ if (plugin in plugins) {
342
+ const { composite, user } = plugins[plugin].data;
343
+
344
+ return {
345
+ composite:
346
+ composite[key] !== undefined ? copy(composite[key]!) : undefined,
347
+ user: user[key] !== undefined ? copy(user[key]!) : undefined
348
+ };
349
+ }
350
+
351
+ return this.load(plugin).then(() => this.get(plugin, key));
352
+ }
353
+
354
+ /**
355
+ * Load a plugin's settings into the setting registry.
356
+ *
357
+ * @param plugin - The name of the plugin whose settings are being loaded.
358
+ *
359
+ * @returns A promise that resolves with a plugin settings object or rejects
360
+ * if the plugin is not found.
361
+ */
362
+ async load(plugin: string): Promise<ISettingRegistry.ISettings> {
363
+ // Wait for data preload before allowing normal operation.
364
+ await this._ready;
365
+
366
+ const plugins = this.plugins;
367
+ const registry = this; // eslint-disable-line
368
+
369
+ // If the plugin exists, resolve.
370
+ if (plugin in plugins) {
371
+ return new Settings({ plugin: plugins[plugin], registry });
372
+ }
373
+
374
+ // If the plugin needs to be loaded from the data connector, fetch.
375
+ return this.reload(plugin);
376
+ }
377
+
378
+ /**
379
+ * Reload a plugin's settings into the registry even if they already exist.
380
+ *
381
+ * @param plugin - The name of the plugin whose settings are being reloaded.
382
+ *
383
+ * @returns A promise that resolves with a plugin settings object or rejects
384
+ * with a list of `ISchemaValidator.IError` objects if it fails.
385
+ */
386
+ async reload(plugin: string): Promise<ISettingRegistry.ISettings> {
387
+ // Wait for data preload before allowing normal operation.
388
+ await this._ready;
389
+
390
+ const fetched = await this.connector.fetch(plugin);
391
+ const plugins = this.plugins; // eslint-disable-line
392
+ const registry = this; // eslint-disable-line
393
+
394
+ if (fetched === undefined) {
395
+ throw [
396
+ {
397
+ instancePath: '',
398
+ keyword: 'id',
399
+ message: `Could not fetch settings for ${plugin}.`,
400
+ schemaPath: ''
401
+ } as ISchemaValidator.IError
402
+ ];
403
+ }
404
+ await this._load(await this._transform('fetch', fetched));
405
+ this._pluginChanged.emit(plugin);
406
+
407
+ return new Settings({ plugin: plugins[plugin], registry });
408
+ }
409
+
410
+ /**
411
+ * Remove a single setting in the registry.
412
+ *
413
+ * @param plugin - The name of the plugin whose setting is being removed.
414
+ *
415
+ * @param key - The name of the setting being removed.
416
+ *
417
+ * @returns A promise that resolves when the setting is removed.
418
+ */
419
+ async remove(plugin: string, key: string): Promise<void> {
420
+ // Wait for data preload before allowing normal operation.
421
+ await this._ready;
422
+
423
+ const plugins = this.plugins;
424
+
425
+ if (!(plugin in plugins)) {
426
+ return;
427
+ }
428
+
429
+ const raw = json5.parse(plugins[plugin].raw);
430
+
431
+ // Delete both the value and any associated comment.
432
+ delete raw[key];
433
+ delete raw[`// ${key}`];
434
+ plugins[plugin].raw = Private.annotatedPlugin(plugins[plugin], raw);
435
+
436
+ return this._save(plugin);
437
+ }
438
+
439
+ /**
440
+ * Set a single setting in the registry.
441
+ *
442
+ * @param plugin - The name of the plugin whose setting is being set.
443
+ *
444
+ * @param key - The name of the setting being set.
445
+ *
446
+ * @param value - The value of the setting being set.
447
+ *
448
+ * @returns A promise that resolves when the setting has been saved.
449
+ *
450
+ */
451
+ async set(plugin: string, key: string, value: JSONValue): Promise<void> {
452
+ // Wait for data preload before allowing normal operation.
453
+ await this._ready;
454
+
455
+ const plugins = this.plugins;
456
+
457
+ if (!(plugin in plugins)) {
458
+ return this.load(plugin).then(() => this.set(plugin, key, value));
459
+ }
460
+
461
+ // Parse the raw JSON string removing all comments and return an object.
462
+ const raw = json5.parse(plugins[plugin].raw);
463
+
464
+ plugins[plugin].raw = Private.annotatedPlugin(plugins[plugin], {
465
+ ...raw,
466
+ [key]: value
467
+ });
468
+
469
+ return this._save(plugin);
470
+ }
471
+
472
+ /**
473
+ * Register a plugin transform function to act on a specific plugin.
474
+ *
475
+ * @param plugin - The name of the plugin whose settings are transformed.
476
+ *
477
+ * @param transforms - The transform functions applied to the plugin.
478
+ *
479
+ * @returns A disposable that removes the transforms from the registry.
480
+ *
481
+ * #### Notes
482
+ * - `compose` transformations: The registry automatically overwrites a
483
+ * plugin's default values with user overrides, but a plugin may instead wish
484
+ * to merge values. This behavior can be accomplished in a `compose`
485
+ * transformation.
486
+ * - `fetch` transformations: The registry uses the plugin data that is
487
+ * fetched from its connector. If a plugin wants to override, e.g. to update
488
+ * its schema with dynamic defaults, a `fetch` transformation can be applied.
489
+ */
490
+ transform(
491
+ plugin: string,
492
+ transforms: {
493
+ [phase in ISettingRegistry.IPlugin.Phase]?: ISettingRegistry.IPlugin.Transform;
494
+ }
495
+ ): IDisposable {
496
+ const transformers = this._transformers;
497
+
498
+ if (plugin in transformers) {
499
+ const error = new Error(`${plugin} already has a transformer.`);
500
+ error.name = 'TransformError';
501
+ throw error;
502
+ }
503
+
504
+ transformers[plugin] = {
505
+ fetch: transforms.fetch || (plugin => plugin),
506
+ compose: transforms.compose || (plugin => plugin)
507
+ };
508
+
509
+ return new DisposableDelegate(() => {
510
+ delete transformers[plugin];
511
+ });
512
+ }
513
+
514
+ /**
515
+ * Upload a plugin's settings.
516
+ *
517
+ * @param plugin - The name of the plugin whose settings are being set.
518
+ *
519
+ * @param raw - The raw plugin settings being uploaded.
520
+ *
521
+ * @returns A promise that resolves when the settings have been saved.
522
+ */
523
+ async upload(plugin: string, raw: string): Promise<void> {
524
+ // Wait for data preload before allowing normal operation.
525
+ await this._ready;
526
+
527
+ const plugins = this.plugins;
528
+
529
+ if (!(plugin in plugins)) {
530
+ return this.load(plugin).then(() => this.upload(plugin, raw));
531
+ }
532
+
533
+ // Set the local copy.
534
+ plugins[plugin].raw = raw;
535
+
536
+ return this._save(plugin);
537
+ }
538
+
539
+ /**
540
+ * Load a plugin into the registry.
541
+ */
542
+ private async _load(data: ISettingRegistry.IPlugin): Promise<void> {
543
+ const plugin = data.id;
544
+
545
+ // Validate and preload the item.
546
+ try {
547
+ await this._validate(data);
548
+ } catch (errors) {
549
+ const output = [`Validating ${plugin} failed:`];
550
+
551
+ (errors as ISchemaValidator.IError[]).forEach((error, index) => {
552
+ const { instancePath, schemaPath, keyword, message } = error;
553
+
554
+ if (instancePath || schemaPath) {
555
+ output.push(
556
+ `${index} - schema @ ${schemaPath}, data @ ${instancePath}`
557
+ );
558
+ }
559
+ output.push(`{${keyword}} ${message}`);
560
+ });
561
+ console.warn(output.join('\n'));
562
+
563
+ throw errors;
564
+ }
565
+ }
566
+
567
+ /**
568
+ * Preload a list of plugins and fail gracefully.
569
+ */
570
+ private async _preload(plugins: ISettingRegistry.IPlugin[]): Promise<void> {
571
+ await Promise.all(
572
+ plugins.map(async plugin => {
573
+ try {
574
+ // Apply a transformation to the plugin if necessary.
575
+ await this._load(await this._transform('fetch', plugin));
576
+ } catch (errors) {
577
+ /* Ignore preload timeout errors silently. */
578
+ if (errors[0]?.keyword !== 'timeout') {
579
+ console.warn('Ignored setting registry preload errors.', errors);
580
+ }
581
+ }
582
+ })
583
+ );
584
+ }
585
+
586
+ /**
587
+ * Save a plugin in the registry.
588
+ */
589
+ private async _save(plugin: string): Promise<void> {
590
+ const plugins = this.plugins;
591
+
592
+ if (!(plugin in plugins)) {
593
+ throw new Error(`${plugin} does not exist in setting registry.`);
594
+ }
595
+
596
+ try {
597
+ await this._validate(plugins[plugin]);
598
+ } catch (errors) {
599
+ console.warn(`${plugin} validation errors:`, errors);
600
+ throw new Error(`${plugin} failed to validate; check console.`);
601
+ }
602
+ await this.connector.save(plugin, plugins[plugin].raw);
603
+
604
+ // Fetch and reload the data to guarantee server and client are in sync.
605
+ const fetched = await this.connector.fetch(plugin);
606
+ if (fetched === undefined) {
607
+ throw [
608
+ {
609
+ instancePath: '',
610
+ keyword: 'id',
611
+ message: `Could not fetch settings for ${plugin}.`,
612
+ schemaPath: ''
613
+ } as ISchemaValidator.IError
614
+ ];
615
+ }
616
+ await this._load(await this._transform('fetch', fetched));
617
+ this._pluginChanged.emit(plugin);
618
+ }
619
+
620
+ /**
621
+ * Transform the plugin if necessary.
622
+ */
623
+ private async _transform(
624
+ phase: ISettingRegistry.IPlugin.Phase,
625
+ plugin: ISettingRegistry.IPlugin,
626
+ started = new Date().getTime()
627
+ ): Promise<ISettingRegistry.IPlugin> {
628
+ const elapsed = new Date().getTime() - started;
629
+ const id = plugin.id;
630
+ const transformers = this._transformers;
631
+ const timeout = this._timeout;
632
+
633
+ if (!plugin.schema['jupyter.lab.transform']) {
634
+ return plugin;
635
+ }
636
+
637
+ if (id in transformers) {
638
+ const transformed = transformers[id][phase].call(null, plugin);
639
+
640
+ if (transformed.id !== id) {
641
+ throw [
642
+ {
643
+ instancePath: '',
644
+ keyword: 'id',
645
+ message: 'Plugin transformations cannot change plugin IDs.',
646
+ schemaPath: ''
647
+ } as ISchemaValidator.IError
648
+ ];
649
+ }
650
+
651
+ return transformed;
652
+ }
653
+
654
+ // If the timeout has not been exceeded, stall and try again in 250ms.
655
+ if (elapsed < timeout) {
656
+ await new Promise<void>(resolve => {
657
+ setTimeout(() => {
658
+ resolve();
659
+ }, 250);
660
+ });
661
+ return this._transform(phase, plugin, started);
662
+ }
663
+
664
+ throw [
665
+ {
666
+ instancePath: '',
667
+ keyword: 'timeout',
668
+ message: `Transforming ${plugin.id} timed out.`,
669
+ schemaPath: ''
670
+ } as ISchemaValidator.IError
671
+ ];
672
+ }
673
+
674
+ /**
675
+ * Validate and preload a plugin, compose the `composite` data.
676
+ */
677
+ private async _validate(plugin: ISettingRegistry.IPlugin): Promise<void> {
678
+ // Validate the user data and create the composite data.
679
+ const errors = this.validator.validateData(plugin);
680
+
681
+ if (errors) {
682
+ throw errors;
683
+ }
684
+
685
+ // Apply a transformation if necessary and set the local copy.
686
+ this.plugins[plugin.id] = await this._transform('compose', plugin);
687
+ }
688
+
689
+ private _pluginChanged = new Signal<this, string>(this);
690
+ private _ready = Promise.resolve();
691
+ private _timeout: number;
692
+ private _transformers: {
693
+ [plugin: string]: {
694
+ [phase in ISettingRegistry.IPlugin.Phase]: ISettingRegistry.IPlugin.Transform;
695
+ };
696
+ } = Object.create(null);
697
+ }
698
+
699
+ /**
700
+ * Base settings specified by a JSON schema.
701
+ */
702
+ export class BaseSettings<
703
+ T extends ISettingRegistry.IProperty = ISettingRegistry.IProperty
704
+ > {
705
+ constructor(options: { schema: T }) {
706
+ this._schema = options.schema;
707
+ }
708
+
709
+ /**
710
+ * The plugin's schema.
711
+ */
712
+ get schema(): T {
713
+ return this._schema;
714
+ }
715
+
716
+ /**
717
+ * Checks if any fields are different from the default value.
718
+ */
719
+ isDefault(user: ReadonlyPartialJSONObject): boolean {
720
+ for (const key in this.schema.properties) {
721
+ const value = user[key];
722
+ const defaultValue = this.default(key);
723
+ if (
724
+ value === undefined ||
725
+ defaultValue === undefined ||
726
+ JSONExt.deepEqual(value, JSONExt.emptyObject) ||
727
+ JSONExt.deepEqual(value, JSONExt.emptyArray)
728
+ ) {
729
+ continue;
730
+ }
731
+ if (!JSONExt.deepEqual(value, defaultValue)) {
732
+ return false;
733
+ }
734
+ }
735
+ return true;
736
+ }
737
+
738
+ /**
739
+ * Calculate the default value of a setting by iterating through the schema.
740
+ *
741
+ * @param key - The name of the setting whose default value is calculated.
742
+ *
743
+ * @returns A calculated default JSON value for a specific setting.
744
+ */
745
+ default(key?: string): PartialJSONValue | undefined {
746
+ return Private.reifyDefault(this.schema, key);
747
+ }
748
+
749
+ private _schema: T;
750
+ }
751
+
752
+ /**
753
+ * A manager for a specific plugin's settings.
754
+ */
755
+ export class Settings
756
+ extends BaseSettings<ISettingRegistry.ISchema>
757
+ implements ISettingRegistry.ISettings
758
+ {
759
+ /**
760
+ * Instantiate a new plugin settings manager.
761
+ */
762
+ constructor(options: Settings.IOptions) {
763
+ super({ schema: options.plugin.schema });
764
+ this.id = options.plugin.id;
765
+ this.registry = options.registry;
766
+ this.registry.pluginChanged.connect(this._onPluginChanged, this);
767
+ }
768
+
769
+ /**
770
+ * The plugin name.
771
+ */
772
+ readonly id: string;
773
+
774
+ /**
775
+ * The setting registry instance used as a back-end for these settings.
776
+ */
777
+ readonly registry: ISettingRegistry;
778
+
779
+ /**
780
+ * A signal that emits when the plugin's settings have changed.
781
+ */
782
+ get changed(): ISignal<this, void> {
783
+ return this._changed;
784
+ }
785
+
786
+ /**
787
+ * The composite of user settings and extension defaults.
788
+ */
789
+ get composite(): ReadonlyPartialJSONObject {
790
+ return this.plugin.data.composite;
791
+ }
792
+
793
+ /**
794
+ * Test whether the plugin settings manager disposed.
795
+ */
796
+ get isDisposed(): boolean {
797
+ return this._isDisposed;
798
+ }
799
+
800
+ get plugin(): ISettingRegistry.IPlugin {
801
+ return this.registry.plugins[this.id]!;
802
+ }
803
+
804
+ /**
805
+ * The plugin settings raw text value.
806
+ */
807
+ get raw(): string {
808
+ return this.plugin.raw;
809
+ }
810
+
811
+ /**
812
+ * Whether the settings have been modified by the user or not.
813
+ */
814
+ get isModified(): boolean {
815
+ return !this.isDefault(this.user);
816
+ }
817
+
818
+ /**
819
+ * The user settings.
820
+ */
821
+ get user(): ReadonlyPartialJSONObject {
822
+ return this.plugin.data.user;
823
+ }
824
+
825
+ /**
826
+ * The published version of the NPM package containing these settings.
827
+ */
828
+ get version(): string {
829
+ return this.plugin.version;
830
+ }
831
+
832
+ /**
833
+ * Return the defaults in a commented JSON format.
834
+ */
835
+ annotatedDefaults(): string {
836
+ return Private.annotatedDefaults(this.schema, this.id);
837
+ }
838
+
839
+ /**
840
+ * Dispose of the plugin settings resources.
841
+ */
842
+ dispose(): void {
843
+ if (this._isDisposed) {
844
+ return;
845
+ }
846
+
847
+ this._isDisposed = true;
848
+ Signal.clearData(this);
849
+ }
850
+
851
+ /**
852
+ * Get an individual setting.
853
+ *
854
+ * @param key - The name of the setting being retrieved.
855
+ *
856
+ * @returns The setting value.
857
+ *
858
+ * #### Notes
859
+ * This method returns synchronously because it uses a cached copy of the
860
+ * plugin settings that is synchronized with the registry.
861
+ */
862
+ get(key: string): {
863
+ composite: ReadonlyPartialJSONValue | undefined;
864
+ user: ReadonlyPartialJSONValue | undefined;
865
+ } {
866
+ const { composite, user } = this;
867
+
868
+ return {
869
+ composite:
870
+ composite[key] !== undefined ? copy(composite[key]!) : undefined,
871
+ user: user[key] !== undefined ? copy(user[key]!) : undefined
872
+ };
873
+ }
874
+
875
+ /**
876
+ * Remove a single setting.
877
+ *
878
+ * @param key - The name of the setting being removed.
879
+ *
880
+ * @returns A promise that resolves when the setting is removed.
881
+ *
882
+ * #### Notes
883
+ * This function is asynchronous because it writes to the setting registry.
884
+ */
885
+ remove(key: string): Promise<void> {
886
+ return this.registry.remove(this.plugin.id, key);
887
+ }
888
+
889
+ /**
890
+ * Save all of the plugin's user settings at once.
891
+ */
892
+ save(raw: string): Promise<void> {
893
+ return this.registry.upload(this.plugin.id, raw);
894
+ }
895
+
896
+ /**
897
+ * Set a single setting.
898
+ *
899
+ * @param key - The name of the setting being set.
900
+ *
901
+ * @param value - The value of the setting.
902
+ *
903
+ * @returns A promise that resolves when the setting has been saved.
904
+ *
905
+ * #### Notes
906
+ * This function is asynchronous because it writes to the setting registry.
907
+ */
908
+ set(key: string, value: JSONValue): Promise<void> {
909
+ return this.registry.set(this.plugin.id, key, value);
910
+ }
911
+
912
+ /**
913
+ * Validates raw settings with comments.
914
+ *
915
+ * @param raw - The JSON with comments string being validated.
916
+ *
917
+ * @returns A list of errors or `null` if valid.
918
+ */
919
+ validate(raw: string): ISchemaValidator.IError[] | null {
920
+ const data = { composite: {}, user: {} };
921
+ const { id, schema } = this.plugin;
922
+ const validator = this.registry.validator;
923
+ const version = this.version;
924
+
925
+ return validator.validateData({ data, id, raw, schema, version }, false);
926
+ }
927
+
928
+ /**
929
+ * Handle plugin changes in the setting registry.
930
+ */
931
+ private _onPluginChanged(sender: any, plugin: string): void {
932
+ if (plugin === this.plugin.id) {
933
+ this._changed.emit(undefined);
934
+ }
935
+ }
936
+
937
+ private _changed = new Signal<this, void>(this);
938
+ private _isDisposed = false;
939
+ }
940
+
941
+ /**
942
+ * A namespace for `SettingRegistry` statics.
943
+ */
944
+ export namespace SettingRegistry {
945
+ /**
946
+ * The instantiation options for a setting registry
947
+ */
948
+ export interface IOptions {
949
+ /**
950
+ * The data connector used by the setting registry.
951
+ */
952
+ connector: IDataConnector<ISettingRegistry.IPlugin, string>;
953
+
954
+ /**
955
+ * Preloaded plugin data to populate the setting registry.
956
+ */
957
+ plugins?: ISettingRegistry.IPlugin[];
958
+
959
+ /**
960
+ * The number of milliseconds before a `load()` call to the registry waits
961
+ * before timing out if it requires a transformation that has not been
962
+ * registered.
963
+ *
964
+ * #### Notes
965
+ * The default value is 7000.
966
+ */
967
+ timeout?: number;
968
+
969
+ /**
970
+ * The validator used to enforce the settings JSON schema.
971
+ */
972
+ validator?: ISchemaValidator;
973
+ }
974
+
975
+ /**
976
+ * Reconcile the menus.
977
+ *
978
+ * @param reference The reference list of menus.
979
+ * @param addition The list of menus to add.
980
+ * @param warn Warn if the command items are duplicated within the same menu.
981
+ * @returns The reconciled list of menus.
982
+ */
983
+ export function reconcileMenus(
984
+ reference: ISettingRegistry.IMenu[] | null,
985
+ addition: ISettingRegistry.IMenu[] | null,
986
+ warn: boolean = false,
987
+ addNewItems: boolean = true
988
+ ): ISettingRegistry.IMenu[] {
989
+ if (!reference) {
990
+ return addition && addNewItems ? JSONExt.deepCopy(addition) : [];
991
+ }
992
+ if (!addition) {
993
+ return JSONExt.deepCopy(reference);
994
+ }
995
+
996
+ const merged = JSONExt.deepCopy(reference);
997
+
998
+ addition.forEach(menu => {
999
+ const refIndex = merged.findIndex(ref => ref.id === menu.id);
1000
+ if (refIndex >= 0) {
1001
+ merged[refIndex] = {
1002
+ ...merged[refIndex],
1003
+ ...menu,
1004
+ items: reconcileItems(
1005
+ merged[refIndex].items,
1006
+ menu.items,
1007
+ warn,
1008
+ addNewItems
1009
+ )
1010
+ };
1011
+ } else {
1012
+ if (addNewItems) {
1013
+ merged.push(menu);
1014
+ }
1015
+ }
1016
+ });
1017
+
1018
+ return merged;
1019
+ }
1020
+
1021
+ /**
1022
+ * Merge two set of menu items.
1023
+ *
1024
+ * @param reference Reference set of menu items
1025
+ * @param addition New items to add
1026
+ * @param warn Whether to warn if item is duplicated; default to false
1027
+ * @returns The merged set of items
1028
+ */
1029
+ export function reconcileItems<T extends ISettingRegistry.IMenuItem>(
1030
+ reference?: T[],
1031
+ addition?: T[],
1032
+ warn: boolean = false,
1033
+ addNewItems: boolean = true
1034
+ ): T[] | undefined {
1035
+ if (!reference) {
1036
+ return addition ? JSONExt.deepCopy(addition) : undefined;
1037
+ }
1038
+ if (!addition) {
1039
+ return JSONExt.deepCopy(reference);
1040
+ }
1041
+
1042
+ const items = JSONExt.deepCopy(reference);
1043
+
1044
+ // Merge array element depending on the type
1045
+ addition.forEach(item => {
1046
+ switch (item.type ?? 'command') {
1047
+ case 'separator':
1048
+ if (addNewItems) {
1049
+ items.push({ ...item });
1050
+ }
1051
+ break;
1052
+ case 'submenu':
1053
+ if (item.submenu) {
1054
+ const refIndex = items.findIndex(
1055
+ ref =>
1056
+ ref.type === 'submenu' && ref.submenu?.id === item.submenu?.id
1057
+ );
1058
+ if (refIndex < 0) {
1059
+ if (addNewItems) {
1060
+ items.push(JSONExt.deepCopy(item));
1061
+ }
1062
+ } else {
1063
+ items[refIndex] = {
1064
+ ...items[refIndex],
1065
+ ...item,
1066
+ submenu: reconcileMenus(
1067
+ items[refIndex].submenu
1068
+ ? [items[refIndex].submenu as any]
1069
+ : null,
1070
+ [item.submenu],
1071
+ warn,
1072
+ addNewItems
1073
+ )[0]
1074
+ };
1075
+ }
1076
+ }
1077
+ break;
1078
+ case 'command':
1079
+ if (item.command) {
1080
+ const refIndex = items.findIndex(
1081
+ ref =>
1082
+ ref.command === item.command &&
1083
+ ref.selector === item.selector &&
1084
+ JSONExt.deepEqual(ref.args ?? {}, item.args ?? {})
1085
+ );
1086
+ if (refIndex < 0) {
1087
+ if (addNewItems) {
1088
+ items.push({ ...item });
1089
+ }
1090
+ } else {
1091
+ if (warn) {
1092
+ console.warn(
1093
+ `Menu entry for command '${item.command}' is duplicated.`
1094
+ );
1095
+ }
1096
+ items[refIndex] = { ...items[refIndex], ...item };
1097
+ }
1098
+ }
1099
+ }
1100
+ });
1101
+
1102
+ return items;
1103
+ }
1104
+
1105
+ /**
1106
+ * Remove disabled entries from menu items
1107
+ *
1108
+ * @param items Menu items
1109
+ * @returns Filtered menu items
1110
+ */
1111
+ export function filterDisabledItems<T extends ISettingRegistry.IMenuItem>(
1112
+ items: T[]
1113
+ ): T[] {
1114
+ return items.reduce<T[]>((final, value) => {
1115
+ const copy = { ...value };
1116
+ if (!copy.disabled) {
1117
+ if (copy.type === 'submenu') {
1118
+ const { submenu } = copy;
1119
+ if (submenu && !submenu.disabled) {
1120
+ copy.submenu = {
1121
+ ...submenu,
1122
+ items: filterDisabledItems(submenu.items ?? [])
1123
+ };
1124
+ }
1125
+ }
1126
+ final.push(copy);
1127
+ }
1128
+
1129
+ return final;
1130
+ }, []);
1131
+ }
1132
+
1133
+ /**
1134
+ * Reconcile default and user shortcuts and return the composite list.
1135
+ *
1136
+ * @param defaults - The list of default shortcuts.
1137
+ *
1138
+ * @param user - The list of user shortcut overrides and additions.
1139
+ *
1140
+ * @returns A loadable list of shortcuts (omitting disabled and overridden).
1141
+ */
1142
+ export function reconcileShortcuts(
1143
+ defaults: ISettingRegistry.IShortcut[],
1144
+ user: ISettingRegistry.IShortcut[]
1145
+ ): ISettingRegistry.IShortcut[] {
1146
+ const memo: {
1147
+ [keys: string]: {
1148
+ [selector: string]: boolean; // If `true`, should warn if a default shortcut conflicts.
1149
+ };
1150
+ } = {};
1151
+
1152
+ // If a user shortcut collides with another user shortcut warn and filter.
1153
+ user = user.filter(shortcut => {
1154
+ const keys =
1155
+ CommandRegistry.normalizeKeys(shortcut).join(RECORD_SEPARATOR);
1156
+ if (!keys) {
1157
+ console.warn(
1158
+ 'Skipping this shortcut because there are no actionable keys on this platform',
1159
+ shortcut
1160
+ );
1161
+ return false;
1162
+ }
1163
+ if (!(keys in memo)) {
1164
+ memo[keys] = {};
1165
+ }
1166
+
1167
+ const { selector } = shortcut;
1168
+ if (!(selector in memo[keys])) {
1169
+ memo[keys][selector] = false; // Do not warn if a default shortcut conflicts.
1170
+ return true;
1171
+ }
1172
+
1173
+ console.warn(
1174
+ 'Skipping this shortcut because it collides with another shortcut.',
1175
+ shortcut
1176
+ );
1177
+ return false;
1178
+ });
1179
+
1180
+ // If a default shortcut collides with another default, warn and filter,
1181
+ // unless one of the shortcuts is a disabling shortcut (so look through
1182
+ // disabled shortcuts first). If a shortcut has already been added by the
1183
+ // user preferences, filter it out too (this includes shortcuts that are
1184
+ // disabled by user preferences).
1185
+ defaults = [
1186
+ ...defaults.filter(s => !!s.disabled),
1187
+ ...defaults.filter(s => !s.disabled)
1188
+ ].filter(shortcut => {
1189
+ const keys =
1190
+ CommandRegistry.normalizeKeys(shortcut).join(RECORD_SEPARATOR);
1191
+
1192
+ if (!keys) {
1193
+ return false;
1194
+ }
1195
+ if (!(keys in memo)) {
1196
+ memo[keys] = {};
1197
+ }
1198
+
1199
+ const { disabled, selector } = shortcut;
1200
+ if (!(selector in memo[keys])) {
1201
+ // Warn of future conflicts if the default shortcut is not disabled.
1202
+ memo[keys][selector] = !disabled;
1203
+ return true;
1204
+ }
1205
+
1206
+ // We have a conflict now. Warn the user if we need to do so.
1207
+ if (memo[keys][selector]) {
1208
+ console.warn(
1209
+ 'Skipping this default shortcut because it collides with another default shortcut.',
1210
+ shortcut
1211
+ );
1212
+ }
1213
+
1214
+ return false;
1215
+ });
1216
+
1217
+ // Return all the shortcuts that should be registered
1218
+ return (
1219
+ user
1220
+ .concat(defaults)
1221
+ .filter(shortcut => !shortcut.disabled)
1222
+ // Fix shortcuts comparison in rjsf Form to avoid polluting the user settings
1223
+ .map(shortcut => {
1224
+ return { args: {}, ...shortcut };
1225
+ })
1226
+ );
1227
+ }
1228
+
1229
+ /**
1230
+ * Merge two set of toolbar items.
1231
+ *
1232
+ * @param reference Reference set of toolbar items
1233
+ * @param addition New items to add
1234
+ * @param warn Whether to warn if item is duplicated; default to false
1235
+ * @returns The merged set of items
1236
+ */
1237
+ export function reconcileToolbarItems(
1238
+ reference?: ISettingRegistry.IToolbarItem[],
1239
+ addition?: ISettingRegistry.IToolbarItem[],
1240
+ warn: boolean = false
1241
+ ): ISettingRegistry.IToolbarItem[] | undefined {
1242
+ if (!reference) {
1243
+ return addition ? JSONExt.deepCopy(addition) : undefined;
1244
+ }
1245
+ if (!addition) {
1246
+ return JSONExt.deepCopy(reference);
1247
+ }
1248
+
1249
+ const items = JSONExt.deepCopy(reference);
1250
+
1251
+ // Merge array element depending on the type
1252
+ addition.forEach(item => {
1253
+ // Name must be unique so it's sufficient to only compare it
1254
+ const refIndex = items.findIndex(ref => ref.name === item.name);
1255
+ if (refIndex < 0) {
1256
+ items.push({ ...item });
1257
+ } else {
1258
+ if (
1259
+ warn &&
1260
+ JSONExt.deepEqual(Object.keys(item), Object.keys(items[refIndex]))
1261
+ ) {
1262
+ console.warn(`Toolbar item '${item.name}' is duplicated.`);
1263
+ }
1264
+ items[refIndex] = { ...items[refIndex], ...item };
1265
+ }
1266
+ });
1267
+
1268
+ return items;
1269
+ }
1270
+ }
1271
+
1272
+ /**
1273
+ * A namespace for `Settings` statics.
1274
+ */
1275
+ export namespace Settings {
1276
+ /**
1277
+ * The instantiation options for a `Settings` object.
1278
+ */
1279
+ export interface IOptions {
1280
+ /**
1281
+ * The setting values for a plugin.
1282
+ */
1283
+ plugin: ISettingRegistry.IPlugin;
1284
+
1285
+ /**
1286
+ * The system registry instance used by the settings manager.
1287
+ */
1288
+ registry: ISettingRegistry;
1289
+ }
1290
+ }
1291
+
1292
+ /**
1293
+ * A namespace for private module data.
1294
+ */
1295
+ namespace Private {
1296
+ /**
1297
+ * The default indentation level, uses spaces instead of tabs.
1298
+ */
1299
+ const indent = ' ';
1300
+
1301
+ /**
1302
+ * Replacement text for schema properties missing a `description` field.
1303
+ */
1304
+ const nondescript = '[missing schema description]';
1305
+
1306
+ /**
1307
+ * Replacement text for schema properties missing a `title` field.
1308
+ */
1309
+ const untitled = '[missing schema title]';
1310
+
1311
+ /**
1312
+ * Returns an annotated (JSON with comments) version of a schema's defaults.
1313
+ */
1314
+ export function annotatedDefaults(
1315
+ schema: ISettingRegistry.ISchema,
1316
+ plugin: string
1317
+ ): string {
1318
+ const { description, properties, title } = schema;
1319
+ const keys = properties
1320
+ ? Object.keys(properties).sort((a, b) => a.localeCompare(b))
1321
+ : [];
1322
+ const length = Math.max((description || nondescript).length, plugin.length);
1323
+
1324
+ return [
1325
+ '{',
1326
+ prefix(`${title || untitled}`),
1327
+ prefix(plugin),
1328
+ prefix(description || nondescript),
1329
+ prefix('*'.repeat(length)),
1330
+ '',
1331
+ join(keys.map(key => defaultDocumentedValue(schema, key))),
1332
+ '}'
1333
+ ].join('\n');
1334
+ }
1335
+
1336
+ /**
1337
+ * Returns an annotated (JSON with comments) version of a plugin's
1338
+ * setting data.
1339
+ */
1340
+ export function annotatedPlugin(
1341
+ plugin: ISettingRegistry.IPlugin,
1342
+ data: JSONObject
1343
+ ): string {
1344
+ const { description, title } = plugin.schema;
1345
+ const keys = Object.keys(data).sort((a, b) => a.localeCompare(b));
1346
+ const length = Math.max(
1347
+ (description || nondescript).length,
1348
+ plugin.id.length
1349
+ );
1350
+
1351
+ return [
1352
+ '{',
1353
+ prefix(`${title || untitled}`),
1354
+ prefix(plugin.id),
1355
+ prefix(description || nondescript),
1356
+ prefix('*'.repeat(length)),
1357
+ '',
1358
+ join(keys.map(key => documentedValue(plugin.schema, key, data[key]))),
1359
+ '}'
1360
+ ].join('\n');
1361
+ }
1362
+
1363
+ /**
1364
+ * Returns the default value-with-documentation-string for a
1365
+ * specific schema property.
1366
+ */
1367
+ function defaultDocumentedValue(
1368
+ schema: ISettingRegistry.ISchema,
1369
+ key: string
1370
+ ): string {
1371
+ const props = (schema.properties && schema.properties[key]) || {};
1372
+ const type = props['type'];
1373
+ const description = props['description'] || nondescript;
1374
+ const title = props['title'] || '';
1375
+ const reified = reifyDefault(schema, key);
1376
+ const spaces = indent.length;
1377
+ const defaults =
1378
+ reified !== undefined
1379
+ ? prefix(`"${key}": ${JSON.stringify(reified, null, spaces)}`, indent)
1380
+ : prefix(`"${key}": ${type}`);
1381
+
1382
+ return [prefix(title), prefix(description), defaults]
1383
+ .filter(str => str.length)
1384
+ .join('\n');
1385
+ }
1386
+
1387
+ /**
1388
+ * Returns a value-with-documentation-string for a specific schema property.
1389
+ */
1390
+ function documentedValue(
1391
+ schema: ISettingRegistry.ISchema,
1392
+ key: string,
1393
+ value: JSONValue
1394
+ ): string {
1395
+ const props = schema.properties && schema.properties[key];
1396
+ const description = (props && props['description']) || nondescript;
1397
+ const title = (props && props['title']) || untitled;
1398
+ const spaces = indent.length;
1399
+ const attribute = prefix(
1400
+ `"${key}": ${JSON.stringify(value, null, spaces)}`,
1401
+ indent
1402
+ );
1403
+
1404
+ return [prefix(title), prefix(description), attribute].join('\n');
1405
+ }
1406
+
1407
+ /**
1408
+ * Returns a joined string with line breaks and commas where appropriate.
1409
+ */
1410
+ function join(body: string[]): string {
1411
+ return body.reduce((acc, val, idx) => {
1412
+ const rows = val.split('\n');
1413
+ const last = rows[rows.length - 1];
1414
+ const comment = last.trim().indexOf('//') === 0;
1415
+ const comma = comment || idx === body.length - 1 ? '' : ',';
1416
+ const separator = idx === body.length - 1 ? '' : '\n\n';
1417
+
1418
+ return acc + val + comma + separator;
1419
+ }, '');
1420
+ }
1421
+
1422
+ /**
1423
+ * Returns a documentation string with a comment prefix added on every line.
1424
+ */
1425
+ function prefix(source: string, pre = `${indent}// `): string {
1426
+ return pre + source.split('\n').join(`\n${pre}`);
1427
+ }
1428
+
1429
+ /**
1430
+ * Create a fully extrapolated default value for a root key in a schema.
1431
+ */
1432
+ export function reifyDefault(
1433
+ schema: ISettingRegistry.IProperty,
1434
+ root?: string,
1435
+ definitions?: PartialJSONObject
1436
+ ): PartialJSONValue | undefined {
1437
+ definitions = definitions ?? (schema.definitions as PartialJSONObject);
1438
+ // If the property is at the root level, traverse its schema.
1439
+ schema = (root ? schema.properties?.[root] : schema) || {};
1440
+
1441
+ if (schema.type === 'object') {
1442
+ // Make a copy of the default value to populate.
1443
+ const result = JSONExt.deepCopy(schema.default as PartialJSONObject);
1444
+
1445
+ // Iterate through and populate each child property.
1446
+ const props = schema.properties || {};
1447
+ for (const property in props) {
1448
+ result[property] = reifyDefault(
1449
+ props[property],
1450
+ undefined,
1451
+ definitions
1452
+ );
1453
+ }
1454
+
1455
+ return result;
1456
+ } else if (schema.type === 'array') {
1457
+ // Make a copy of the default value to populate.
1458
+ const result = JSONExt.deepCopy(schema.default as PartialJSONArray);
1459
+
1460
+ // Items defines the properties of each item in the array
1461
+ let props = (schema.items as PartialJSONObject) || {};
1462
+ // Use referenced definition if one exists
1463
+ if (props['$ref'] && definitions) {
1464
+ const ref: string = (props['$ref'] as string).replace(
1465
+ '#/definitions/',
1466
+ ''
1467
+ );
1468
+ props = (definitions[ref] as PartialJSONObject) ?? {};
1469
+ }
1470
+ // Iterate through the items in the array and fill in defaults
1471
+ for (const item in result) {
1472
+ // Use the values that are hard-coded in the default array over the defaults for each field.
1473
+ const reified =
1474
+ (reifyDefault(props, undefined, definitions) as PartialJSONObject) ??
1475
+ {};
1476
+ for (const prop in reified) {
1477
+ if ((result[item] as PartialJSONObject)?.[prop]) {
1478
+ reified[prop] = (result[item] as PartialJSONObject)[prop];
1479
+ }
1480
+ }
1481
+ result[item] = reified;
1482
+ }
1483
+
1484
+ return result;
1485
+ } else {
1486
+ return schema.default;
1487
+ }
1488
+ }
1489
+ }