licenses.tsx 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729
  1. // Copyright (c) Jupyter Development Team.
  2. // Distributed under the terms of the Modified BSD License.
  3. import * as React from 'react';
  4. import { Panel, SplitPanel, TabBar, Widget } from '@lumino/widgets';
  5. import { ReadonlyJSONObject, PromiseDelegate } from '@lumino/coreutils';
  6. import { ISignal, Signal } from '@lumino/signaling';
  7. import { VirtualElement, h } from '@lumino/virtualdom';
  8. import { ServerConnection } from '@jupyterlab/services';
  9. import { TranslationBundle } from '@jupyterlab/translation';
  10. import { VDomModel, VDomRenderer } from '@jupyterlab/apputils';
  11. import {
  12. spreadsheetIcon,
  13. jsonIcon,
  14. markdownIcon,
  15. LabIcon
  16. } from '@jupyterlab/ui-components';
  17. /**
  18. * A license viewer
  19. */
  20. export class Licenses extends SplitPanel {
  21. readonly model: Licenses.Model;
  22. constructor(options: Licenses.IOptions) {
  23. super();
  24. this.addClass('jp-Licenses');
  25. this.model = options.model;
  26. this.initLeftPanel();
  27. this.initFilters();
  28. this.initBundles();
  29. this.initGrid();
  30. this.initLicenseText();
  31. this.setRelativeSizes([1, 2, 3]);
  32. void this.model.initLicenses().then(() => this._updateBundles());
  33. this.model.trackerDataChanged.connect(() => {
  34. this.title.label = this.model.title;
  35. });
  36. }
  37. /**
  38. * Handle disposing of the widget
  39. */
  40. dispose(): void {
  41. if (this.isDisposed) {
  42. return;
  43. }
  44. this._bundles.currentChanged.disconnect(this.onBundleSelected, this);
  45. this.model.dispose();
  46. super.dispose();
  47. }
  48. /**
  49. * Initialize the left area for filters and bundles
  50. */
  51. protected initLeftPanel(): void {
  52. this._leftPanel = new Panel();
  53. this._leftPanel.addClass('jp-Licenses-FormArea');
  54. this.addWidget(this._leftPanel);
  55. SplitPanel.setStretch(this._leftPanel, 1);
  56. }
  57. /**
  58. * Initialize the filters
  59. */
  60. protected initFilters(): void {
  61. this._filters = new Licenses.Filters(this.model);
  62. SplitPanel.setStretch(this._filters, 1);
  63. this._leftPanel.addWidget(this._filters);
  64. }
  65. /**
  66. * Initialize the listing of available bundles
  67. */
  68. protected initBundles(): void {
  69. this._bundles = new TabBar({
  70. orientation: 'vertical',
  71. renderer: new Licenses.BundleTabRenderer(this.model)
  72. });
  73. this._bundles.addClass('jp-Licenses-Bundles');
  74. SplitPanel.setStretch(this._bundles, 1);
  75. this._leftPanel.addWidget(this._bundles);
  76. this._bundles.currentChanged.connect(this.onBundleSelected, this);
  77. this.model.stateChanged.connect(() => this._bundles.update());
  78. }
  79. /**
  80. * Initialize the listing of packages within the current bundle
  81. */
  82. protected initGrid(): void {
  83. this._grid = new Licenses.Grid(this.model);
  84. SplitPanel.setStretch(this._grid, 1);
  85. this.addWidget(this._grid);
  86. }
  87. /**
  88. * Initialize the full text of the current package
  89. */
  90. protected initLicenseText(): void {
  91. this._licenseText = new Licenses.FullText(this.model);
  92. SplitPanel.setStretch(this._grid, 1);
  93. this.addWidget(this._licenseText);
  94. }
  95. /**
  96. * Event handler for updating the model with the current bundle
  97. */
  98. protected onBundleSelected(): void {
  99. if (this._bundles.currentTitle?.label) {
  100. this.model.currentBundleName = this._bundles.currentTitle.label;
  101. }
  102. }
  103. /**
  104. * Update the bundle tabs.
  105. */
  106. protected _updateBundles(): void {
  107. this._bundles.clearTabs();
  108. let i = 0;
  109. const { currentBundleName } = this.model;
  110. let currentIndex = 0;
  111. for (const bundle of this.model.bundleNames) {
  112. const tab = new Widget();
  113. tab.title.label = bundle;
  114. if (bundle === currentBundleName) {
  115. currentIndex = i;
  116. }
  117. this._bundles.insertTab(++i, tab.title);
  118. }
  119. this._bundles.currentIndex = currentIndex;
  120. }
  121. /**
  122. * An area for selecting licenses by bundle and filters
  123. */
  124. protected _leftPanel: Panel;
  125. /**
  126. * Filters on visible licenses
  127. */
  128. protected _filters: Licenses.Filters;
  129. /**
  130. * Tabs reflecting available bundles
  131. */
  132. protected _bundles: TabBar<Widget>;
  133. /**
  134. * A grid of the current bundle's packages' license metadata
  135. */
  136. protected _grid: Licenses.Grid;
  137. /**
  138. * The currently-selected package's full license text
  139. */
  140. protected _licenseText: Licenses.FullText;
  141. }
  142. /** A namespace for license components */
  143. export namespace Licenses {
  144. /** The information about a license report format */
  145. export interface IReportFormat {
  146. title: string;
  147. icon: LabIcon;
  148. id: string;
  149. }
  150. /**
  151. * License report formats understood by the server (once lower-cased)
  152. */
  153. export const REPORT_FORMATS: Record<string, IReportFormat> = {
  154. markdown: {
  155. id: 'markdown',
  156. title: 'Markdown',
  157. icon: markdownIcon
  158. },
  159. csv: {
  160. id: 'csv',
  161. title: 'CSV',
  162. icon: spreadsheetIcon
  163. },
  164. json: {
  165. id: 'csv',
  166. title: 'JSON',
  167. icon: jsonIcon
  168. }
  169. };
  170. /**
  171. * The default format (most human-readable)
  172. */
  173. export const DEFAULT_FORMAT = 'markdown';
  174. /**
  175. * Options for instantiating a license viewer
  176. */
  177. export interface IOptions {
  178. model: Model;
  179. }
  180. /**
  181. * Options for instantiating a license model
  182. */
  183. export interface IModelOptions extends ICreateArgs {
  184. licensesUrl: string;
  185. serverSettings?: ServerConnection.ISettings;
  186. trans: TranslationBundle;
  187. }
  188. /**
  189. * The JSON response from the API
  190. */
  191. export interface ILicenseResponse {
  192. bundles: {
  193. [key: string]: ILicenseBundle;
  194. };
  195. }
  196. /**
  197. * A top-level report of the licenses for all code included in a bundle
  198. *
  199. * ### Note
  200. *
  201. * This is roughly informed by the terms defined in the SPDX spec, though is not
  202. * an SPDX Document, since there seem to be several (incompatible) specs
  203. * in that repo.
  204. *
  205. * @see https://github.com/spdx/spdx-spec/blob/development/v2.2.1/schemas/spdx-schema.json
  206. **/
  207. export interface ILicenseBundle extends ReadonlyJSONObject {
  208. packages: IPackageLicenseInfo[];
  209. }
  210. /**
  211. * A best-effort single bundled package's information.
  212. *
  213. * ### Note
  214. *
  215. * This is roughly informed by SPDX `packages` and `hasExtractedLicenseInfos`,
  216. * as making it conformant would vastly complicate the structure.
  217. *
  218. * @see https://github.com/spdx/spdx-spec/blob/development/v2.2.1/schemas/spdx-schema.json
  219. **/
  220. export interface IPackageLicenseInfo extends ReadonlyJSONObject {
  221. /**
  222. * the name of the package as it appears in package.json
  223. */
  224. name: string;
  225. /**
  226. * the version of the package, or an empty string if unknown
  227. */
  228. versionInfo: string;
  229. /**
  230. * an SPDX license identifier or LicenseRef, or an empty string if unknown
  231. */
  232. licenseId: string;
  233. /**
  234. * the verbatim extracted text of the license, or an empty string if unknown
  235. */
  236. extractedText: string;
  237. }
  238. /**
  239. * The format information for a download
  240. */
  241. export interface IDownloadOptions {
  242. format: string;
  243. }
  244. /**
  245. * The fields which can be filtered
  246. */
  247. export type TFilterKey = 'name' | 'versionInfo' | 'licenseId';
  248. export interface ICreateArgs {
  249. currentBundleName?: string | null;
  250. packageFilter?: Partial<IPackageLicenseInfo> | null;
  251. currentPackageIndex?: number | null;
  252. }
  253. /**
  254. * A model for license data
  255. */
  256. export class Model extends VDomModel implements ICreateArgs {
  257. constructor(options: IModelOptions) {
  258. super();
  259. this._trans = options.trans;
  260. this._licensesUrl = options.licensesUrl;
  261. this._serverSettings =
  262. options.serverSettings || ServerConnection.makeSettings();
  263. if (options.currentBundleName) {
  264. this._currentBundleName = options.currentBundleName;
  265. }
  266. if (options.packageFilter) {
  267. this._packageFilter = options.packageFilter;
  268. }
  269. if (options.currentPackageIndex) {
  270. this._currentPackageIndex = options.currentPackageIndex;
  271. }
  272. }
  273. /**
  274. * Handle the initial request for the licenses from the server.
  275. */
  276. async initLicenses(): Promise<void> {
  277. try {
  278. const response = await ServerConnection.makeRequest(
  279. this._licensesUrl,
  280. {},
  281. this._serverSettings
  282. );
  283. this._serverResponse = await response.json();
  284. this._licensesReady.resolve();
  285. this.stateChanged.emit(void 0);
  286. } catch (err) {
  287. this._licensesReady.reject(err);
  288. }
  289. }
  290. /**
  291. * Create a temporary download link, and emulate clicking it to trigger a named
  292. * file download.
  293. */
  294. async download(options: IDownloadOptions): Promise<void> {
  295. const url = `${this._licensesUrl}?format=${options.format}&download=1`;
  296. const element = document.createElement('a');
  297. element.href = url;
  298. element.download = '';
  299. document.body.appendChild(element);
  300. element.click();
  301. document.body.removeChild(element);
  302. return void 0;
  303. }
  304. /**
  305. * A promise that resolves when the licenses from the server change
  306. */
  307. get selectedPackageChanged(): ISignal<Model, void> {
  308. return this._selectedPackageChanged;
  309. }
  310. /**
  311. * A promise that resolves when the trackable data changes
  312. */
  313. get trackerDataChanged(): ISignal<Model, void> {
  314. return this._trackerDataChanged;
  315. }
  316. /**
  317. * The names of the license bundles available
  318. */
  319. get bundleNames(): string[] {
  320. return Object.keys(this._serverResponse?.bundles || {});
  321. }
  322. /**
  323. * The current license bundle
  324. */
  325. get currentBundleName(): string | null {
  326. if (this._currentBundleName) {
  327. return this._currentBundleName;
  328. }
  329. if (this.bundleNames.length) {
  330. return this.bundleNames[0];
  331. }
  332. return null;
  333. }
  334. /**
  335. * Set the current license bundle, and reset the selected index
  336. */
  337. set currentBundleName(currentBundleName: string | null) {
  338. if (this._currentBundleName !== currentBundleName) {
  339. this._currentBundleName = currentBundleName;
  340. this.stateChanged.emit(void 0);
  341. this._trackerDataChanged.emit(void 0);
  342. }
  343. }
  344. /**
  345. * A promise that resolves when the licenses are available from the server
  346. */
  347. get licensesReady(): Promise<void> {
  348. return this._licensesReady.promise;
  349. }
  350. /**
  351. * All the license bundles, keyed by the distributing packages
  352. */
  353. get bundles(): null | { [key: string]: ILicenseBundle } {
  354. return this._serverResponse?.bundles || {};
  355. }
  356. /**
  357. * The index of the currently-selected package within its license bundle
  358. */
  359. get currentPackageIndex(): number | null {
  360. return this._currentPackageIndex;
  361. }
  362. /**
  363. * Update the currently-selected package within its license bundle
  364. */
  365. set currentPackageIndex(currentPackageIndex: number | null) {
  366. if (this._currentPackageIndex === currentPackageIndex) {
  367. return;
  368. }
  369. this._currentPackageIndex = currentPackageIndex;
  370. this._selectedPackageChanged.emit(void 0);
  371. this.stateChanged.emit(void 0);
  372. this._trackerDataChanged.emit(void 0);
  373. }
  374. /**
  375. * The license data for the currently-selected package
  376. */
  377. get currentPackage(): IPackageLicenseInfo | null {
  378. if (
  379. this.currentBundleName &&
  380. this.bundles &&
  381. this._currentPackageIndex != null
  382. ) {
  383. return this.getFilteredPackages(
  384. this.bundles[this.currentBundleName]?.packages || []
  385. )[this._currentPackageIndex];
  386. }
  387. return null;
  388. }
  389. /**
  390. * A translation bundle
  391. */
  392. get trans(): TranslationBundle {
  393. return this._trans;
  394. }
  395. get title(): string {
  396. return `${this._currentBundleName || ''} ${this._trans.__(
  397. 'Licenses'
  398. )}`.trim();
  399. }
  400. /**
  401. * The current package filter
  402. */
  403. get packageFilter(): Partial<IPackageLicenseInfo> {
  404. return this._packageFilter;
  405. }
  406. set packageFilter(packageFilter: Partial<IPackageLicenseInfo>) {
  407. this._packageFilter = packageFilter;
  408. this.stateChanged.emit(void 0);
  409. this._trackerDataChanged.emit(void 0);
  410. }
  411. /**
  412. * Get filtered packages from current bundle where at least one token of each
  413. * key is present.
  414. */
  415. getFilteredPackages(allRows: IPackageLicenseInfo[]): IPackageLicenseInfo[] {
  416. let rows: IPackageLicenseInfo[] = [];
  417. let filters: [string, string[]][] = Object.entries(this._packageFilter)
  418. .filter(([k, v]) => v && `${v}`.trim().length)
  419. .map(([k, v]) => [k, `${v}`.toLowerCase().trim().split(' ')]);
  420. for (const row of allRows) {
  421. let keyHits = 0;
  422. for (const [key, bits] of filters) {
  423. let bitHits = 0;
  424. let rowKeyValue = `${row[key]}`.toLowerCase();
  425. for (const bit of bits) {
  426. if (rowKeyValue.includes(bit)) {
  427. bitHits += 1;
  428. }
  429. }
  430. if (bitHits) {
  431. keyHits += 1;
  432. }
  433. }
  434. if (keyHits === filters.length) {
  435. rows.push(row);
  436. }
  437. }
  438. return Object.values(rows);
  439. }
  440. private _selectedPackageChanged: Signal<Model, void> = new Signal(this);
  441. private _trackerDataChanged: Signal<Model, void> = new Signal(this);
  442. private _serverResponse: ILicenseResponse | null;
  443. private _licensesUrl: string;
  444. private _serverSettings: ServerConnection.ISettings;
  445. private _currentBundleName: string | null;
  446. private _trans: TranslationBundle;
  447. private _currentPackageIndex: number | null = 0;
  448. private _licensesReady = new PromiseDelegate<void>();
  449. private _packageFilter: Partial<IPackageLicenseInfo> = {};
  450. }
  451. /**
  452. * A filter form for limiting the packages displayed
  453. */
  454. export class Filters extends VDomRenderer<Model> {
  455. constructor(model: Model) {
  456. super(model);
  457. this.addClass('jp-Licenses-Filters');
  458. this.addClass('jp-RenderedHTMLCommon');
  459. }
  460. protected render(): JSX.Element {
  461. const { trans } = this.model;
  462. return (
  463. <div>
  464. <label>
  465. <strong>{trans.__('Filter Licenses By')}</strong>
  466. </label>
  467. <ul>
  468. <li>
  469. <label>{trans.__('Package')}</label>
  470. {this.renderFilter('name')}
  471. </li>
  472. <li>
  473. <label>{trans.__('Version')}</label>
  474. {this.renderFilter('versionInfo')}
  475. </li>
  476. <li>
  477. <label>{trans.__('License')}</label>
  478. {this.renderFilter('licenseId')}
  479. </li>
  480. </ul>
  481. <label>
  482. <strong>{trans.__('Distributions')}</strong>
  483. </label>
  484. </div>
  485. );
  486. }
  487. /**
  488. * Render a filter input
  489. */
  490. protected renderFilter = (key: TFilterKey): JSX.Element => {
  491. const value = this.model.packageFilter[key] || '';
  492. return (
  493. <input
  494. type="text"
  495. name={key}
  496. defaultValue={value}
  497. className="jp-mod-styled"
  498. onInput={this.onFilterInput}
  499. />
  500. );
  501. };
  502. /**
  503. * Handle a filter input changing
  504. */
  505. protected onFilterInput = (
  506. evt: React.ChangeEvent<HTMLInputElement>
  507. ): void => {
  508. const input = evt.currentTarget;
  509. const { name, value } = input;
  510. this.model.packageFilter = { ...this.model.packageFilter, [name]: value };
  511. };
  512. }
  513. /**
  514. * A fancy bundle renderer with the package count
  515. */
  516. export class BundleTabRenderer extends TabBar.Renderer {
  517. /**
  518. * A model of the state of license viewing as well as the underlying data
  519. */
  520. model: Model;
  521. readonly closeIconSelector = '.lm-TabBar-tabCloseIcon';
  522. constructor(model: Model) {
  523. super();
  524. this.model = model;
  525. }
  526. /**
  527. * Render a full bundle
  528. */
  529. renderTab(data: TabBar.IRenderData<Widget>): VirtualElement {
  530. let title = data.title.caption;
  531. let key = this.createTabKey(data);
  532. let style = this.createTabStyle(data);
  533. let className = this.createTabClass(data);
  534. let dataset = this.createTabDataset(data);
  535. return h.li(
  536. { key, className, title, style, dataset },
  537. this.renderIcon(data),
  538. this.renderLabel(data),
  539. this.renderCountBadge(data)
  540. );
  541. }
  542. /**
  543. * Render the package count
  544. */
  545. renderCountBadge(data: TabBar.IRenderData<Widget>): VirtualElement {
  546. const bundle = data.title.label;
  547. const { bundles } = this.model;
  548. const packages = this.model.getFilteredPackages(
  549. (bundles && bundle ? bundles[bundle].packages : []) || []
  550. );
  551. return h.label({}, `${packages.length}`);
  552. }
  553. }
  554. /**
  555. * A grid of licenses
  556. */
  557. export class Grid extends VDomRenderer<Licenses.Model> {
  558. constructor(model: Licenses.Model) {
  559. super(model);
  560. this.addClass('jp-Licenses-Grid');
  561. this.addClass('jp-RenderedHTMLCommon');
  562. }
  563. /**
  564. * Render a grid of package license information
  565. */
  566. protected render(): JSX.Element {
  567. const { bundles, currentBundleName, trans } = this.model;
  568. const filteredPackages = this.model.getFilteredPackages(
  569. bundles && currentBundleName
  570. ? bundles[currentBundleName]?.packages || []
  571. : []
  572. );
  573. if (!filteredPackages.length) {
  574. return (
  575. <blockquote>
  576. <em>{trans.__('No Packages found')}</em>
  577. </blockquote>
  578. );
  579. }
  580. return (
  581. <form>
  582. <table>
  583. <thead>
  584. <tr>
  585. <td></td>
  586. <th>{trans.__('Package')}</th>
  587. <th>{trans.__('Version')}</th>
  588. <th>{trans.__('License')}</th>
  589. </tr>
  590. </thead>
  591. <tbody>{filteredPackages.map(this.renderRow)}</tbody>
  592. </table>
  593. </form>
  594. );
  595. }
  596. /**
  597. * Render a single package's license information
  598. */
  599. protected renderRow = (
  600. row: Licenses.IPackageLicenseInfo,
  601. index: number
  602. ): JSX.Element => {
  603. const selected = index === this.model.currentPackageIndex;
  604. const onCheck = () => (this.model.currentPackageIndex = index);
  605. return (
  606. <tr
  607. key={row.name}
  608. className={selected ? 'jp-mod-selected' : ''}
  609. onClick={onCheck}
  610. >
  611. <td>
  612. <input
  613. type="radio"
  614. name="show-package-license"
  615. value={index}
  616. onChange={onCheck}
  617. checked={selected}
  618. />
  619. </td>
  620. <th>{row.name}</th>
  621. <td>
  622. <code>{row.versionInfo}</code>
  623. </td>
  624. <td>
  625. <code>{row.licenseId}</code>
  626. </td>
  627. </tr>
  628. );
  629. };
  630. }
  631. /**
  632. * A package's full license text
  633. */
  634. export class FullText extends VDomRenderer<Model> {
  635. constructor(model: Model) {
  636. super(model);
  637. this.addClass('jp-Licenses-Text');
  638. this.addClass('jp-RenderedHTMLCommon');
  639. this.addClass('jp-RenderedMarkdown');
  640. }
  641. /**
  642. * Render the license text, or a null state if no package is selected
  643. */
  644. protected render(): JSX.Element[] {
  645. const { currentPackage, trans } = this.model;
  646. let head = '';
  647. let quote = trans.__('No Package selected');
  648. let code = '';
  649. if (currentPackage) {
  650. const { name, versionInfo, licenseId, extractedText } = currentPackage;
  651. head = `${name} v${versionInfo}`;
  652. quote = `${trans.__('License')}: ${
  653. licenseId || trans.__('No License ID found')
  654. }`;
  655. code = extractedText || trans.__('No License Text found');
  656. }
  657. return [
  658. <h1 key="h1">{head}</h1>,
  659. <blockquote key="quote">
  660. <em>{quote}</em>
  661. </blockquote>,
  662. <code key="code">{code}</code>
  663. ];
  664. }
  665. }
  666. }