settingregistry.ts 30 KB

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