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 @@
"npm-packlist": "^8.0.0",
"postcss": "^8.0.3",
"preact": "^10.4.0",
"preact-render-to-string": "^6.3.1",
"prettier": "3.1.1",
"redux": "^5.0.0",
"redux-thunk": "^3.1.0",
......
......@@ -38,23 +38,17 @@ export function downloadJSONFile(
return fileContent;
}
/**
* Download a text file containing text
*/
export function downloadTextFile(
text: string,
filename: string,
/* istanbul ignore next - test seam */
_document = document,
) {
downloadFile(text, 'text/plain', filename, _document);
function buildTextFileDownloader(type: string) {
return (
text: string,
filename: string,
/* istanbul ignore next - test seam */
_document = document,
) => downloadFile(text, type, filename, _document);
}
export function downloadCSVFile(
text: string,
filename: string,
/* istanbul ignore next - test seam */
_document = document,
) {
downloadFile(text, 'text/csv', filename, _document);
}
export const downloadTextFile = buildTextFileDownloader('text/plain');
export const downloadCSVFile = buildTextFileDownloader('text/csv');
export const downloadHTMLFile = buildTextFileDownloader('text/html');
import {
downloadCSVFile,
downloadHTMLFile,
downloadJSONFile,
downloadTextFile,
} from '../download-file';
......@@ -73,4 +74,13 @@ describe('download-file', () => {
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';
import {
downloadCSVFile,
downloadHTMLFile,
downloadJSONFile,
downloadTextFile,
} from '../../../shared/download-file';
......@@ -46,20 +47,18 @@ const exportFormats: ExportFormat[] = [
{
value: 'txt',
name: 'Text',
description: 'For import into Word or text editors',
description: 'For import into word processors as plain text',
},
{
value: 'csv',
name: 'CSV',
description: 'For import into a spreadsheet',
},
// TODO Enable these formats when implemented
// {
// value: 'html',
// name: 'HTML',
// description: '',
// },
{
value: 'html',
name: 'HTML',
description: 'For import into word processors as rich text',
},
];
/**
......@@ -169,6 +168,18 @@ function ExportAnnotations({
downloadCSVFile(exportData, filename);
break;
}
case 'html': {
const exportData = annotationsExporter.buildHTMLExportContent(
annotationsToExport,
{
groupName: group?.name,
defaultAuthority,
displayNamesEnabled,
},
);
downloadHTMLFile(exportData, filename);
break;
}
}
} catch (e) {
toastMessenger.error('Exporting annotations failed');
......
......@@ -17,6 +17,7 @@ describe('ExportAnnotations', () => {
let fakeDownloadJSONFile;
let fakeDownloadTextFile;
let fakeDownloadCSVFile;
let fakeDownloadHTMLFile;
let fakeSuggestedFilename;
const fakePrivateGroup = {
......@@ -39,6 +40,7 @@ describe('ExportAnnotations', () => {
buildJSONExportContent: sinon.stub().returns({}),
buildTextExportContent: sinon.stub().returns(''),
buildCSVExportContent: sinon.stub().returns(''),
buildHTMLExportContent: sinon.stub().returns(''),
};
fakeToastMessenger = {
error: sinon.stub(),
......@@ -46,6 +48,7 @@ describe('ExportAnnotations', () => {
fakeDownloadJSONFile = sinon.stub();
fakeDownloadTextFile = sinon.stub();
fakeDownloadCSVFile = sinon.stub();
fakeDownloadHTMLFile = sinon.stub();
fakeStore = {
defaultAuthority: sinon.stub().returns('example.com'),
isFeatureEnabled: sinon.stub().returns(true),
......@@ -69,6 +72,7 @@ describe('ExportAnnotations', () => {
downloadJSONFile: fakeDownloadJSONFile,
downloadTextFile: fakeDownloadTextFile,
downloadCSVFile: fakeDownloadCSVFile,
downloadHTMLFile: fakeDownloadHTMLFile,
},
'../../helpers/export-annotations': {
suggestedFilename: fakeSuggestedFilename,
......@@ -244,7 +248,7 @@ describe('ExportAnnotations', () => {
const optionText = (index, type) =>
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, 'description'),
......@@ -253,10 +257,15 @@ describe('ExportAnnotations', () => {
assert.equal(optionText(1, 'name'), 'Text');
assert.equal(
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, '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', () => {
......@@ -287,6 +296,11 @@ describe('ExportAnnotations', () => {
getExpectedInvokedContentBuilder: () =>
fakeAnnotationsExporter.buildCSVExportContent,
},
{
format: 'html',
getExpectedInvokedContentBuilder: () =>
fakeAnnotationsExporter.buildHTMLExportContent,
},
].forEach(({ format, getExpectedInvokedContentBuilder }) => {
it('builds an export file from all non-draft annotations', async () => {
const wrapper = createComponent();
......@@ -377,6 +391,10 @@ describe('ExportAnnotations', () => {
format: 'csv',
getExpectedInvokedDownloader: () => fakeDownloadCSVFile,
},
{
format: 'html',
getExpectedInvokedDownloader: () => fakeDownloadHTMLFile,
},
].forEach(({ format, getExpectedInvokedDownloader }) => {
it('downloads a file using user-entered filename appended with proper extension', async () => {
const wrapper = createComponent();
......
import renderToString from 'preact-render-to-string/jsx';
import { escapeCSVValue } from '../../shared/csv';
import { trimAndDedent } from '../../shared/trim-and-dedent';
import type { APIAnnotationData, Profile } from '../../types/api';
......@@ -73,25 +75,23 @@ export class AnnotationsExporter {
defaultAuthority,
});
const annotationsText = annotations
.map((annotation, index) => {
const page = pageLabel(annotation);
const lines = [
formatDateTime(new Date(annotation.created)),
`Comment: ${annotation.text}`,
extractUsername(annotation),
`Quote: "${quote(annotation)}"`,
annotation.tags.length > 0
? `Tags: ${annotation.tags.join(', ')}`
: undefined,
page ? `Page: ${page}` : undefined,
].filter(Boolean);
return trimAndDedent`
Annotation ${index + 1}:
${lines.join('\n')}`;
})
.join('\n\n');
const annotationsAsText = annotations.map((annotation, index) => {
const page = pageLabel(annotation);
const lines = [
formatDateTime(new Date(annotation.created)),
`Comment: ${annotation.text}`,
extractUsername(annotation),
`Quote: "${quote(annotation)}"`,
annotation.tags.length > 0
? `Tags: ${annotation.tags.join(', ')}`
: undefined,
page ? `Page: ${page}` : undefined,
].filter(Boolean);
return trimAndDedent`
Annotation ${index + 1}:
${lines.join('\n')}`;
});
return trimAndDedent`
${formatDateTime(now)}
......@@ -103,7 +103,7 @@ export class AnnotationsExporter {
Total annotations: ${annotations.length}
Total replies: ${replies.length}
${annotationsText}`;
${annotationsAsText.join('\n\n')}`;
}
buildCSVExportContent(
......@@ -152,6 +152,89 @@ export class AnnotationsExporter {
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(
annotations: APIAnnotationData[],
{
......
......@@ -224,6 +224,7 @@ Tags: tag_1, tag_2`,
const result = exporter.buildCSVExportContent(annotations, {
groupName,
now,
});
assert.equal(
......@@ -246,6 +247,7 @@ ${formattedNow},http://example.com,My group,Annotation,,bill,Annotation text,,ii
const result = exporter.buildCSVExportContent([annotation], {
displayNamesEnabled: true,
groupName,
now,
});
assert.equal(
......@@ -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 @@
// Prevent automatic inclusion of global variables defined in `@types/<name>` packages.
// This prevents eg. Node globals from `@types/node` being included when writing
// code for the browser.
"types": []
"types": [],
"paths": {
"preact-render-to-string/jsx": ["./types/preact-render-to-string"]
}
},
"include": ["**/*.js", "**/*.ts", "**/*.tsx", "types/*.d.ts"],
"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:
npm-packlist: ^8.0.0
postcss: ^8.0.3
preact: ^10.4.0
preact-render-to-string: ^6.3.1
prettier: 3.1.1
redux: ^5.0.0
redux-thunk: ^3.1.0
......@@ -11130,6 +11131,17 @@ __metadata:
languageName: node
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":
version: 10.19.3
resolution: "preact@npm:10.19.3"
......@@ -11153,6 +11165,13 @@ __metadata:
languageName: node
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":
version: 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