From 3d210e053a89dea25c60a834b17624fe0b14d969 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Thu, 25 Jan 2024 11:27:09 +0100 Subject: [PATCH] Add option to export annotations in markdown --- .../ShareDialog/ExportAnnotations.tsx | 18 +++- .../test/ExportAnnotations-test.js | 25 +++++- src/sidebar/services/annotations-exporter.tsx | 85 +++++++++++++++++-- 3 files changed, 121 insertions(+), 7 deletions(-) diff --git a/src/sidebar/components/ShareDialog/ExportAnnotations.tsx b/src/sidebar/components/ShareDialog/ExportAnnotations.tsx index 4fcbf142389..b4814ed3384 100644 --- a/src/sidebar/components/ShareDialog/ExportAnnotations.tsx +++ b/src/sidebar/components/ShareDialog/ExportAnnotations.tsx @@ -30,7 +30,7 @@ export type ExportAnnotationsProps = { type ExportFormat = { /** Unique format identifier used also as file extension */ - value: 'json' | 'csv' | 'txt' | 'html'; + value: 'json' | 'csv' | 'txt' | 'html' | 'md'; /** The title to be displayed in the listbox item */ title: string; @@ -67,6 +67,12 @@ const exportFormats: ExportFormat[] = [ shortTitle: 'HTML', description: 'For import into word processors as rich text', }, + { + value: 'md', + title: 'Markdown (MD)', + shortTitle: 'MD', + description: 'For import into markdown based editors', + }, ]; function formatToMimeType(format: ExportFormat['value']): string { @@ -75,6 +81,7 @@ function formatToMimeType(format: ExportFormat['value']): string { txt: 'text/plain', csv: 'text/csv', html: 'text/html', + md: 'text/markdown', }; return typeForFormat[format]; } @@ -193,6 +200,15 @@ function ExportAnnotations({ }, ); } + case 'md': + return annotationsExporter.buildMarkdownExportContent( + annotationsToExport, + { + groupName: group?.name, + defaultAuthority, + displayNamesEnabled, + }, + ); /* istanbul ignore next - This should never happen */ default: throw new Error(`Invalid format: ${format}`); diff --git a/src/sidebar/components/ShareDialog/test/ExportAnnotations-test.js b/src/sidebar/components/ShareDialog/test/ExportAnnotations-test.js index 271c90a07ea..4d6e5a09ba0 100644 --- a/src/sidebar/components/ShareDialog/test/ExportAnnotations-test.js +++ b/src/sidebar/components/ShareDialog/test/ExportAnnotations-test.js @@ -40,6 +40,7 @@ describe('ExportAnnotations', () => { buildTextExportContent: sinon.stub().returns(''), buildCSVExportContent: sinon.stub().returns(''), buildHTMLExportContent: sinon.stub().returns(''), + buildMarkdownExportContent: sinon.stub().returns(''), }; fakeToastMessenger = { error: sinon.stub(), @@ -258,7 +259,7 @@ describe('ExportAnnotations', () => { const optionText = (index, type) => options.at(index).find(`[data-testid="format-${type}"]`).text(); - assert.equal(options.length, 4); + assert.equal(options.length, 5); assert.equal(optionText(0, 'name'), 'JSON'); assert.equal( optionText(0, 'description'), @@ -276,6 +277,11 @@ describe('ExportAnnotations', () => { optionText(3, 'description'), 'For import into word processors as rich text', ); + assert.equal(optionText(4, 'name'), 'Markdown (MD)'); + assert.equal( + optionText(4, 'description'), + 'For import into markdown based editors', + ); }); [ @@ -325,6 +331,12 @@ describe('ExportAnnotations', () => { fakeAnnotationsExporter.buildHTMLExportContent, ], }, + { + format: 'md', + getExpectedInvokedContentBuilder: () => [ + fakeAnnotationsExporter.buildMarkdownExportContent, + ], + }, ].forEach(({ format, getExpectedInvokedContentBuilder }) => { it('builds an export file from all non-draft annotations', async () => { const wrapper = createComponent(); @@ -418,6 +430,10 @@ describe('ExportAnnotations', () => { format: 'html', expectedMimeType: 'text/html', }, + { + format: 'md', + expectedMimeType: 'text/markdown', + }, ].forEach(({ format, expectedMimeType }) => { it('downloads a file using user-entered filename appended with proper extension', async () => { const wrapper = createComponent(); @@ -508,6 +524,13 @@ describe('ExportAnnotations', () => { fakeAnnotationsExporter.buildHTMLExportContent, ], }, + { + format: 'md', + getExpectedInvokedCallback: () => fakeCopyPlainText, + getExpectedInvokedContentBuilder: () => [ + fakeAnnotationsExporter.buildMarkdownExportContent, + ], + }, ].forEach( ({ format, diff --git a/src/sidebar/services/annotations-exporter.tsx b/src/sidebar/services/annotations-exporter.tsx index c86bca1a836..ba4a55c1cf7 100644 --- a/src/sidebar/services/annotations-exporter.tsx +++ b/src/sidebar/services/annotations-exporter.tsx @@ -93,13 +93,11 @@ export class AnnotationsExporter { const lines = [ `Created at: ${formatDateTime(new Date(annotation.created))}`, `Author: ${extractUsername(annotation)}`, - page ? `Page: ${page}` : undefined, + page && `Page: ${page}`, `Type: ${annotationRole(annotation)}`, - annotationQuote ? `Quote: "${annotationQuote}"` : undefined, + annotationQuote && `Quote: "${annotationQuote}"`, `Comment: ${annotation.text}`, - annotation.tags.length > 0 - ? `Tags: ${annotation.tags.join(', ')}` - : undefined, + annotation.tags.length > 0 && `Tags: ${annotation.tags.join(', ')}`, ].filter(Boolean); return trimAndDedent` @@ -311,6 +309,83 @@ export class AnnotationsExporter { ).replace(/\t/g, ' '); } + buildMarkdownExportContent( + annotations: APIAnnotationData[], + { + groupName = '', + displayNamesEnabled = false, + defaultAuthority = '', + /* istanbul ignore next - test seam */ + now = new Date(), + }: HTMLExportOptions = {}, + ): string { + const { uri, title, uniqueUsers, replies, extractUsername } = + this._exportCommon(annotations, { + displayNamesEnabled, + defaultAuthority, + }); + + const quoteToMarkdown = (quote: string): string => trimAndDedent` + * Quote: + ${quote + .split('\n') + .map(quoteLine => ` > ${quoteLine.trim()}`) + .join('\n')} + `; + // Since annotations text is already markdown, we want to wrap it in a pre + // to avoid it to be rendered by markdown parsers + const textToMarkdown = (text: string): string => trimAndDedent` + * Comment: + \`\`\` + ${text + .split('\n') + .map(textLine => ` ${textLine.trim()}`) + .join('\n')} + \`\`\` + `; + + return trimAndDedent` + # Annotations on "${title}" + + ${formatDateTime(now)} + + [${uri}](${uri}) + + * Group: ${groupName} + * Total users: ${uniqueUsers.length} + * Users: ${uniqueUsers.join(', ')} + * Total annotations: ${annotations.length} + * Total replies: ${replies.length} + + * * * + + # Annotations + + ${annotations + .map((annotation, index) => { + const page = pageLabel(annotation); + const annotationQuote = quote(annotation); + + const lines = [ + `* Created at: ${formatDateTime(new Date(annotation.created))}`, + `* Author: ${extractUsername(annotation)}`, + page && `* Page: ${page}`, + `* Type: ${annotationRole(annotation)}`, + annotationQuote && quoteToMarkdown(annotationQuote), + textToMarkdown(annotation.text), + annotation.tags.length > 0 && + `* Tags: ${annotation.tags.join(', ')}`, + ].filter(Boolean); + + return trimAndDedent` + ## Annotation ${index + 1}: + + ${lines.join('\n')}`; + }) + .join('\n\n')} + `; + } + private _exportCommon( annotations: APIAnnotationData[], {