From 3007b28b0f84a78fdd63658e5ae0780887f4aae3 Mon Sep 17 00:00:00 2001 From: Scott Bell Date: Thu, 30 Mar 2023 19:44:12 +0200 Subject: [PATCH] Simple text export of Notebook (#6510) * add simple prototype * tags and metadata now exported * add form for options * revert notebook * add simple e2e test * add test stubs * death to debug --- .../plugins/notebook/notebook.e2e.spec.js | 27 +++ .../actions/ExportNotebookAsTextAction.js | 155 ++++++++++++++++++ src/plugins/notebook/plugin.js | 2 + 3 files changed, 184 insertions(+) create mode 100644 src/plugins/notebook/actions/ExportNotebookAsTextAction.js diff --git a/e2e/tests/functional/plugins/notebook/notebook.e2e.spec.js b/e2e/tests/functional/plugins/notebook/notebook.e2e.spec.js index c76c4066b9..7842595298 100644 --- a/e2e/tests/functional/plugins/notebook/notebook.e2e.spec.js +++ b/e2e/tests/functional/plugins/notebook/notebook.e2e.spec.js @@ -377,4 +377,31 @@ test.describe('Notebook entry tests', () => { expect.soft(await sanitizedLink.count()).toBe(1); expect(await unsanitizedLink.count()).toBe(0); }); + test('can export notebook as text', async ({ page }) => { + await nbUtils.enterTextEntry(page, `Foo bar entry`); + // Click on 3 Dot Menu + await page.locator('button[title="More options"]').click(); + const downloadPromise = page.waitForEvent('download'); + + await page.getByRole('menuitem', { name: /Export Notebook as Text/ }).click(); + + await page.getByRole('button', { name: 'Save' }).click(); + const download = await downloadPromise; + const readStream = await download.createReadStream(); + const exportedText = await streamToString(readStream); + expect(exportedText).toContain('Foo bar entry'); + }); + test.fixme('can export multiple notebook entries as text ', async ({ page }) => {}); + test.fixme('can export all notebook entry metdata', async ({ page }) => {}); + test.fixme('can export all notebook tags', async ({ page }) => {}); + test.fixme('can export all notebook snapshots', async ({ page }) => {}); + + async function streamToString(readable) { + let result = ''; + for await (const chunk of readable) { + result += chunk; + } + + return result; + } }); diff --git a/src/plugins/notebook/actions/ExportNotebookAsTextAction.js b/src/plugins/notebook/actions/ExportNotebookAsTextAction.js new file mode 100644 index 0000000000..17c48bdd9b --- /dev/null +++ b/src/plugins/notebook/actions/ExportNotebookAsTextAction.js @@ -0,0 +1,155 @@ +import {saveAs} from 'saveAs'; +import Moment from 'moment'; + +const UNKNOWN_USER = 'Unknown'; +const UNKNOWN_TIME = 'Unknown'; + +export default class ExportNotebookAsTextAction { + + constructor(openmct) { + this.openmct = openmct; + + this.cssClass = 'icon-export'; + this.description = 'Exports notebook contents as a text file'; + this.group = "action"; + this.key = 'exportNotebookAsText'; + this.name = 'Export Notebook as Text'; + this.priority = 1; + } + + invoke(objectPath) { + this.showForm(objectPath); + } + + getTagName(tagId, availableTags) { + const foundTag = availableTags.find(tag => tag.id === tagId); + if (foundTag) { + return foundTag.label; + } else { + return tagId; + } + } + + getTagsForEntry(entry, domainObjectKeyString, annotations) { + const foundTags = []; + annotations.forEach(annotation => { + const target = annotation.targets?.[domainObjectKeyString]; + if (target?.entryId === entry.id) { + annotation.tags.forEach(tag => { + if (!foundTags.includes(tag)) { + foundTags.push(tag); + } + }); + } + }); + + return foundTags; + } + + formatTimeStamp(timestamp) { + if (timestamp) { + return `${Moment.utc(timestamp).format('YYYY-MM-DD HH:mm:ss')} UTC`; + } else { + return UNKNOWN_TIME; + } + } + + appliesTo(objectPath) { + const domainObject = objectPath[0]; + const type = this.openmct.types.get(domainObject.type); + + return type?.definition?.name === 'Notebook'; + } + + async onSave(changes, objectPath) { + const availableTags = this.openmct.annotation.getAvailableTags(); + const identifier = objectPath[0].identifier; + const domainObject = await this.openmct.objects.get(identifier); + let foundAnnotations = []; + // only load annotations if there are tags + if (availableTags.length) { + foundAnnotations = await this.openmct.annotation.getAnnotations(domainObject.identifier); + } + + let notebookAsText = `# ${domainObject.name}\n\n`; + + if (changes.exportMetaData) { + const createdTimestamp = domainObject.created; + const createdBy = domainObject.createdBy ?? UNKNOWN_USER; + const modifiedBy = domainObject.modifiedBy ?? UNKNOWN_USER; + const modifiedTimestamp = domainObject.modified ?? domainObject.created; + notebookAsText += `Created on ${this.formatTimeStamp(createdTimestamp)} by user ${createdBy}\n\n`; + notebookAsText += `Updated on ${this.formatTimeStamp(modifiedTimestamp)} by user ${modifiedBy}\n\n`; + } + + const notebookSections = domainObject.configuration.sections; + const notebookEntries = domainObject.configuration.entries; + + notebookSections.forEach(section => { + notebookAsText += `## ${section.name}\n\n`; + + const notebookPages = section.pages; + + notebookPages.forEach(page => { + notebookAsText += `### ${page.name}\n\n`; + + const notebookPageEntries = notebookEntries[section.id]?.[page.id]; + notebookPageEntries.forEach(entry => { + if (changes.exportMetaData) { + const createdTimestamp = entry.createdOn; + const createdBy = entry.createdBy ?? UNKNOWN_USER; + const modifiedBy = entry.modifiedBy ?? UNKNOWN_USER; + const modifiedTimestamp = entry.modified ?? entry.created; + notebookAsText += `Created on ${this.formatTimeStamp(createdTimestamp)} by user ${createdBy}\n\n`; + notebookAsText += `Updated on ${this.formatTimeStamp(modifiedTimestamp)} by user ${modifiedBy}\n\n`; + } + + if (changes.exportTags) { + const domainObjectKeyString = this.openmct.objects.makeKeyString(domainObject.identifier); + const tags = this.getTagsForEntry(entry, domainObjectKeyString, foundAnnotations); + const tagNames = tags.map(tag => this.getTagName(tag, availableTags)); + if (tagNames) { + notebookAsText += `Tags: ${tagNames.join(', ')}\n\n`; + } + } + + notebookAsText += `${entry.text}\n\n`; + }); + }); + }); + + const blob = new Blob([notebookAsText], {type: "text/markdown"}); + const fileName = domainObject.name + '.md'; + saveAs(blob, fileName); + } + + async showForm(objectPath) { + const formStructure = { + title: "Export Notebook Text", + sections: [ + { + rows: [ + { + key: "exportMetaData", + control: "toggleSwitch", + name: "Include Metadata (created/modified, etc.)", + required: true, + value: false + }, + { + name: "Include Tags", + control: "toggleSwitch", + required: true, + key: 'exportTags', + value: false + } + ] + } + ] + }; + + const changes = await this.openmct.forms.showForm(formStructure); + + return this.onSave(changes, objectPath); + } +} diff --git a/src/plugins/notebook/plugin.js b/src/plugins/notebook/plugin.js index 42d77dcee5..72281bab8d 100644 --- a/src/plugins/notebook/plugin.js +++ b/src/plugins/notebook/plugin.js @@ -21,6 +21,7 @@ *****************************************************************************/ import CopyToNotebookAction from './actions/CopyToNotebookAction'; +import ExportNotebookAsTextAction from './actions/ExportNotebookAsTextAction'; import NotebookSnapshotIndicator from './components/NotebookSnapshotIndicator.vue'; import NotebookViewProvider from './NotebookViewProvider'; import NotebookType from './NotebookType'; @@ -80,6 +81,7 @@ function installBaseNotebookFunctionality(openmct) { }; openmct.types.addType('notebookSnapshotImage', notebookSnapshotImageType); openmct.actions.register(new CopyToNotebookAction(openmct)); + openmct.actions.register(new ExportNotebookAsTextAction(openmct)); const notebookSnapshotIndicator = new Vue ({ components: {