index.ts 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736
  1. // Copyright (c) Jupyter Development Team.
  2. // Distributed under the terms of the Modified BSD License.
  3. import { some, map, each } from '@phosphor/algorithm';
  4. import { Widget } from '@phosphor/widgets';
  5. import {
  6. ILabShell,
  7. ILabStatus,
  8. JupyterFrontEnd,
  9. JupyterFrontEndPlugin
  10. } from '@jupyterlab/application';
  11. import {
  12. showDialog,
  13. showErrorMessage,
  14. Dialog,
  15. ICommandPalette
  16. } from '@jupyterlab/apputils';
  17. import { IChangedArgs, ISettingRegistry, Time } from '@jupyterlab/coreutils';
  18. import {
  19. renameDialog,
  20. getOpenPath,
  21. DocumentManager,
  22. IDocumentManager,
  23. PathStatus,
  24. SavingStatus
  25. } from '@jupyterlab/docmanager';
  26. import { DocumentRegistry } from '@jupyterlab/docregistry';
  27. import { IMainMenu } from '@jupyterlab/mainmenu';
  28. import { Contents, Kernel } from '@jupyterlab/services';
  29. import { IStatusBar } from '@jupyterlab/statusbar';
  30. import { IDisposable } from '@phosphor/disposable';
  31. /**
  32. * The command IDs used by the document manager plugin.
  33. */
  34. namespace CommandIDs {
  35. export const clone = 'docmanager:clone';
  36. export const deleteFile = 'docmanager:delete-file';
  37. export const newUntitled = 'docmanager:new-untitled';
  38. export const open = 'docmanager:open';
  39. export const openBrowserTab = 'docmanager:open-browser-tab';
  40. export const openDirect = 'docmanager:open-direct';
  41. export const reload = 'docmanager:reload';
  42. export const rename = 'docmanager:rename';
  43. export const restoreCheckpoint = 'docmanager:restore-checkpoint';
  44. export const save = 'docmanager:save';
  45. export const saveAll = 'docmanager:save-all';
  46. export const saveAs = 'docmanager:save-as';
  47. export const toggleAutosave = 'docmanager:toggle-autosave';
  48. export const showInFileBrowser = 'docmanager:show-in-file-browser';
  49. }
  50. const pluginId = '@jupyterlab/docmanager-extension:plugin';
  51. /**
  52. * The default document manager provider.
  53. */
  54. const docManagerPlugin: JupyterFrontEndPlugin<IDocumentManager> = {
  55. id: pluginId,
  56. provides: IDocumentManager,
  57. requires: [ISettingRegistry],
  58. optional: [ILabStatus, ICommandPalette, ILabShell, IMainMenu],
  59. activate: (
  60. app: JupyterFrontEnd,
  61. settingRegistry: ISettingRegistry,
  62. status: ILabStatus | null,
  63. palette: ICommandPalette | null,
  64. labShell: ILabShell | null,
  65. mainMenu: IMainMenu | null
  66. ): IDocumentManager => {
  67. const { shell } = app;
  68. const manager = app.serviceManager;
  69. const contexts = new WeakSet<DocumentRegistry.Context>();
  70. const opener: DocumentManager.IWidgetOpener = {
  71. open: (widget, options) => {
  72. if (!widget.id) {
  73. widget.id = `document-manager-${++Private.id}`;
  74. }
  75. widget.title.dataset = {
  76. type: 'document-title',
  77. ...widget.title.dataset
  78. };
  79. if (!widget.isAttached) {
  80. shell.add(widget, 'main', options || {});
  81. }
  82. shell.activateById(widget.id);
  83. // Handle dirty state for open documents.
  84. let context = docManager.contextForWidget(widget);
  85. if (!contexts.has(context)) {
  86. handleContext(status, context);
  87. contexts.add(context);
  88. }
  89. }
  90. };
  91. const registry = app.docRegistry;
  92. const when = app.restored.then(() => void 0);
  93. const docManager = new DocumentManager({
  94. registry,
  95. manager,
  96. opener,
  97. when,
  98. setBusy: status.setBusy.bind(app)
  99. });
  100. // Register the file operations commands.
  101. addCommands(
  102. app,
  103. docManager,
  104. opener,
  105. settingRegistry,
  106. labShell,
  107. palette,
  108. mainMenu
  109. );
  110. // Keep up to date with the settings registry.
  111. const onSettingsUpdated = (settings: ISettingRegistry.ISettings) => {
  112. const autosave = settings.get('autosave').composite as boolean | null;
  113. docManager.autosave =
  114. autosave === true || autosave === false ? autosave : true;
  115. app.commands.notifyCommandChanged(CommandIDs.toggleAutosave);
  116. const autosaveInterval = settings.get('autosaveInterval').composite as
  117. | number
  118. | null;
  119. docManager.autosaveInterval = autosaveInterval || 120;
  120. };
  121. // Fetch the initial state of the settings.
  122. Promise.all([settingRegistry.load(pluginId), app.restored])
  123. .then(([settings]) => {
  124. settings.changed.connect(onSettingsUpdated);
  125. onSettingsUpdated(settings);
  126. })
  127. .catch((reason: Error) => {
  128. console.error(reason.message);
  129. });
  130. return docManager;
  131. }
  132. };
  133. /**
  134. * A plugin for adding a saving status item to the status bar.
  135. */
  136. export const savingStatusPlugin: JupyterFrontEndPlugin<void> = {
  137. id: '@jupyterlab/docmanager-extension:saving-status',
  138. autoStart: true,
  139. requires: [IStatusBar, IDocumentManager, ILabShell],
  140. activate: (
  141. _: JupyterFrontEnd,
  142. statusBar: IStatusBar,
  143. docManager: IDocumentManager,
  144. labShell: ILabShell
  145. ) => {
  146. const saving = new SavingStatus({ docManager });
  147. // Keep the currently active widget synchronized.
  148. saving.model!.widget = labShell.currentWidget;
  149. labShell.currentChanged.connect(() => {
  150. saving.model!.widget = labShell.currentWidget;
  151. });
  152. statusBar.registerStatusItem(savingStatusPlugin.id, {
  153. item: saving,
  154. align: 'middle',
  155. isActive: () => true,
  156. activeStateChanged: saving.model!.stateChanged
  157. });
  158. }
  159. };
  160. /**
  161. * A plugin providing a file path widget to the status bar.
  162. */
  163. export const pathStatusPlugin: JupyterFrontEndPlugin<void> = {
  164. id: '@jupyterlab/docmanager-extension:path-status',
  165. autoStart: true,
  166. requires: [IStatusBar, IDocumentManager, ILabShell],
  167. activate: (
  168. _: JupyterFrontEnd,
  169. statusBar: IStatusBar,
  170. docManager: IDocumentManager,
  171. labShell: ILabShell
  172. ) => {
  173. const path = new PathStatus({ docManager });
  174. // Keep the file path widget up-to-date with the application active widget.
  175. path.model!.widget = labShell.currentWidget;
  176. labShell.currentChanged.connect(() => {
  177. path.model!.widget = labShell.currentWidget;
  178. });
  179. statusBar.registerStatusItem(pathStatusPlugin.id, {
  180. item: path,
  181. align: 'right',
  182. rank: 0,
  183. isActive: () => true
  184. });
  185. }
  186. };
  187. /**
  188. * Export the plugins as default.
  189. */
  190. const plugins: JupyterFrontEndPlugin<any>[] = [
  191. docManagerPlugin,
  192. pathStatusPlugin,
  193. savingStatusPlugin
  194. ];
  195. export default plugins;
  196. /* Widget to display the revert to checkpoint confirmation. */
  197. class RevertConfirmWidget extends Widget {
  198. /**
  199. * Construct a new revert confirmation widget.
  200. */
  201. constructor(
  202. checkpoint: Contents.ICheckpointModel,
  203. fileType: string = 'notebook'
  204. ) {
  205. super({ node: Private.createRevertConfirmNode(checkpoint, fileType) });
  206. }
  207. }
  208. // Returns the file type for a widget.
  209. function fileType(widget: Widget, docManager: IDocumentManager): string {
  210. if (!widget) {
  211. return 'File';
  212. }
  213. const context = docManager.contextForWidget(widget);
  214. if (!context) {
  215. return '';
  216. }
  217. const fts = docManager.registry.getFileTypesForPath(context.path);
  218. return fts.length && fts[0].displayName ? fts[0].displayName : 'File';
  219. }
  220. /**
  221. * Add the file operations commands to the application's command registry.
  222. */
  223. function addCommands(
  224. app: JupyterFrontEnd,
  225. docManager: IDocumentManager,
  226. opener: DocumentManager.IWidgetOpener,
  227. settingRegistry: ISettingRegistry,
  228. labShell: ILabShell | null,
  229. palette: ICommandPalette | null,
  230. mainMenu: IMainMenu | null
  231. ): void {
  232. const { commands, shell } = app;
  233. const category = 'File Operations';
  234. const isEnabled = () => {
  235. const { currentWidget } = shell;
  236. return !!(currentWidget && docManager.contextForWidget(currentWidget));
  237. };
  238. const isWritable = () => {
  239. const { currentWidget } = shell;
  240. if (!currentWidget) {
  241. return false;
  242. }
  243. const context = docManager.contextForWidget(currentWidget);
  244. return !!(
  245. context &&
  246. context.contentsModel &&
  247. context.contentsModel.writable
  248. );
  249. };
  250. // If inside a rich application like JupyterLab, add additional functionality.
  251. if (labShell) {
  252. addLabCommands(app, docManager, labShell, opener, palette);
  253. }
  254. commands.addCommand(CommandIDs.deleteFile, {
  255. label: () => `Delete ${fileType(shell.currentWidget, docManager)}`,
  256. execute: args => {
  257. const path =
  258. typeof args['path'] === 'undefined' ? '' : (args['path'] as string);
  259. if (!path) {
  260. const command = CommandIDs.deleteFile;
  261. throw new Error(`A non-empty path is required for ${command}.`);
  262. }
  263. return docManager.deleteFile(path);
  264. }
  265. });
  266. commands.addCommand(CommandIDs.newUntitled, {
  267. execute: args => {
  268. const errorTitle = (args['error'] as string) || 'Error';
  269. const path =
  270. typeof args['path'] === 'undefined' ? '' : (args['path'] as string);
  271. let options: Partial<Contents.ICreateOptions> = {
  272. type: args['type'] as Contents.ContentType,
  273. path
  274. };
  275. if (args['type'] === 'file') {
  276. options.ext = (args['ext'] as string) || '.txt';
  277. }
  278. return docManager.services.contents
  279. .newUntitled(options)
  280. .catch(error => showErrorMessage(errorTitle, error));
  281. },
  282. label: args => (args['label'] as string) || `New ${args['type'] as string}`
  283. });
  284. commands.addCommand(CommandIDs.open, {
  285. execute: args => {
  286. const path =
  287. typeof args['path'] === 'undefined' ? '' : (args['path'] as string);
  288. const factory = (args['factory'] as string) || void 0;
  289. const kernel = (args['kernel'] as Kernel.IModel) || void 0;
  290. const options =
  291. (args['options'] as DocumentRegistry.IOpenOptions) || void 0;
  292. return docManager.services.contents
  293. .get(path, { content: false })
  294. .then(() => docManager.openOrReveal(path, factory, kernel, options));
  295. },
  296. icon: args => (args['icon'] as string) || '',
  297. label: args => (args['label'] || args['factory']) as string,
  298. mnemonic: args => (args['mnemonic'] as number) || -1
  299. });
  300. commands.addCommand(CommandIDs.openBrowserTab, {
  301. execute: args => {
  302. const path =
  303. typeof args['path'] === 'undefined' ? '' : (args['path'] as string);
  304. if (!path) {
  305. return;
  306. }
  307. return docManager.services.contents.getDownloadUrl(path).then(url => {
  308. const opened = window.open();
  309. opened.opener = null;
  310. opened.location.href = url;
  311. });
  312. },
  313. icon: args => (args['icon'] as string) || '',
  314. label: () => 'Open in New Browser Tab'
  315. });
  316. commands.addCommand(CommandIDs.openDirect, {
  317. label: () => 'Open From Path...',
  318. caption: 'Open from path',
  319. isEnabled: () => true,
  320. execute: () => {
  321. return getOpenPath(docManager.services.contents).then(path => {
  322. if (!path) {
  323. return;
  324. }
  325. docManager.services.contents.get(path, { content: false }).then(
  326. args => {
  327. // exists
  328. return commands.execute(CommandIDs.open, { path: path });
  329. },
  330. () => {
  331. // does not exist
  332. return showDialog({
  333. title: 'Cannot open',
  334. body: 'File not found',
  335. buttons: [Dialog.okButton()]
  336. });
  337. }
  338. );
  339. return;
  340. });
  341. }
  342. });
  343. commands.addCommand(CommandIDs.reload, {
  344. label: () =>
  345. `Reload ${fileType(shell.currentWidget, docManager)} from Disk`,
  346. caption: 'Reload contents from disk',
  347. isEnabled,
  348. execute: () => {
  349. if (!isEnabled()) {
  350. return;
  351. }
  352. const context = docManager.contextForWidget(shell.currentWidget);
  353. const type = fileType(shell.currentWidget, docManager);
  354. return showDialog({
  355. title: `Reload ${type} from Disk`,
  356. body: `Are you sure you want to reload
  357. the ${type} from the disk?`,
  358. buttons: [Dialog.cancelButton(), Dialog.warnButton({ label: 'Reload' })]
  359. }).then(result => {
  360. if (result.button.accept && !context.isDisposed) {
  361. return context.revert();
  362. }
  363. });
  364. }
  365. });
  366. commands.addCommand(CommandIDs.restoreCheckpoint, {
  367. label: () =>
  368. `Revert ${fileType(shell.currentWidget, docManager)} to Checkpoint`,
  369. caption: 'Revert contents to previous checkpoint',
  370. isEnabled,
  371. execute: () => {
  372. if (!isEnabled()) {
  373. return;
  374. }
  375. const context = docManager.contextForWidget(shell.currentWidget);
  376. return context.listCheckpoints().then(checkpoints => {
  377. if (checkpoints.length < 1) {
  378. return;
  379. }
  380. const lastCheckpoint = checkpoints[checkpoints.length - 1];
  381. if (!lastCheckpoint) {
  382. return;
  383. }
  384. const type = fileType(shell.currentWidget, docManager);
  385. return showDialog({
  386. title: `Revert ${type} to checkpoint`,
  387. body: new RevertConfirmWidget(lastCheckpoint, type),
  388. buttons: [
  389. Dialog.cancelButton(),
  390. Dialog.warnButton({ label: 'Revert' })
  391. ]
  392. }).then(result => {
  393. if (context.isDisposed) {
  394. return;
  395. }
  396. if (result.button.accept) {
  397. if (context.model.readOnly) {
  398. return context.revert();
  399. }
  400. return context.restoreCheckpoint().then(() => context.revert());
  401. }
  402. });
  403. });
  404. }
  405. });
  406. commands.addCommand(CommandIDs.save, {
  407. label: () => `Save ${fileType(shell.currentWidget, docManager)}`,
  408. caption: 'Save and create checkpoint',
  409. isEnabled: isWritable,
  410. execute: () => {
  411. if (isEnabled()) {
  412. let context = docManager.contextForWidget(shell.currentWidget);
  413. if (context.model.readOnly) {
  414. return showDialog({
  415. title: 'Cannot Save',
  416. body: 'Document is read-only',
  417. buttons: [Dialog.okButton()]
  418. });
  419. }
  420. return context
  421. .save()
  422. .then(() => context.createCheckpoint())
  423. .catch(err => {
  424. // If the save was canceled by user-action, do nothing.
  425. if (err.message === 'Cancel') {
  426. return;
  427. }
  428. throw err;
  429. });
  430. }
  431. }
  432. });
  433. commands.addCommand(CommandIDs.saveAll, {
  434. label: () => 'Save All',
  435. caption: 'Save all open documents',
  436. isEnabled: () => {
  437. return some(
  438. map(shell.widgets('main'), w => docManager.contextForWidget(w)),
  439. c => c && c.contentsModel && c.contentsModel.writable
  440. );
  441. },
  442. execute: () => {
  443. const promises: Promise<void>[] = [];
  444. const paths = new Set<string>(); // Cache so we don't double save files.
  445. each(shell.widgets('main'), widget => {
  446. const context = docManager.contextForWidget(widget);
  447. if (context && !context.model.readOnly && !paths.has(context.path)) {
  448. paths.add(context.path);
  449. promises.push(context.save());
  450. }
  451. });
  452. return Promise.all(promises);
  453. }
  454. });
  455. commands.addCommand(CommandIDs.saveAs, {
  456. label: () => `Save ${fileType(shell.currentWidget, docManager)} As…`,
  457. caption: 'Save with new path',
  458. isEnabled,
  459. execute: () => {
  460. if (isEnabled()) {
  461. let context = docManager.contextForWidget(shell.currentWidget);
  462. return context.saveAs();
  463. }
  464. }
  465. });
  466. commands.addCommand(CommandIDs.toggleAutosave, {
  467. label: 'Autosave Documents',
  468. isToggled: () => docManager.autosave,
  469. execute: () => {
  470. const value = !docManager.autosave;
  471. const key = 'autosave';
  472. return settingRegistry
  473. .set(pluginId, key, value)
  474. .catch((reason: Error) => {
  475. console.error(`Failed to set ${pluginId}:${key} - ${reason.message}`);
  476. });
  477. }
  478. });
  479. // .jp-mod-current added so that the console-creation command is only shown
  480. // on the current document.
  481. // Otherwise it will delegate to the wrong widget.
  482. app.contextMenu.addItem({
  483. command: 'filemenu:create-console',
  484. selector: '[data-type="document-title"].jp-mod-current',
  485. rank: 6
  486. });
  487. if (palette) {
  488. [
  489. CommandIDs.openDirect,
  490. CommandIDs.reload,
  491. CommandIDs.restoreCheckpoint,
  492. CommandIDs.save,
  493. CommandIDs.saveAs,
  494. CommandIDs.toggleAutosave
  495. ].forEach(command => {
  496. palette.addItem({ command, category });
  497. });
  498. }
  499. if (mainMenu) {
  500. mainMenu.settingsMenu.addGroup([{ command: CommandIDs.toggleAutosave }], 5);
  501. }
  502. }
  503. function addLabCommands(
  504. app: JupyterFrontEnd,
  505. docManager: IDocumentManager,
  506. labShell: ILabShell,
  507. opener: DocumentManager.IWidgetOpener,
  508. palette: ICommandPalette | null
  509. ): void {
  510. const { commands } = app;
  511. // Returns the doc widget associated with the most recent contextmenu event.
  512. const contextMenuWidget = (): Widget => {
  513. const pathRe = /[Pp]ath:\s?(.*)\n?/;
  514. const test = (node: HTMLElement) =>
  515. node['title'] && !!node['title'].match(pathRe);
  516. const node = app.contextMenuHitTest(test);
  517. if (!node) {
  518. // Fall back to active doc widget if path cannot be obtained from event.
  519. return labShell.currentWidget;
  520. }
  521. const pathMatch = node['title'].match(pathRe);
  522. return docManager.findWidget(pathMatch[1]);
  523. };
  524. // Returns `true` if the current widget has a document context.
  525. const isEnabled = () => {
  526. const { currentWidget } = labShell;
  527. return !!(currentWidget && docManager.contextForWidget(currentWidget));
  528. };
  529. commands.addCommand(CommandIDs.clone, {
  530. label: () => `New View for ${fileType(contextMenuWidget(), docManager)}`,
  531. isEnabled,
  532. execute: args => {
  533. const widget = contextMenuWidget();
  534. const options = (args['options'] as DocumentRegistry.IOpenOptions) || {
  535. mode: 'split-right'
  536. };
  537. if (!widget) {
  538. return;
  539. }
  540. // Clone the widget.
  541. let child = docManager.cloneWidget(widget);
  542. if (child) {
  543. opener.open(child, options);
  544. }
  545. }
  546. });
  547. commands.addCommand(CommandIDs.rename, {
  548. label: () => `Rename ${fileType(contextMenuWidget(), docManager)}…`,
  549. isEnabled,
  550. execute: () => {
  551. if (isEnabled()) {
  552. let context = docManager.contextForWidget(contextMenuWidget());
  553. return renameDialog(docManager, context!.path);
  554. }
  555. }
  556. });
  557. commands.addCommand(CommandIDs.showInFileBrowser, {
  558. label: () => `Show in File Browser`,
  559. isEnabled,
  560. execute: async () => {
  561. let context = docManager.contextForWidget(contextMenuWidget());
  562. if (!context) {
  563. return;
  564. }
  565. // 'activate' is needed if this command is selected in the "open tabs" sidebar
  566. await commands.execute('filebrowser:activate', { path: context.path });
  567. await commands.execute('filebrowser:navigate', { path: context.path });
  568. }
  569. });
  570. app.contextMenu.addItem({
  571. command: CommandIDs.rename,
  572. selector: '[data-type="document-title"]',
  573. rank: 1
  574. });
  575. app.contextMenu.addItem({
  576. command: CommandIDs.clone,
  577. selector: '[data-type="document-title"]',
  578. rank: 2
  579. });
  580. app.contextMenu.addItem({
  581. command: CommandIDs.showInFileBrowser,
  582. selector: '[data-type="document-title"]',
  583. rank: 3
  584. });
  585. }
  586. /**
  587. * Handle dirty state for a context.
  588. */
  589. function handleContext(
  590. status: ILabStatus,
  591. context: DocumentRegistry.Context
  592. ): void {
  593. let disposable: IDisposable | null = null;
  594. let onStateChanged = (sender: any, args: IChangedArgs<any>) => {
  595. if (args.name === 'dirty') {
  596. if (args.newValue === true) {
  597. if (!disposable) {
  598. disposable = status.setDirty();
  599. }
  600. } else if (disposable) {
  601. disposable.dispose();
  602. disposable = null;
  603. }
  604. }
  605. };
  606. void context.ready.then(() => {
  607. context.model.stateChanged.connect(onStateChanged);
  608. if (context.model.dirty) {
  609. disposable = status.setDirty();
  610. }
  611. });
  612. context.disposed.connect(() => {
  613. if (disposable) {
  614. disposable.dispose();
  615. }
  616. });
  617. }
  618. /**
  619. * A namespace for private module data.
  620. */
  621. namespace Private {
  622. /**
  623. * A counter for unique IDs.
  624. */
  625. export let id = 0;
  626. export function createRevertConfirmNode(
  627. checkpoint: Contents.ICheckpointModel,
  628. fileType: string
  629. ): HTMLElement {
  630. let body = document.createElement('div');
  631. let confirmMessage = document.createElement('p');
  632. let confirmText = document.createTextNode(`Are you sure you want to revert
  633. the ${fileType} to the latest checkpoint? `);
  634. let cannotUndoText = document.createElement('strong');
  635. cannotUndoText.textContent = 'This cannot be undone.';
  636. confirmMessage.appendChild(confirmText);
  637. confirmMessage.appendChild(cannotUndoText);
  638. let lastCheckpointMessage = document.createElement('p');
  639. let lastCheckpointText = document.createTextNode(
  640. 'The checkpoint was last updated at: '
  641. );
  642. let lastCheckpointDate = document.createElement('p');
  643. let date = new Date(checkpoint.last_modified);
  644. lastCheckpointDate.style.textAlign = 'center';
  645. lastCheckpointDate.textContent =
  646. Time.format(date, 'dddd, MMMM Do YYYY, h:mm:ss a') +
  647. ' (' +
  648. Time.formatHuman(date) +
  649. ')';
  650. lastCheckpointMessage.appendChild(lastCheckpointText);
  651. lastCheckpointMessage.appendChild(lastCheckpointDate);
  652. body.appendChild(confirmMessage);
  653. body.appendChild(lastCheckpointMessage);
  654. return body;
  655. }
  656. }