@jupyterlab/settingregistry 4.0.0-alpha.8 → 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.
- package/lib/plugin-schema.json +88 -0
- package/lib/settingregistry.d.ts +49 -21
- package/lib/settingregistry.js +115 -95
- package/lib/settingregistry.js.map +1 -1
- package/lib/tokens.d.ts +94 -0
- package/lib/tokens.js.map +1 -1
- package/package.json +17 -15
- package/src/index.ts +11 -0
- package/src/plugin-schema.json +388 -0
- package/src/settingregistry.ts +1489 -0
- package/src/tokens.ts +771 -0
|
@@ -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
|
+
}
|