settingregistry.ts 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167
  1. // Copyright (c) Jupyter Development Team.
  2. // Distributed under the terms of the Modified BSD License.
  3. import Ajv from 'ajv';
  4. import * as json 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 = json.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 = json.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 = json.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 errors. */
  460. console.warn('Ignored setting registry preload errors.', errors);
  461. }
  462. })
  463. );
  464. }
  465. /**
  466. * Save a plugin in the registry.
  467. */
  468. private async _save(plugin: string): Promise<void> {
  469. const plugins = this.plugins;
  470. if (!(plugin in plugins)) {
  471. throw new Error(`${plugin} does not exist in setting registry.`);
  472. }
  473. try {
  474. await this._validate(plugins[plugin]);
  475. } catch (errors) {
  476. console.warn(`${plugin} validation errors:`, errors);
  477. throw new Error(`${plugin} failed to validate; check console.`);
  478. }
  479. await this.connector.save(plugin, plugins[plugin].raw);
  480. // Fetch and reload the data to guarantee server and client are in sync.
  481. const fetched = await this.connector.fetch(plugin);
  482. if (fetched === undefined) {
  483. throw [
  484. {
  485. dataPath: '',
  486. keyword: 'id',
  487. message: `Could not fetch settings for ${plugin}.`,
  488. schemaPath: ''
  489. } as ISchemaValidator.IError
  490. ];
  491. }
  492. await this._load(await this._transform('fetch', fetched));
  493. this._pluginChanged.emit(plugin);
  494. }
  495. /**
  496. * Transform the plugin if necessary.
  497. */
  498. private async _transform(
  499. phase: ISettingRegistry.IPlugin.Phase,
  500. plugin: ISettingRegistry.IPlugin,
  501. started = new Date().getTime()
  502. ): Promise<ISettingRegistry.IPlugin> {
  503. const elapsed = new Date().getTime() - started;
  504. const id = plugin.id;
  505. const transformers = this._transformers;
  506. const timeout = this._timeout;
  507. if (!plugin.schema['jupyter.lab.transform']) {
  508. return plugin;
  509. }
  510. if (id in transformers) {
  511. const transformed = transformers[id][phase].call(null, plugin);
  512. if (transformed.id !== id) {
  513. throw [
  514. {
  515. dataPath: '',
  516. keyword: 'id',
  517. message: 'Plugin transformations cannot change plugin IDs.',
  518. schemaPath: ''
  519. } as ISchemaValidator.IError
  520. ];
  521. }
  522. return transformed;
  523. }
  524. // If the timeout has not been exceeded, stall and try again in 250ms.
  525. if (elapsed < timeout) {
  526. await new Promise(resolve => {
  527. setTimeout(() => {
  528. resolve();
  529. }, 250);
  530. });
  531. return this._transform(phase, plugin, started);
  532. }
  533. throw [
  534. {
  535. dataPath: '',
  536. keyword: 'timeout',
  537. message: `Transforming ${plugin.id} timed out.`,
  538. schemaPath: ''
  539. } as ISchemaValidator.IError
  540. ];
  541. }
  542. /**
  543. * Validate and preload a plugin, compose the `composite` data.
  544. */
  545. private async _validate(plugin: ISettingRegistry.IPlugin): Promise<void> {
  546. // Validate the user data and create the composite data.
  547. const errors = this.validator.validateData(plugin);
  548. if (errors) {
  549. throw errors;
  550. }
  551. // Apply a transformation if necessary and set the local copy.
  552. this.plugins[plugin.id] = await this._transform('compose', plugin);
  553. }
  554. private _pluginChanged = new Signal<this, string>(this);
  555. private _ready = Promise.resolve();
  556. private _timeout: number;
  557. private _transformers: {
  558. [plugin: string]: {
  559. [phase in ISettingRegistry.IPlugin.Phase]: ISettingRegistry.IPlugin.Transform;
  560. };
  561. } = Object.create(null);
  562. }
  563. /**
  564. * A manager for a specific plugin's settings.
  565. */
  566. export class Settings implements ISettingRegistry.ISettings {
  567. /**
  568. * Instantiate a new plugin settings manager.
  569. */
  570. constructor(options: Settings.IOptions) {
  571. this.id = options.plugin.id;
  572. this.registry = options.registry;
  573. this.registry.pluginChanged.connect(this._onPluginChanged, this);
  574. }
  575. /**
  576. * The plugin name.
  577. */
  578. readonly id: string;
  579. /**
  580. * The setting registry instance used as a back-end for these settings.
  581. */
  582. readonly registry: ISettingRegistry;
  583. /**
  584. * A signal that emits when the plugin's settings have changed.
  585. */
  586. get changed(): ISignal<this, void> {
  587. return this._changed;
  588. }
  589. /**
  590. * The composite of user settings and extension defaults.
  591. */
  592. get composite(): ReadonlyPartialJSONObject {
  593. return this.plugin.data.composite;
  594. }
  595. /**
  596. * Test whether the plugin settings manager disposed.
  597. */
  598. get isDisposed(): boolean {
  599. return this._isDisposed;
  600. }
  601. get plugin(): ISettingRegistry.IPlugin {
  602. return this.registry.plugins[this.id]!;
  603. }
  604. /**
  605. * The plugin's schema.
  606. */
  607. get schema(): ISettingRegistry.ISchema {
  608. return this.plugin.schema;
  609. }
  610. /**
  611. * The plugin settings raw text value.
  612. */
  613. get raw(): string {
  614. return this.plugin.raw;
  615. }
  616. /**
  617. * The user settings.
  618. */
  619. get user(): ReadonlyPartialJSONObject {
  620. return this.plugin.data.user;
  621. }
  622. /**
  623. * The published version of the NPM package containing these settings.
  624. */
  625. get version(): string {
  626. return this.plugin.version;
  627. }
  628. /**
  629. * Return the defaults in a commented JSON format.
  630. */
  631. annotatedDefaults(): string {
  632. return Private.annotatedDefaults(this.schema, this.id);
  633. }
  634. /**
  635. * Calculate the default value of a setting by iterating through the schema.
  636. *
  637. * @param key - The name of the setting whose default value is calculated.
  638. *
  639. * @returns A calculated default JSON value for a specific setting.
  640. */
  641. default(key: string): PartialJSONValue | undefined {
  642. return Private.reifyDefault(this.schema, key);
  643. }
  644. /**
  645. * Dispose of the plugin settings resources.
  646. */
  647. dispose(): void {
  648. if (this._isDisposed) {
  649. return;
  650. }
  651. this._isDisposed = true;
  652. Signal.clearData(this);
  653. }
  654. /**
  655. * Get an individual setting.
  656. *
  657. * @param key - The name of the setting being retrieved.
  658. *
  659. * @returns The setting value.
  660. *
  661. * #### Notes
  662. * This method returns synchronously because it uses a cached copy of the
  663. * plugin settings that is synchronized with the registry.
  664. */
  665. get(
  666. key: string
  667. ): {
  668. composite: ReadonlyPartialJSONValue | undefined;
  669. user: ReadonlyPartialJSONValue | undefined;
  670. } {
  671. const { composite, user } = this;
  672. return {
  673. composite:
  674. composite[key] !== undefined ? copy(composite[key]!) : undefined,
  675. user: user[key] !== undefined ? copy(user[key]!) : undefined
  676. };
  677. }
  678. /**
  679. * Remove a single setting.
  680. *
  681. * @param key - The name of the setting being removed.
  682. *
  683. * @returns A promise that resolves when the setting is removed.
  684. *
  685. * #### Notes
  686. * This function is asynchronous because it writes to the setting registry.
  687. */
  688. remove(key: string): Promise<void> {
  689. return this.registry.remove(this.plugin.id, key);
  690. }
  691. /**
  692. * Save all of the plugin's user settings at once.
  693. */
  694. save(raw: string): Promise<void> {
  695. return this.registry.upload(this.plugin.id, raw);
  696. }
  697. /**
  698. * Set a single setting.
  699. *
  700. * @param key - The name of the setting being set.
  701. *
  702. * @param value - The value of the setting.
  703. *
  704. * @returns A promise that resolves when the setting has been saved.
  705. *
  706. * #### Notes
  707. * This function is asynchronous because it writes to the setting registry.
  708. */
  709. set(key: string, value: JSONValue): Promise<void> {
  710. return this.registry.set(this.plugin.id, key, value);
  711. }
  712. /**
  713. * Validates raw settings with comments.
  714. *
  715. * @param raw - The JSON with comments string being validated.
  716. *
  717. * @returns A list of errors or `null` if valid.
  718. */
  719. validate(raw: string): ISchemaValidator.IError[] | null {
  720. const data = { composite: {}, user: {} };
  721. const { id, schema } = this.plugin;
  722. const validator = this.registry.validator;
  723. const version = this.version;
  724. return validator.validateData({ data, id, raw, schema, version }, false);
  725. }
  726. /**
  727. * Handle plugin changes in the setting registry.
  728. */
  729. private _onPluginChanged(sender: any, plugin: string): void {
  730. if (plugin === this.plugin.id) {
  731. this._changed.emit(undefined);
  732. }
  733. }
  734. private _changed = new Signal<this, void>(this);
  735. private _isDisposed = false;
  736. }
  737. /**
  738. * A namespace for `SettingRegistry` statics.
  739. */
  740. export namespace SettingRegistry {
  741. /**
  742. * The instantiation options for a setting registry
  743. */
  744. export interface IOptions {
  745. /**
  746. * The data connector used by the setting registry.
  747. */
  748. connector: IDataConnector<ISettingRegistry.IPlugin, string>;
  749. /**
  750. * Preloaded plugin data to populate the setting registry.
  751. */
  752. plugins?: ISettingRegistry.IPlugin[];
  753. /**
  754. * The number of milliseconds before a `load()` call to the registry waits
  755. * before timing out if it requires a transformation that has not been
  756. * registered.
  757. *
  758. * #### Notes
  759. * The default value is 7000.
  760. */
  761. timeout?: number;
  762. /**
  763. * The validator used to enforce the settings JSON schema.
  764. */
  765. validator?: ISchemaValidator;
  766. }
  767. /**
  768. * Reconcile default and user shortcuts and return the composite list.
  769. *
  770. * @param defaults - The list of default shortcuts.
  771. *
  772. * @param user - The list of user shortcut overrides and additions.
  773. *
  774. * @returns A loadable list of shortcuts (omitting disabled and overridden).
  775. */
  776. export function reconcileShortcuts(
  777. defaults: ISettingRegistry.IShortcut[],
  778. user: ISettingRegistry.IShortcut[]
  779. ): ISettingRegistry.IShortcut[] {
  780. const memo: {
  781. [keys: string]: {
  782. [selector: string]: boolean; // If `true`, this is a default shortcut.
  783. };
  784. } = {};
  785. // If a user shortcut collides with another user shortcut warn and filter.
  786. user = user.filter(shortcut => {
  787. const keys = CommandRegistry.normalizeKeys(shortcut).join(
  788. RECORD_SEPARATOR
  789. );
  790. const { selector } = shortcut;
  791. if (!keys) {
  792. console.warn(
  793. 'Skipping this shortcut because there are no actionable keys on this platform',
  794. shortcut
  795. );
  796. return false;
  797. }
  798. if (!(keys in memo)) {
  799. memo[keys] = {};
  800. }
  801. if (!(selector in memo[keys])) {
  802. memo[keys][selector] = false; // User shortcuts are `false`.
  803. return true;
  804. }
  805. console.warn(
  806. 'Skipping this shortcut because it collides with another shortcut.',
  807. shortcut
  808. );
  809. return false;
  810. });
  811. // If a default shortcut collides with another default, warn and filter.
  812. // If a shortcut has already been added by the user preferences, filter it
  813. // out too (this includes shortcuts that are disabled by user preferences).
  814. defaults = defaults.filter(shortcut => {
  815. const { disabled } = shortcut;
  816. const keys = CommandRegistry.normalizeKeys(shortcut).join(
  817. RECORD_SEPARATOR
  818. );
  819. if (disabled || !keys) {
  820. return false;
  821. }
  822. if (!(keys in memo)) {
  823. memo[keys] = {};
  824. }
  825. const { selector } = shortcut;
  826. if (!(selector in memo[keys])) {
  827. memo[keys][selector] = true; // Default shortcuts are `true`.
  828. return true;
  829. }
  830. // Only warn if a default shortcut collides with another default shortcut.
  831. if (memo[keys][selector]) {
  832. console.warn(
  833. 'Skipping this shortcut because it collides with another shortcut.',
  834. shortcut
  835. );
  836. }
  837. return false;
  838. });
  839. // Filter out disabled user shortcuts and concat defaults before returning.
  840. return user.filter(shortcut => !shortcut.disabled).concat(defaults);
  841. }
  842. }
  843. /**
  844. * A namespace for `Settings` statics.
  845. */
  846. export namespace Settings {
  847. /**
  848. * The instantiation options for a `Settings` object.
  849. */
  850. export interface IOptions {
  851. /**
  852. * The setting values for a plugin.
  853. */
  854. plugin: ISettingRegistry.IPlugin;
  855. /**
  856. * The system registry instance used by the settings manager.
  857. */
  858. registry: ISettingRegistry;
  859. }
  860. }
  861. /**
  862. * A namespace for private module data.
  863. */
  864. namespace Private {
  865. /**
  866. * The default indentation level, uses spaces instead of tabs.
  867. */
  868. const indent = ' ';
  869. /**
  870. * Replacement text for schema properties missing a `description` field.
  871. */
  872. const nondescript = '[missing schema description]';
  873. /**
  874. * Replacement text for schema properties missing a `title` field.
  875. */
  876. const untitled = '[missing schema title]';
  877. /**
  878. * Returns an annotated (JSON with comments) version of a schema's defaults.
  879. */
  880. export function annotatedDefaults(
  881. schema: ISettingRegistry.ISchema,
  882. plugin: string
  883. ): string {
  884. const { description, properties, title } = schema;
  885. const keys = properties
  886. ? Object.keys(properties).sort((a, b) => a.localeCompare(b))
  887. : [];
  888. const length = Math.max((description || nondescript).length, plugin.length);
  889. return [
  890. '{',
  891. prefix(`${title || untitled}`),
  892. prefix(plugin),
  893. prefix(description || nondescript),
  894. prefix('*'.repeat(length)),
  895. '',
  896. join(keys.map(key => defaultDocumentedValue(schema, key))),
  897. '}'
  898. ].join('\n');
  899. }
  900. /**
  901. * Returns an annotated (JSON with comments) version of a plugin's
  902. * setting data.
  903. */
  904. export function annotatedPlugin(
  905. plugin: ISettingRegistry.IPlugin,
  906. data: JSONObject
  907. ): string {
  908. const { description, title } = plugin.schema;
  909. const keys = Object.keys(data).sort((a, b) => a.localeCompare(b));
  910. const length = Math.max(
  911. (description || nondescript).length,
  912. plugin.id.length
  913. );
  914. return [
  915. '{',
  916. prefix(`${title || untitled}`),
  917. prefix(plugin.id),
  918. prefix(description || nondescript),
  919. prefix('*'.repeat(length)),
  920. '',
  921. join(keys.map(key => documentedValue(plugin.schema, key, data[key]))),
  922. '}'
  923. ].join('\n');
  924. }
  925. /**
  926. * Returns the default value-with-documentation-string for a
  927. * specific schema property.
  928. */
  929. function defaultDocumentedValue(
  930. schema: ISettingRegistry.ISchema,
  931. key: string
  932. ): string {
  933. const props = (schema.properties && schema.properties[key]) || {};
  934. const type = props['type'];
  935. const description = props['description'] || nondescript;
  936. const title = props['title'] || '';
  937. const reified = reifyDefault(schema, key);
  938. const spaces = indent.length;
  939. const defaults =
  940. reified !== undefined
  941. ? prefix(`"${key}": ${JSON.stringify(reified, null, spaces)}`, indent)
  942. : prefix(`"${key}": ${type}`);
  943. return [prefix(title), prefix(description), defaults]
  944. .filter(str => str.length)
  945. .join('\n');
  946. }
  947. /**
  948. * Returns a value-with-documentation-string for a specific schema property.
  949. */
  950. function documentedValue(
  951. schema: ISettingRegistry.ISchema,
  952. key: string,
  953. value: JSONValue
  954. ): string {
  955. const props = schema.properties && schema.properties[key];
  956. const description = (props && props['description']) || nondescript;
  957. const title = (props && props['title']) || untitled;
  958. const spaces = indent.length;
  959. const attribute = prefix(
  960. `"${key}": ${JSON.stringify(value, null, spaces)}`,
  961. indent
  962. );
  963. return [prefix(title), prefix(description), attribute].join('\n');
  964. }
  965. /**
  966. * Returns a joined string with line breaks and commas where appropriate.
  967. */
  968. function join(body: string[]): string {
  969. return body.reduce((acc, val, idx) => {
  970. const rows = val.split('\n');
  971. const last = rows[rows.length - 1];
  972. const comment = last.trim().indexOf('//') === 0;
  973. const comma = comment || idx === body.length - 1 ? '' : ',';
  974. const separator = idx === body.length - 1 ? '' : '\n\n';
  975. return acc + val + comma + separator;
  976. }, '');
  977. }
  978. /**
  979. * Returns a documentation string with a comment prefix added on every line.
  980. */
  981. function prefix(source: string, pre = `${indent}// `): string {
  982. return pre + source.split('\n').join(`\n${pre}`);
  983. }
  984. /**
  985. * Create a fully extrapolated default value for a root key in a schema.
  986. */
  987. export function reifyDefault(
  988. schema: ISettingRegistry.IProperty,
  989. root?: string
  990. ): PartialJSONValue | undefined {
  991. // If the property is at the root level, traverse its schema.
  992. schema = (root ? schema.properties?.[root] : schema) || {};
  993. // If the property has no default or is a primitive, return.
  994. if (!('default' in schema) || schema.type !== 'object') {
  995. return schema.default;
  996. }
  997. // Make a copy of the default value to populate.
  998. const result = JSONExt.deepCopy(schema.default as PartialJSONObject);
  999. // Iterate through and populate each child property.
  1000. const props = schema.properties || {};
  1001. for (const property in props) {
  1002. result[property] = reifyDefault(props[property]);
  1003. }
  1004. return result;
  1005. }
  1006. }