settingregistry.ts 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173
  1. // Copyright (c) Jupyter Development Team.
  2. // Distributed under the terms of the Modified BSD License.
  3. import Ajv from 'ajv';
  4. import * as json5 from 'json5';
  5. import { CommandRegistry } from '@lumino/commands';
  6. import {
  7. JSONExt,
  8. JSONObject,
  9. JSONValue,
  10. ReadonlyJSONObject,
  11. PartialJSONValue,
  12. ReadonlyPartialJSONObject,
  13. ReadonlyPartialJSONValue,
  14. PartialJSONObject
  15. } from '@lumino/coreutils';
  16. import { DisposableDelegate, IDisposable } from '@lumino/disposable';
  17. import { ISignal, Signal } from '@lumino/signaling';
  18. import { IDataConnector } from '@jupyterlab/statedb';
  19. import { ISettingRegistry } from './tokens';
  20. import SCHEMA from './plugin-schema.json';
  21. /**
  22. * An alias for the JSON deep copy function.
  23. */
  24. const copy = JSONExt.deepCopy;
  25. /**
  26. * The default number of milliseconds before a `load()` call to the registry
  27. * will wait before timing out if it requires a transformation that has not been
  28. * registered.
  29. */
  30. const DEFAULT_TRANSFORM_TIMEOUT = 1000;
  31. /**
  32. * The ASCII record separator character.
  33. */
  34. const RECORD_SEPARATOR = String.fromCharCode(30);
  35. /**
  36. * An implementation of a schema validator.
  37. */
  38. export interface ISchemaValidator {
  39. /**
  40. * Validate a plugin's schema and user data; populate the `composite` data.
  41. *
  42. * @param plugin - The plugin being validated. Its `composite` data will be
  43. * populated by reference.
  44. *
  45. * @param populate - Whether plugin data should be populated, defaults to
  46. * `true`.
  47. *
  48. * @return A list of errors if either the schema or data fail to validate or
  49. * `null` if there are no errors.
  50. */
  51. validateData(
  52. plugin: ISettingRegistry.IPlugin,
  53. populate?: boolean
  54. ): ISchemaValidator.IError[] | null;
  55. }
  56. /**
  57. * A namespace for schema validator interfaces.
  58. */
  59. export namespace ISchemaValidator {
  60. /**
  61. * A schema validation error definition.
  62. */
  63. export interface IError {
  64. /**
  65. * The path in the data where the error occurred.
  66. */
  67. dataPath: string;
  68. /**
  69. * The keyword whose validation failed.
  70. */
  71. keyword: string;
  72. /**
  73. * The error message.
  74. */
  75. message: string;
  76. /**
  77. * Optional parameter metadata that might be included in an error.
  78. */
  79. params?: ReadonlyJSONObject;
  80. /**
  81. * The path in the schema where the error occurred.
  82. */
  83. schemaPath: string;
  84. }
  85. }
  86. /**
  87. * The default implementation of a schema validator.
  88. */
  89. export class DefaultSchemaValidator implements ISchemaValidator {
  90. /**
  91. * Instantiate a schema validator.
  92. */
  93. constructor() {
  94. this._composer.addSchema(SCHEMA, 'jupyterlab-plugin-schema');
  95. this._validator.addSchema(SCHEMA, 'jupyterlab-plugin-schema');
  96. }
  97. /**
  98. * Validate a plugin's schema and user data; populate the `composite` data.
  99. *
  100. * @param plugin - The plugin being validated. Its `composite` data will be
  101. * populated by reference.
  102. *
  103. * @param populate - Whether plugin data should be populated, defaults to
  104. * `true`.
  105. *
  106. * @return A list of errors if either the schema or data fail to validate or
  107. * `null` if there are no errors.
  108. */
  109. validateData(
  110. plugin: ISettingRegistry.IPlugin,
  111. populate = true
  112. ): ISchemaValidator.IError[] | null {
  113. const validate = this._validator.getSchema(plugin.id);
  114. const compose = this._composer.getSchema(plugin.id);
  115. // If the schemas do not exist, add them to the validator and continue.
  116. if (!validate || !compose) {
  117. if (plugin.schema.type !== 'object') {
  118. const keyword = 'schema';
  119. const message =
  120. `Setting registry schemas' root-level type must be ` +
  121. `'object', rejecting type: ${plugin.schema.type}`;
  122. return [{ dataPath: 'type', keyword, schemaPath: '', message }];
  123. }
  124. const errors = this._addSchema(plugin.id, plugin.schema);
  125. return errors || this.validateData(plugin);
  126. }
  127. // Parse the raw commented JSON into a user map.
  128. let user: JSONObject;
  129. try {
  130. user = json5.parse(plugin.raw) as JSONObject;
  131. } catch (error) {
  132. if (error instanceof SyntaxError) {
  133. return [
  134. {
  135. dataPath: '',
  136. keyword: 'syntax',
  137. schemaPath: '',
  138. message: error.message
  139. }
  140. ];
  141. }
  142. const { column, description } = error;
  143. const line = error.lineNumber;
  144. return [
  145. {
  146. dataPath: '',
  147. keyword: 'parse',
  148. schemaPath: '',
  149. message: `${description} (line ${line} column ${column})`
  150. }
  151. ];
  152. }
  153. if (!validate(user)) {
  154. return validate.errors as ISchemaValidator.IError[];
  155. }
  156. // Copy the user data before merging defaults into composite map.
  157. const composite = copy(user);
  158. if (!compose(composite)) {
  159. return compose.errors as ISchemaValidator.IError[];
  160. }
  161. if (populate) {
  162. plugin.data = { composite, user };
  163. }
  164. return null;
  165. }
  166. /**
  167. * Add a schema to the validator.
  168. *
  169. * @param plugin - The plugin ID.
  170. *
  171. * @param schema - The schema being added.
  172. *
  173. * @return A list of errors if the schema fails to validate or `null` if there
  174. * are no errors.
  175. *
  176. * #### Notes
  177. * It is safe to call this function multiple times with the same plugin name.
  178. */
  179. private _addSchema(
  180. plugin: string,
  181. schema: ISettingRegistry.ISchema
  182. ): ISchemaValidator.IError[] | null {
  183. const composer = this._composer;
  184. const validator = this._validator;
  185. const validate = validator.getSchema('jupyterlab-plugin-schema')!;
  186. // Validate against the main schema.
  187. if (!(validate!(schema) as boolean)) {
  188. return validate!.errors as ISchemaValidator.IError[];
  189. }
  190. // Validate against the JSON schema meta-schema.
  191. if (!(validator.validateSchema(schema) as boolean)) {
  192. return validator.errors as ISchemaValidator.IError[];
  193. }
  194. // Remove if schema already exists.
  195. composer.removeSchema(plugin);
  196. validator.removeSchema(plugin);
  197. // Add schema to the validator and composer.
  198. composer.addSchema(schema, plugin);
  199. validator.addSchema(schema, plugin);
  200. return null;
  201. }
  202. private _composer = new Ajv({ useDefaults: true });
  203. private _validator = new Ajv();
  204. }
  205. /**
  206. * The default concrete implementation of a setting registry.
  207. */
  208. export class SettingRegistry implements ISettingRegistry {
  209. /**
  210. * Create a new setting registry.
  211. */
  212. constructor(options: SettingRegistry.IOptions) {
  213. this.connector = options.connector;
  214. this.validator = options.validator || new DefaultSchemaValidator();
  215. this._timeout = options.timeout || DEFAULT_TRANSFORM_TIMEOUT;
  216. // Preload with any available data at instantiation-time.
  217. if (options.plugins) {
  218. this._ready = this._preload(options.plugins);
  219. }
  220. }
  221. /**
  222. * The data connector used by the setting registry.
  223. */
  224. readonly connector: IDataConnector<ISettingRegistry.IPlugin, string, string>;
  225. /**
  226. * The schema of the setting registry.
  227. */
  228. readonly schema = SCHEMA as ISettingRegistry.ISchema;
  229. /**
  230. * The schema validator used by the setting registry.
  231. */
  232. readonly validator: ISchemaValidator;
  233. /**
  234. * A signal that emits the name of a plugin when its settings change.
  235. */
  236. get pluginChanged(): ISignal<this, string> {
  237. return this._pluginChanged;
  238. }
  239. /**
  240. * The collection of setting registry plugins.
  241. */
  242. readonly plugins: {
  243. [name: string]: ISettingRegistry.IPlugin;
  244. } = Object.create(null);
  245. /**
  246. * Get an individual setting.
  247. *
  248. * @param plugin - The name of the plugin whose settings are being retrieved.
  249. *
  250. * @param key - The name of the setting being retrieved.
  251. *
  252. * @returns A promise that resolves when the setting is retrieved.
  253. */
  254. async get(
  255. plugin: string,
  256. key: string
  257. ): Promise<{
  258. composite: PartialJSONValue | undefined;
  259. user: PartialJSONValue | undefined;
  260. }> {
  261. // Wait for data preload before allowing normal operation.
  262. await this._ready;
  263. const plugins = this.plugins;
  264. if (plugin in plugins) {
  265. const { composite, user } = plugins[plugin].data;
  266. return {
  267. composite:
  268. composite[key] !== undefined ? copy(composite[key]!) : undefined,
  269. user: user[key] !== undefined ? copy(user[key]!) : undefined
  270. };
  271. }
  272. return this.load(plugin).then(() => this.get(plugin, key));
  273. }
  274. /**
  275. * Load a plugin's settings into the setting registry.
  276. *
  277. * @param plugin - The name of the plugin whose settings are being loaded.
  278. *
  279. * @returns A promise that resolves with a plugin settings object or rejects
  280. * if the plugin is not found.
  281. */
  282. async load(plugin: string): Promise<ISettingRegistry.ISettings> {
  283. // Wait for data preload before allowing normal operation.
  284. await this._ready;
  285. const plugins = this.plugins;
  286. const registry = this; // eslint-disable-line
  287. // If the plugin exists, resolve.
  288. if (plugin in plugins) {
  289. return new Settings({ plugin: plugins[plugin], registry });
  290. }
  291. // If the plugin needs to be loaded from the data connector, fetch.
  292. return this.reload(plugin);
  293. }
  294. /**
  295. * Reload a plugin's settings into the registry even if they already exist.
  296. *
  297. * @param plugin - The name of the plugin whose settings are being reloaded.
  298. *
  299. * @returns A promise that resolves with a plugin settings object or rejects
  300. * with a list of `ISchemaValidator.IError` objects if it fails.
  301. */
  302. async reload(plugin: string): Promise<ISettingRegistry.ISettings> {
  303. // Wait for data preload before allowing normal operation.
  304. await this._ready;
  305. const fetched = await this.connector.fetch(plugin);
  306. const plugins = this.plugins; // eslint-disable-line
  307. const registry = this; // eslint-disable-line
  308. if (fetched === undefined) {
  309. throw [
  310. {
  311. dataPath: '',
  312. keyword: 'id',
  313. message: `Could not fetch settings for ${plugin}.`,
  314. schemaPath: ''
  315. } as ISchemaValidator.IError
  316. ];
  317. }
  318. await this._load(await this._transform('fetch', fetched));
  319. this._pluginChanged.emit(plugin);
  320. return new Settings({ plugin: plugins[plugin], registry });
  321. }
  322. /**
  323. * Remove a single setting in the registry.
  324. *
  325. * @param plugin - The name of the plugin whose setting is being removed.
  326. *
  327. * @param key - The name of the setting being removed.
  328. *
  329. * @returns A promise that resolves when the setting is removed.
  330. */
  331. async remove(plugin: string, key: string): Promise<void> {
  332. // Wait for data preload before allowing normal operation.
  333. await this._ready;
  334. const plugins = this.plugins;
  335. if (!(plugin in plugins)) {
  336. return;
  337. }
  338. const raw = json5.parse(plugins[plugin].raw);
  339. // Delete both the value and any associated comment.
  340. delete raw[key];
  341. delete raw[`// ${key}`];
  342. plugins[plugin].raw = Private.annotatedPlugin(plugins[plugin], raw);
  343. return this._save(plugin);
  344. }
  345. /**
  346. * Set a single setting in the registry.
  347. *
  348. * @param plugin - The name of the plugin whose setting is being set.
  349. *
  350. * @param key - The name of the setting being set.
  351. *
  352. * @param value - The value of the setting being set.
  353. *
  354. * @returns A promise that resolves when the setting has been saved.
  355. *
  356. */
  357. async set(plugin: string, key: string, value: JSONValue): Promise<void> {
  358. // Wait for data preload before allowing normal operation.
  359. await this._ready;
  360. const plugins = this.plugins;
  361. if (!(plugin in plugins)) {
  362. return this.load(plugin).then(() => this.set(plugin, key, value));
  363. }
  364. // Parse the raw JSON string removing all comments and return an object.
  365. const raw = json5.parse(plugins[plugin].raw);
  366. plugins[plugin].raw = Private.annotatedPlugin(plugins[plugin], {
  367. ...raw,
  368. [key]: value
  369. });
  370. return this._save(plugin);
  371. }
  372. /**
  373. * Register a plugin transform function to act on a specific plugin.
  374. *
  375. * @param plugin - The name of the plugin whose settings are transformed.
  376. *
  377. * @param transforms - The transform functions applied to the plugin.
  378. *
  379. * @returns A disposable that removes the transforms from the registry.
  380. *
  381. * #### Notes
  382. * - `compose` transformations: The registry automatically overwrites a
  383. * plugin's default values with user overrides, but a plugin may instead wish
  384. * to merge values. This behavior can be accomplished in a `compose`
  385. * transformation.
  386. * - `fetch` transformations: The registry uses the plugin data that is
  387. * fetched from its connector. If a plugin wants to override, e.g. to update
  388. * its schema with dynamic defaults, a `fetch` transformation can be applied.
  389. */
  390. transform(
  391. plugin: string,
  392. transforms: {
  393. [phase in ISettingRegistry.IPlugin.Phase]?: ISettingRegistry.IPlugin.Transform;
  394. }
  395. ): IDisposable {
  396. const transformers = this._transformers;
  397. if (plugin in transformers) {
  398. throw new Error(`${plugin} already has a transformer.`);
  399. }
  400. transformers[plugin] = {
  401. fetch: transforms.fetch || (plugin => plugin),
  402. compose: transforms.compose || (plugin => plugin)
  403. };
  404. return new DisposableDelegate(() => {
  405. delete transformers[plugin];
  406. });
  407. }
  408. /**
  409. * Upload a plugin's settings.
  410. *
  411. * @param plugin - The name of the plugin whose settings are being set.
  412. *
  413. * @param raw - The raw plugin settings being uploaded.
  414. *
  415. * @returns A promise that resolves when the settings have been saved.
  416. */
  417. async upload(plugin: string, raw: string): Promise<void> {
  418. // Wait for data preload before allowing normal operation.
  419. await this._ready;
  420. const plugins = this.plugins;
  421. if (!(plugin in plugins)) {
  422. return this.load(plugin).then(() => this.upload(plugin, raw));
  423. }
  424. // Set the local copy.
  425. plugins[plugin].raw = raw;
  426. return this._save(plugin);
  427. }
  428. /**
  429. * Load a plugin into the registry.
  430. */
  431. private async _load(data: ISettingRegistry.IPlugin): Promise<void> {
  432. const plugin = data.id;
  433. // Validate and preload the item.
  434. try {
  435. await this._validate(data);
  436. } catch (errors) {
  437. const output = [`Validating ${plugin} failed:`];
  438. (errors as ISchemaValidator.IError[]).forEach((error, index) => {
  439. const { dataPath, schemaPath, keyword, message } = error;
  440. if (dataPath || schemaPath) {
  441. output.push(`${index} - schema @ ${schemaPath}, data @ ${dataPath}`);
  442. }
  443. output.push(`{${keyword}} ${message}`);
  444. });
  445. console.warn(output.join('\n'));
  446. throw errors;
  447. }
  448. }
  449. /**
  450. * Preload a list of plugins and fail gracefully.
  451. */
  452. private async _preload(plugins: ISettingRegistry.IPlugin[]): Promise<void> {
  453. await Promise.all(
  454. plugins.map(async plugin => {
  455. try {
  456. // Apply a transformation to the plugin if necessary.
  457. await this._load(await this._transform('fetch', plugin));
  458. } catch (errors) {
  459. /* Ignore preload timeout errors silently. */
  460. if (errors[0]?.keyword !== 'timeout') {
  461. console.warn('Ignored setting registry preload errors.', errors);
  462. }
  463. }
  464. })
  465. );
  466. }
  467. /**
  468. * Save a plugin in the registry.
  469. */
  470. private async _save(plugin: string): Promise<void> {
  471. const plugins = this.plugins;
  472. if (!(plugin in plugins)) {
  473. throw new Error(`${plugin} does not exist in setting registry.`);
  474. }
  475. try {
  476. await this._validate(plugins[plugin]);
  477. } catch (errors) {
  478. console.warn(`${plugin} validation errors:`, errors);
  479. throw new Error(`${plugin} failed to validate; check console.`);
  480. }
  481. await this.connector.save(plugin, plugins[plugin].raw);
  482. // Fetch and reload the data to guarantee server and client are in sync.
  483. const fetched = await this.connector.fetch(plugin);
  484. if (fetched === undefined) {
  485. throw [
  486. {
  487. dataPath: '',
  488. keyword: 'id',
  489. message: `Could not fetch settings for ${plugin}.`,
  490. schemaPath: ''
  491. } as ISchemaValidator.IError
  492. ];
  493. }
  494. await this._load(await this._transform('fetch', fetched));
  495. this._pluginChanged.emit(plugin);
  496. }
  497. /**
  498. * Transform the plugin if necessary.
  499. */
  500. private async _transform(
  501. phase: ISettingRegistry.IPlugin.Phase,
  502. plugin: ISettingRegistry.IPlugin,
  503. started = new Date().getTime()
  504. ): Promise<ISettingRegistry.IPlugin> {
  505. const elapsed = new Date().getTime() - started;
  506. const id = plugin.id;
  507. const transformers = this._transformers;
  508. const timeout = this._timeout;
  509. if (!plugin.schema['jupyter.lab.transform']) {
  510. return plugin;
  511. }
  512. if (id in transformers) {
  513. const transformed = transformers[id][phase].call(null, plugin);
  514. if (transformed.id !== id) {
  515. throw [
  516. {
  517. dataPath: '',
  518. keyword: 'id',
  519. message: 'Plugin transformations cannot change plugin IDs.',
  520. schemaPath: ''
  521. } as ISchemaValidator.IError
  522. ];
  523. }
  524. return transformed;
  525. }
  526. // If the timeout has not been exceeded, stall and try again in 250ms.
  527. if (elapsed < timeout) {
  528. await new Promise<void>(resolve => {
  529. setTimeout(() => {
  530. resolve();
  531. }, 250);
  532. });
  533. return this._transform(phase, plugin, started);
  534. }
  535. throw [
  536. {
  537. dataPath: '',
  538. keyword: 'timeout',
  539. message: `Transforming ${plugin.id} timed out.`,
  540. schemaPath: ''
  541. } as ISchemaValidator.IError
  542. ];
  543. }
  544. /**
  545. * Validate and preload a plugin, compose the `composite` data.
  546. */
  547. private async _validate(plugin: ISettingRegistry.IPlugin): Promise<void> {
  548. // Validate the user data and create the composite data.
  549. const errors = this.validator.validateData(plugin);
  550. if (errors) {
  551. throw errors;
  552. }
  553. // Apply a transformation if necessary and set the local copy.
  554. this.plugins[plugin.id] = await this._transform('compose', plugin);
  555. }
  556. private _pluginChanged = new Signal<this, string>(this);
  557. private _ready = Promise.resolve();
  558. private _timeout: number;
  559. private _transformers: {
  560. [plugin: string]: {
  561. [phase in ISettingRegistry.IPlugin.Phase]: ISettingRegistry.IPlugin.Transform;
  562. };
  563. } = Object.create(null);
  564. }
  565. /**
  566. * A manager for a specific plugin's settings.
  567. */
  568. export class Settings implements ISettingRegistry.ISettings {
  569. /**
  570. * Instantiate a new plugin settings manager.
  571. */
  572. constructor(options: Settings.IOptions) {
  573. this.id = options.plugin.id;
  574. this.registry = options.registry;
  575. this.registry.pluginChanged.connect(this._onPluginChanged, this);
  576. }
  577. /**
  578. * The plugin name.
  579. */
  580. readonly id: string;
  581. /**
  582. * The setting registry instance used as a back-end for these settings.
  583. */
  584. readonly registry: ISettingRegistry;
  585. /**
  586. * A signal that emits when the plugin's settings have changed.
  587. */
  588. get changed(): ISignal<this, void> {
  589. return this._changed;
  590. }
  591. /**
  592. * The composite of user settings and extension defaults.
  593. */
  594. get composite(): ReadonlyPartialJSONObject {
  595. return this.plugin.data.composite;
  596. }
  597. /**
  598. * Test whether the plugin settings manager disposed.
  599. */
  600. get isDisposed(): boolean {
  601. return this._isDisposed;
  602. }
  603. get plugin(): ISettingRegistry.IPlugin {
  604. return this.registry.plugins[this.id]!;
  605. }
  606. /**
  607. * The plugin's schema.
  608. */
  609. get schema(): ISettingRegistry.ISchema {
  610. return this.plugin.schema;
  611. }
  612. /**
  613. * The plugin settings raw text value.
  614. */
  615. get raw(): string {
  616. return this.plugin.raw;
  617. }
  618. /**
  619. * The user settings.
  620. */
  621. get user(): ReadonlyPartialJSONObject {
  622. return this.plugin.data.user;
  623. }
  624. /**
  625. * The published version of the NPM package containing these settings.
  626. */
  627. get version(): string {
  628. return this.plugin.version;
  629. }
  630. /**
  631. * Return the defaults in a commented JSON format.
  632. */
  633. annotatedDefaults(): string {
  634. return Private.annotatedDefaults(this.schema, this.id);
  635. }
  636. /**
  637. * Calculate the default value of a setting by iterating through the schema.
  638. *
  639. * @param key - The name of the setting whose default value is calculated.
  640. *
  641. * @returns A calculated default JSON value for a specific setting.
  642. */
  643. default(key: string): PartialJSONValue | undefined {
  644. return Private.reifyDefault(this.schema, key);
  645. }
  646. /**
  647. * Dispose of the plugin settings resources.
  648. */
  649. dispose(): void {
  650. if (this._isDisposed) {
  651. return;
  652. }
  653. this._isDisposed = true;
  654. Signal.clearData(this);
  655. }
  656. /**
  657. * Get an individual setting.
  658. *
  659. * @param key - The name of the setting being retrieved.
  660. *
  661. * @returns The setting value.
  662. *
  663. * #### Notes
  664. * This method returns synchronously because it uses a cached copy of the
  665. * plugin settings that is synchronized with the registry.
  666. */
  667. get(
  668. key: string
  669. ): {
  670. composite: ReadonlyPartialJSONValue | undefined;
  671. user: ReadonlyPartialJSONValue | undefined;
  672. } {
  673. const { composite, user } = this;
  674. return {
  675. composite:
  676. composite[key] !== undefined ? copy(composite[key]!) : undefined,
  677. user: user[key] !== undefined ? copy(user[key]!) : undefined
  678. };
  679. }
  680. /**
  681. * Remove a single setting.
  682. *
  683. * @param key - The name of the setting being removed.
  684. *
  685. * @returns A promise that resolves when the setting is removed.
  686. *
  687. * #### Notes
  688. * This function is asynchronous because it writes to the setting registry.
  689. */
  690. remove(key: string): Promise<void> {
  691. return this.registry.remove(this.plugin.id, key);
  692. }
  693. /**
  694. * Save all of the plugin's user settings at once.
  695. */
  696. save(raw: string): Promise<void> {
  697. return this.registry.upload(this.plugin.id, raw);
  698. }
  699. /**
  700. * Set a single setting.
  701. *
  702. * @param key - The name of the setting being set.
  703. *
  704. * @param value - The value of the setting.
  705. *
  706. * @returns A promise that resolves when the setting has been saved.
  707. *
  708. * #### Notes
  709. * This function is asynchronous because it writes to the setting registry.
  710. */
  711. set(key: string, value: JSONValue): Promise<void> {
  712. return this.registry.set(this.plugin.id, key, value);
  713. }
  714. /**
  715. * Validates raw settings with comments.
  716. *
  717. * @param raw - The JSON with comments string being validated.
  718. *
  719. * @returns A list of errors or `null` if valid.
  720. */
  721. validate(raw: string): ISchemaValidator.IError[] | null {
  722. const data = { composite: {}, user: {} };
  723. const { id, schema } = this.plugin;
  724. const validator = this.registry.validator;
  725. const version = this.version;
  726. return validator.validateData({ data, id, raw, schema, version }, false);
  727. }
  728. /**
  729. * Handle plugin changes in the setting registry.
  730. */
  731. private _onPluginChanged(sender: any, plugin: string): void {
  732. if (plugin === this.plugin.id) {
  733. this._changed.emit(undefined);
  734. }
  735. }
  736. private _changed = new Signal<this, void>(this);
  737. private _isDisposed = false;
  738. }
  739. /**
  740. * A namespace for `SettingRegistry` statics.
  741. */
  742. export namespace SettingRegistry {
  743. /**
  744. * The instantiation options for a setting registry
  745. */
  746. export interface IOptions {
  747. /**
  748. * The data connector used by the setting registry.
  749. */
  750. connector: IDataConnector<ISettingRegistry.IPlugin, string>;
  751. /**
  752. * Preloaded plugin data to populate the setting registry.
  753. */
  754. plugins?: ISettingRegistry.IPlugin[];
  755. /**
  756. * The number of milliseconds before a `load()` call to the registry waits
  757. * before timing out if it requires a transformation that has not been
  758. * registered.
  759. *
  760. * #### Notes
  761. * The default value is 7000.
  762. */
  763. timeout?: number;
  764. /**
  765. * The validator used to enforce the settings JSON schema.
  766. */
  767. validator?: ISchemaValidator;
  768. }
  769. /**
  770. * Reconcile default and user shortcuts and return the composite list.
  771. *
  772. * @param defaults - The list of default shortcuts.
  773. *
  774. * @param user - The list of user shortcut overrides and additions.
  775. *
  776. * @returns A loadable list of shortcuts (omitting disabled and overridden).
  777. */
  778. export function reconcileShortcuts(
  779. defaults: ISettingRegistry.IShortcut[],
  780. user: ISettingRegistry.IShortcut[]
  781. ): ISettingRegistry.IShortcut[] {
  782. const memo: {
  783. [keys: string]: {
  784. [selector: string]: boolean; // If `true`, should warn if a default shortcut conflicts.
  785. };
  786. } = {};
  787. // If a user shortcut collides with another user shortcut warn and filter.
  788. user = user.filter(shortcut => {
  789. const keys = CommandRegistry.normalizeKeys(shortcut).join(
  790. RECORD_SEPARATOR
  791. );
  792. if (!keys) {
  793. console.warn(
  794. 'Skipping this shortcut because there are no actionable keys on this platform',
  795. shortcut
  796. );
  797. return false;
  798. }
  799. if (!(keys in memo)) {
  800. memo[keys] = {};
  801. }
  802. const { selector } = shortcut;
  803. if (!(selector in memo[keys])) {
  804. memo[keys][selector] = false; // Do not warn if a default shortcut conflicts.
  805. return true;
  806. }
  807. console.warn(
  808. 'Skipping this shortcut because it collides with another shortcut.',
  809. shortcut
  810. );
  811. return false;
  812. });
  813. // If a default shortcut collides with another default, warn and filter,
  814. // unless one of the shortcuts is a disabling shortcut (so look through
  815. // disabled shortcuts first). If a shortcut has already been added by the
  816. // user preferences, filter it out too (this includes shortcuts that are
  817. // disabled by user preferences).
  818. defaults = [
  819. ...defaults.filter(s => !!s.disabled),
  820. ...defaults.filter(s => !s.disabled)
  821. ].filter(shortcut => {
  822. const keys = CommandRegistry.normalizeKeys(shortcut).join(
  823. RECORD_SEPARATOR
  824. );
  825. if (!keys) {
  826. return false;
  827. }
  828. if (!(keys in memo)) {
  829. memo[keys] = {};
  830. }
  831. const { disabled, selector } = shortcut;
  832. if (!(selector in memo[keys])) {
  833. // Warn of future conflicts if the default shortcut is not disabled.
  834. memo[keys][selector] = !disabled;
  835. return true;
  836. }
  837. // We have a conflict now. Warn the user if we need to do so.
  838. if (memo[keys][selector]) {
  839. console.warn(
  840. 'Skipping this default shortcut because it collides with another default shortcut.',
  841. shortcut
  842. );
  843. }
  844. return false;
  845. });
  846. // Return all the shortcuts that should be registered
  847. return user.concat(defaults).filter(shortcut => !shortcut.disabled);
  848. }
  849. }
  850. /**
  851. * A namespace for `Settings` statics.
  852. */
  853. export namespace Settings {
  854. /**
  855. * The instantiation options for a `Settings` object.
  856. */
  857. export interface IOptions {
  858. /**
  859. * The setting values for a plugin.
  860. */
  861. plugin: ISettingRegistry.IPlugin;
  862. /**
  863. * The system registry instance used by the settings manager.
  864. */
  865. registry: ISettingRegistry;
  866. }
  867. }
  868. /**
  869. * A namespace for private module data.
  870. */
  871. namespace Private {
  872. /**
  873. * The default indentation level, uses spaces instead of tabs.
  874. */
  875. const indent = ' ';
  876. /**
  877. * Replacement text for schema properties missing a `description` field.
  878. */
  879. const nondescript = '[missing schema description]';
  880. /**
  881. * Replacement text for schema properties missing a `title` field.
  882. */
  883. const untitled = '[missing schema title]';
  884. /**
  885. * Returns an annotated (JSON with comments) version of a schema's defaults.
  886. */
  887. export function annotatedDefaults(
  888. schema: ISettingRegistry.ISchema,
  889. plugin: string
  890. ): string {
  891. const { description, properties, title } = schema;
  892. const keys = properties
  893. ? Object.keys(properties).sort((a, b) => a.localeCompare(b))
  894. : [];
  895. const length = Math.max((description || nondescript).length, plugin.length);
  896. return [
  897. '{',
  898. prefix(`${title || untitled}`),
  899. prefix(plugin),
  900. prefix(description || nondescript),
  901. prefix('*'.repeat(length)),
  902. '',
  903. join(keys.map(key => defaultDocumentedValue(schema, key))),
  904. '}'
  905. ].join('\n');
  906. }
  907. /**
  908. * Returns an annotated (JSON with comments) version of a plugin's
  909. * setting data.
  910. */
  911. export function annotatedPlugin(
  912. plugin: ISettingRegistry.IPlugin,
  913. data: JSONObject
  914. ): string {
  915. const { description, title } = plugin.schema;
  916. const keys = Object.keys(data).sort((a, b) => a.localeCompare(b));
  917. const length = Math.max(
  918. (description || nondescript).length,
  919. plugin.id.length
  920. );
  921. return [
  922. '{',
  923. prefix(`${title || untitled}`),
  924. prefix(plugin.id),
  925. prefix(description || nondescript),
  926. prefix('*'.repeat(length)),
  927. '',
  928. join(keys.map(key => documentedValue(plugin.schema, key, data[key]))),
  929. '}'
  930. ].join('\n');
  931. }
  932. /**
  933. * Returns the default value-with-documentation-string for a
  934. * specific schema property.
  935. */
  936. function defaultDocumentedValue(
  937. schema: ISettingRegistry.ISchema,
  938. key: string
  939. ): string {
  940. const props = (schema.properties && schema.properties[key]) || {};
  941. const type = props['type'];
  942. const description = props['description'] || nondescript;
  943. const title = props['title'] || '';
  944. const reified = reifyDefault(schema, key);
  945. const spaces = indent.length;
  946. const defaults =
  947. reified !== undefined
  948. ? prefix(`"${key}": ${JSON.stringify(reified, null, spaces)}`, indent)
  949. : prefix(`"${key}": ${type}`);
  950. return [prefix(title), prefix(description), defaults]
  951. .filter(str => str.length)
  952. .join('\n');
  953. }
  954. /**
  955. * Returns a value-with-documentation-string for a specific schema property.
  956. */
  957. function documentedValue(
  958. schema: ISettingRegistry.ISchema,
  959. key: string,
  960. value: JSONValue
  961. ): string {
  962. const props = schema.properties && schema.properties[key];
  963. const description = (props && props['description']) || nondescript;
  964. const title = (props && props['title']) || untitled;
  965. const spaces = indent.length;
  966. const attribute = prefix(
  967. `"${key}": ${JSON.stringify(value, null, spaces)}`,
  968. indent
  969. );
  970. return [prefix(title), prefix(description), attribute].join('\n');
  971. }
  972. /**
  973. * Returns a joined string with line breaks and commas where appropriate.
  974. */
  975. function join(body: string[]): string {
  976. return body.reduce((acc, val, idx) => {
  977. const rows = val.split('\n');
  978. const last = rows[rows.length - 1];
  979. const comment = last.trim().indexOf('//') === 0;
  980. const comma = comment || idx === body.length - 1 ? '' : ',';
  981. const separator = idx === body.length - 1 ? '' : '\n\n';
  982. return acc + val + comma + separator;
  983. }, '');
  984. }
  985. /**
  986. * Returns a documentation string with a comment prefix added on every line.
  987. */
  988. function prefix(source: string, pre = `${indent}// `): string {
  989. return pre + source.split('\n').join(`\n${pre}`);
  990. }
  991. /**
  992. * Create a fully extrapolated default value for a root key in a schema.
  993. */
  994. export function reifyDefault(
  995. schema: ISettingRegistry.IProperty,
  996. root?: string
  997. ): PartialJSONValue | undefined {
  998. // If the property is at the root level, traverse its schema.
  999. schema = (root ? schema.properties?.[root] : schema) || {};
  1000. // If the property has no default or is a primitive, return.
  1001. if (!('default' in schema) || schema.type !== 'object') {
  1002. return schema.default;
  1003. }
  1004. // Make a copy of the default value to populate.
  1005. const result = JSONExt.deepCopy(schema.default as PartialJSONObject);
  1006. // Iterate through and populate each child property.
  1007. const props = schema.properties || {};
  1008. for (const property in props) {
  1009. result[property] = reifyDefault(props[property]);
  1010. }
  1011. return result;
  1012. }
  1013. }