Commit f9678a60 authored by Alejandro Celaya's avatar Alejandro Celaya Committed by Alejandro Celaya

Add logic to export annotations in HTML format

parent deca184a
...@@ -82,6 +82,7 @@ ...@@ -82,6 +82,7 @@
"npm-packlist": "^8.0.0", "npm-packlist": "^8.0.0",
"postcss": "^8.0.3", "postcss": "^8.0.3",
"preact": "^10.4.0", "preact": "^10.4.0",
"preact-render-to-string": "^6.3.1",
"prettier": "3.1.1", "prettier": "3.1.1",
"redux": "^5.0.0", "redux": "^5.0.0",
"redux-thunk": "^3.1.0", "redux-thunk": "^3.1.0",
......
...@@ -38,23 +38,17 @@ export function downloadJSONFile( ...@@ -38,23 +38,17 @@ export function downloadJSONFile(
return fileContent; return fileContent;
} }
/** function buildTextFileDownloader(type: string) {
* Download a text file containing text return (
*/
export function downloadTextFile(
text: string, text: string,
filename: string, filename: string,
/* istanbul ignore next - test seam */ /* istanbul ignore next - test seam */
_document = document, _document = document,
) { ) => downloadFile(text, type, filename, _document);
downloadFile(text, 'text/plain', filename, _document);
} }
export function downloadCSVFile( export const downloadTextFile = buildTextFileDownloader('text/plain');
text: string,
filename: string, export const downloadCSVFile = buildTextFileDownloader('text/csv');
/* istanbul ignore next - test seam */
_document = document, export const downloadHTMLFile = buildTextFileDownloader('text/html');
) {
downloadFile(text, 'text/csv', filename, _document);
}
import { import {
downloadCSVFile, downloadCSVFile,
downloadHTMLFile,
downloadJSONFile, downloadJSONFile,
downloadTextFile, downloadTextFile,
} from '../download-file'; } from '../download-file';
...@@ -73,4 +74,13 @@ describe('download-file', () => { ...@@ -73,4 +74,13 @@ describe('download-file', () => {
assertDownloadHappened(filename, data, 'text/csv'); assertDownloadHappened(filename, data, 'text/csv');
}); });
it('downloadHTMLFile generates HTML file with provided data', () => {
const data = '<p>Hello</p>';
const filename = 'my-file.html';
downloadHTMLFile(data, filename, fakeDocument);
assertDownloadHappened(filename, data, 'text/html');
});
}); });
...@@ -9,6 +9,7 @@ import { useCallback, useId, useMemo, useState } from 'preact/hooks'; ...@@ -9,6 +9,7 @@ import { useCallback, useId, useMemo, useState } from 'preact/hooks';
import { import {
downloadCSVFile, downloadCSVFile,
downloadHTMLFile,
downloadJSONFile, downloadJSONFile,
downloadTextFile, downloadTextFile,
} from '../../../shared/download-file'; } from '../../../shared/download-file';
...@@ -46,20 +47,18 @@ const exportFormats: ExportFormat[] = [ ...@@ -46,20 +47,18 @@ const exportFormats: ExportFormat[] = [
{ {
value: 'txt', value: 'txt',
name: 'Text', name: 'Text',
description: 'For import into Word or text editors', description: 'For import into word processors as plain text',
}, },
{ {
value: 'csv', value: 'csv',
name: 'CSV', name: 'CSV',
description: 'For import into a spreadsheet', description: 'For import into a spreadsheet',
}, },
{
// TODO Enable these formats when implemented value: 'html',
// { name: 'HTML',
// value: 'html', description: 'For import into word processors as rich text',
// name: 'HTML', },
// description: '',
// },
]; ];
/** /**
...@@ -169,6 +168,18 @@ function ExportAnnotations({ ...@@ -169,6 +168,18 @@ function ExportAnnotations({
downloadCSVFile(exportData, filename); downloadCSVFile(exportData, filename);
break; break;
} }
case 'html': {
const exportData = annotationsExporter.buildHTMLExportContent(
annotationsToExport,
{
groupName: group?.name,
defaultAuthority,
displayNamesEnabled,
},
);
downloadHTMLFile(exportData, filename);
break;
}
} }
} catch (e) { } catch (e) {
toastMessenger.error('Exporting annotations failed'); toastMessenger.error('Exporting annotations failed');
......
...@@ -17,6 +17,7 @@ describe('ExportAnnotations', () => { ...@@ -17,6 +17,7 @@ describe('ExportAnnotations', () => {
let fakeDownloadJSONFile; let fakeDownloadJSONFile;
let fakeDownloadTextFile; let fakeDownloadTextFile;
let fakeDownloadCSVFile; let fakeDownloadCSVFile;
let fakeDownloadHTMLFile;
let fakeSuggestedFilename; let fakeSuggestedFilename;
const fakePrivateGroup = { const fakePrivateGroup = {
...@@ -39,6 +40,7 @@ describe('ExportAnnotations', () => { ...@@ -39,6 +40,7 @@ describe('ExportAnnotations', () => {
buildJSONExportContent: sinon.stub().returns({}), buildJSONExportContent: sinon.stub().returns({}),
buildTextExportContent: sinon.stub().returns(''), buildTextExportContent: sinon.stub().returns(''),
buildCSVExportContent: sinon.stub().returns(''), buildCSVExportContent: sinon.stub().returns(''),
buildHTMLExportContent: sinon.stub().returns(''),
}; };
fakeToastMessenger = { fakeToastMessenger = {
error: sinon.stub(), error: sinon.stub(),
...@@ -46,6 +48,7 @@ describe('ExportAnnotations', () => { ...@@ -46,6 +48,7 @@ describe('ExportAnnotations', () => {
fakeDownloadJSONFile = sinon.stub(); fakeDownloadJSONFile = sinon.stub();
fakeDownloadTextFile = sinon.stub(); fakeDownloadTextFile = sinon.stub();
fakeDownloadCSVFile = sinon.stub(); fakeDownloadCSVFile = sinon.stub();
fakeDownloadHTMLFile = sinon.stub();
fakeStore = { fakeStore = {
defaultAuthority: sinon.stub().returns('example.com'), defaultAuthority: sinon.stub().returns('example.com'),
isFeatureEnabled: sinon.stub().returns(true), isFeatureEnabled: sinon.stub().returns(true),
...@@ -69,6 +72,7 @@ describe('ExportAnnotations', () => { ...@@ -69,6 +72,7 @@ describe('ExportAnnotations', () => {
downloadJSONFile: fakeDownloadJSONFile, downloadJSONFile: fakeDownloadJSONFile,
downloadTextFile: fakeDownloadTextFile, downloadTextFile: fakeDownloadTextFile,
downloadCSVFile: fakeDownloadCSVFile, downloadCSVFile: fakeDownloadCSVFile,
downloadHTMLFile: fakeDownloadHTMLFile,
}, },
'../../helpers/export-annotations': { '../../helpers/export-annotations': {
suggestedFilename: fakeSuggestedFilename, suggestedFilename: fakeSuggestedFilename,
...@@ -244,7 +248,7 @@ describe('ExportAnnotations', () => { ...@@ -244,7 +248,7 @@ describe('ExportAnnotations', () => {
const optionText = (index, type) => const optionText = (index, type) =>
options.at(index).find(`[data-testid="format-${type}"]`).text(); options.at(index).find(`[data-testid="format-${type}"]`).text();
assert.equal(options.length, 3); assert.equal(options.length, 4);
assert.equal(optionText(0, 'name'), 'JSON'); assert.equal(optionText(0, 'name'), 'JSON');
assert.equal( assert.equal(
optionText(0, 'description'), optionText(0, 'description'),
...@@ -253,10 +257,15 @@ describe('ExportAnnotations', () => { ...@@ -253,10 +257,15 @@ describe('ExportAnnotations', () => {
assert.equal(optionText(1, 'name'), 'Text'); assert.equal(optionText(1, 'name'), 'Text');
assert.equal( assert.equal(
optionText(1, 'description'), optionText(1, 'description'),
'For import into Word or text editors', 'For import into word processors as plain text',
); );
assert.equal(optionText(2, 'name'), 'CSV'); assert.equal(optionText(2, 'name'), 'CSV');
assert.equal(optionText(2, 'description'), 'For import into a spreadsheet'); assert.equal(optionText(2, 'description'), 'For import into a spreadsheet');
assert.equal(optionText(3, 'name'), 'HTML');
assert.equal(
optionText(3, 'description'),
'For import into word processors as rich text',
);
}); });
describe('export form submitted', () => { describe('export form submitted', () => {
...@@ -287,6 +296,11 @@ describe('ExportAnnotations', () => { ...@@ -287,6 +296,11 @@ describe('ExportAnnotations', () => {
getExpectedInvokedContentBuilder: () => getExpectedInvokedContentBuilder: () =>
fakeAnnotationsExporter.buildCSVExportContent, fakeAnnotationsExporter.buildCSVExportContent,
}, },
{
format: 'html',
getExpectedInvokedContentBuilder: () =>
fakeAnnotationsExporter.buildHTMLExportContent,
},
].forEach(({ format, getExpectedInvokedContentBuilder }) => { ].forEach(({ format, getExpectedInvokedContentBuilder }) => {
it('builds an export file from all non-draft annotations', async () => { it('builds an export file from all non-draft annotations', async () => {
const wrapper = createComponent(); const wrapper = createComponent();
...@@ -377,6 +391,10 @@ describe('ExportAnnotations', () => { ...@@ -377,6 +391,10 @@ describe('ExportAnnotations', () => {
format: 'csv', format: 'csv',
getExpectedInvokedDownloader: () => fakeDownloadCSVFile, getExpectedInvokedDownloader: () => fakeDownloadCSVFile,
}, },
{
format: 'html',
getExpectedInvokedDownloader: () => fakeDownloadHTMLFile,
},
].forEach(({ format, getExpectedInvokedDownloader }) => { ].forEach(({ format, getExpectedInvokedDownloader }) => {
it('downloads a file using user-entered filename appended with proper extension', async () => { it('downloads a file using user-entered filename appended with proper extension', async () => {
const wrapper = createComponent(); const wrapper = createComponent();
......
import renderToString from 'preact-render-to-string/jsx';
import { escapeCSVValue } from '../../shared/csv'; import { escapeCSVValue } from '../../shared/csv';
import { trimAndDedent } from '../../shared/trim-and-dedent'; import { trimAndDedent } from '../../shared/trim-and-dedent';
import type { APIAnnotationData, Profile } from '../../types/api'; import type { APIAnnotationData, Profile } from '../../types/api';
...@@ -73,8 +75,7 @@ export class AnnotationsExporter { ...@@ -73,8 +75,7 @@ export class AnnotationsExporter {
defaultAuthority, defaultAuthority,
}); });
const annotationsText = annotations const annotationsAsText = annotations.map((annotation, index) => {
.map((annotation, index) => {
const page = pageLabel(annotation); const page = pageLabel(annotation);
const lines = [ const lines = [
formatDateTime(new Date(annotation.created)), formatDateTime(new Date(annotation.created)),
...@@ -90,8 +91,7 @@ export class AnnotationsExporter { ...@@ -90,8 +91,7 @@ export class AnnotationsExporter {
return trimAndDedent` return trimAndDedent`
Annotation ${index + 1}: Annotation ${index + 1}:
${lines.join('\n')}`; ${lines.join('\n')}`;
}) });
.join('\n\n');
return trimAndDedent` return trimAndDedent`
${formatDateTime(now)} ${formatDateTime(now)}
...@@ -103,7 +103,7 @@ export class AnnotationsExporter { ...@@ -103,7 +103,7 @@ export class AnnotationsExporter {
Total annotations: ${annotations.length} Total annotations: ${annotations.length}
Total replies: ${replies.length} Total replies: ${replies.length}
${annotationsText}`; ${annotationsAsText.join('\n\n')}`;
} }
buildCSVExportContent( buildCSVExportContent(
...@@ -152,6 +152,89 @@ export class AnnotationsExporter { ...@@ -152,6 +152,89 @@ export class AnnotationsExporter {
return `${headers}\n${annotationsContent}`; return `${headers}\n${annotationsContent}`;
} }
buildHTMLExportContent(
annotations: APIAnnotationData[],
{
groupName = '',
displayNamesEnabled = false,
defaultAuthority = '',
/* istanbul ignore next - test seam */
now = new Date(),
}: ExportOptions = {},
): string {
const { uri, title, uniqueUsers, replies, extractUsername } =
this._exportCommon(annotations, {
displayNamesEnabled,
defaultAuthority,
});
return renderToString(
<html lang="en">
<head>
<title>Annotations export - Hypothesis</title>
<meta charSet="UTF-8" />
</head>
<body>
<section>
<h1>Summary</h1>
<p>
<time dateTime={now.toISOString()}>{formatDateTime(now)}</time>
</p>
<p>
<strong>{title}</strong>
</p>
<p>
<a href={uri} target="_blank" rel="noopener noreferrer">
{uri}
</a>
</p>
<dl>
<dt>Group</dt>
<dd>{groupName}</dd>
<dt>Total users</dt>
<dd>{uniqueUsers.length}</dd>
<dt>Users</dt>
<dd>{uniqueUsers.join(', ')}</dd>
<dt>Total annotations</dt>
<dd>{annotations.length}</dd>
<dt>Total replies</dt>
<dd>{replies.length}</dd>
</dl>
</section>
<hr />
<section>
<h1>Annotations</h1>
{annotations.map((annotation, index) => {
const page = pageLabel(annotation);
return (
<article key={annotation.id}>
<h2>Annotation {index + 1}:</h2>
<p>
<time dateTime={annotation.created}>
{formatDateTime(new Date(annotation.created))}
</time>
</p>
<p>Comment: {annotation.text}</p>
<p>{extractUsername(annotation)}</p>
<p>
Quote: <blockquote>{quote(annotation)}</blockquote>
</p>
{annotation.tags.length > 0 && (
<p>Tags: {annotation.tags.join(', ')}</p>
)}
{page && <p>Page: {page}</p>}
</article>
);
})}
</section>
</body>
</html>,
{},
{ pretty: true },
);
}
private _exportCommon( private _exportCommon(
annotations: APIAnnotationData[], annotations: APIAnnotationData[],
{ {
......
...@@ -224,6 +224,7 @@ Tags: tag_1, tag_2`, ...@@ -224,6 +224,7 @@ Tags: tag_1, tag_2`,
const result = exporter.buildCSVExportContent(annotations, { const result = exporter.buildCSVExportContent(annotations, {
groupName, groupName,
now,
}); });
assert.equal( assert.equal(
...@@ -246,6 +247,7 @@ ${formattedNow},http://example.com,My group,Annotation,,bill,Annotation text,,ii ...@@ -246,6 +247,7 @@ ${formattedNow},http://example.com,My group,Annotation,,bill,Annotation text,,ii
const result = exporter.buildCSVExportContent([annotation], { const result = exporter.buildCSVExportContent([annotation], {
displayNamesEnabled: true, displayNamesEnabled: true,
groupName, groupName,
now,
}); });
assert.equal( assert.equal(
...@@ -255,4 +257,132 @@ ${formattedNow},http://example.com,My group,Annotation,,John Doe,Annotation text ...@@ -255,4 +257,132 @@ ${formattedNow},http://example.com,My group,Annotation,,John Doe,Annotation text
); );
}); });
}); });
describe('buildHTMLExportContent', () => {
it('throws error when empty list of annotations is provided', () => {
assert.throws(
() => exporter.buildHTMLExportContent([]),
'No annotations to export',
);
});
it('generates HTML content with expected annotations', () => {
const isoDate = now.toISOString();
const annotations = [
{
...baseAnnotation,
user: 'acct:jane@localhost',
tags: ['foo', 'bar'],
},
{
...baseAnnotation,
...newReply(),
target: targetWithSelectors(
quoteSelector('includes <p>HTML</p> tags'),
pageSelector(23),
),
},
{
...baseAnnotation,
tags: [],
target: targetWithSelectors(pageSelector('iii')),
},
];
const result = exporter.buildHTMLExportContent(annotations, {
groupName,
now,
});
// The result uses tabs to indent lines.
// We can get rid of that for simplicity and just compare the markup
const removeAllIndentation = str => str.replace(/^[ \t]+/gm, '');
assert.equal(
removeAllIndentation(result),
removeAllIndentation(`<html lang="en">
<head>
<title>Annotations export - Hypothesis</title>
<meta charset="UTF-8" />
</head>
<body>
<section>
<h1>Summary</h1>
<p>
<time datetime="${isoDate}">${formattedNow}</time>
</p>
<p>
<strong>A special document</strong>
</p>
<p>
<a
href="http://example.com"
target="_blank"
rel="noopener noreferrer"
>
http://example.com
</a>
</p>
<dl>
<dt>Group</dt>
<dd>My group</dd>
<dt>Total users</dt>
<dd>2</dd>
<dt>Users</dt>
<dd>jane, bill</dd>
<dt>Total annotations</dt>
<dd>3</dd>
<dt>Total replies</dt>
<dd>1</dd>
</dl>
</section>
<hr />
<section>
<h1>Annotations</h1>
<article>
<h2>Annotation 1:</h2>
<p>
<time datetime="${isoDate}">${formattedNow}</time>
</p>
<p>Comment: Annotation text</p>
<p>jane</p>
<p>
Quote:
<blockquote></blockquote>
</p>
<p>Tags: foo, bar</p>
</article>
<article>
<h2>Annotation 2:</h2>
<p>
<time datetime="${isoDate}">${formattedNow}</time>
</p>
<p>Comment: Annotation text</p>
<p>bill</p>
<p>
Quote:
<blockquote>includes &lt;p>HTML&lt;/p> tags</blockquote>
</p>
<p>Tags: tag_1, tag_2</p>
<p>Page: 23</p>
</article>
<article>
<h2>Annotation 3:</h2>
<p>
<time datetime="${isoDate}">${formattedNow}</time>
</p>
<p>Comment: Annotation text</p>
<p>bill</p>
<p>
Quote:
<blockquote></blockquote>
</p>
<p>Page: iii</p>
</article>
</section>
</body>
</html>`),
);
});
});
}); });
...@@ -21,7 +21,11 @@ ...@@ -21,7 +21,11 @@
// Prevent automatic inclusion of global variables defined in `@types/<name>` packages. // Prevent automatic inclusion of global variables defined in `@types/<name>` packages.
// This prevents eg. Node globals from `@types/node` being included when writing // This prevents eg. Node globals from `@types/node` being included when writing
// code for the browser. // code for the browser.
"types": [] "types": [],
"paths": {
"preact-render-to-string/jsx": ["./types/preact-render-to-string"]
}
}, },
"include": ["**/*.js", "**/*.ts", "**/*.tsx", "types/*.d.ts"], "include": ["**/*.js", "**/*.ts", "**/*.tsx", "types/*.d.ts"],
"exclude": [ "exclude": [
......
// This local type definition is used to address an incorrect one from
// preact-render-to-string.
// As soon as https://github.com/preactjs/preact-render-to-string/issues/328
// is solved upstream, we can remove this.
declare module 'preact-render-to-string/jsx' {
import type { VNode } from 'preact';
interface Options {
jsx?: boolean;
xml?: boolean;
pretty?: boolean | string;
shallow?: boolean;
functions?: boolean;
functionNames?: boolean;
skipFalseAttributes?: boolean;
}
export default function renderToStringPretty(
vnode: VNode,
context?: any,
options?: Options,
): string;
export function shallowRender(vnode: VNode, context?: any): string;
}
...@@ -7857,6 +7857,7 @@ __metadata: ...@@ -7857,6 +7857,7 @@ __metadata:
npm-packlist: ^8.0.0 npm-packlist: ^8.0.0
postcss: ^8.0.3 postcss: ^8.0.3
preact: ^10.4.0 preact: ^10.4.0
preact-render-to-string: ^6.3.1
prettier: 3.1.1 prettier: 3.1.1
redux: ^5.0.0 redux: ^5.0.0
redux-thunk: ^3.1.0 redux-thunk: ^3.1.0
...@@ -11130,6 +11131,17 @@ __metadata: ...@@ -11130,6 +11131,17 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"preact-render-to-string@npm:^6.3.1":
version: 6.3.1
resolution: "preact-render-to-string@npm:6.3.1"
dependencies:
pretty-format: ^3.8.0
peerDependencies:
preact: ">=10"
checksum: bb640f3e9045585ac23d64878f02134aa78e83589edce697e350c782e375d64c900210861caf3018a95b696ef4e77a777ae6174f4624c562a6067214a3dfd91e
languageName: node
linkType: hard
"preact@npm:^10.18.1, preact@npm:^10.4.0": "preact@npm:^10.18.1, preact@npm:^10.4.0":
version: 10.19.3 version: 10.19.3
resolution: "preact@npm:10.19.3" resolution: "preact@npm:10.19.3"
...@@ -11153,6 +11165,13 @@ __metadata: ...@@ -11153,6 +11165,13 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"pretty-format@npm:^3.8.0":
version: 3.8.0
resolution: "pretty-format@npm:3.8.0"
checksum: 21a114d43ef06978f8f7f6212be4649b0b094f05d9b30e14e37550bf35c8ca24d8adbca9e5adc4cc15d9eaf7a1e7a30478a4dc37b30982bfdf0292a5b385484c
languageName: node
linkType: hard
"pretty-hrtime@npm:^1.0.0": "pretty-hrtime@npm:^1.0.0":
version: 1.0.3 version: 1.0.3
resolution: "pretty-hrtime@npm:1.0.3" resolution: "pretty-hrtime@npm:1.0.3"
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment