Commit 8e1232ee authored by Alejandro Celaya's avatar Alejandro Celaya Committed by Alejandro Celaya

Add support to export annotations in text format

parent 393a2aca
function downloadFile(
content: string,
type: string,
filename: string,
document: Document,
): void {
const blob = new Blob([content], { type });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.setAttribute('href', url);
link.setAttribute('download', filename);
link.style.visibility = 'hidden';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}
/**
* Download a file containing JSON-serialized `object` as `filename`
*
* @param data - JSON-serializable object
* @param _document - Test seam
* @return The contents of the downloaded file
* @throws {Error} If provided data cannot be JSON-serialized
*/
export function downloadJSONFile(
data: object,
filename: string,
/* istanbul ignore next */
/* istanbul ignore next - test seam */
_document = document,
): string {
const link = _document.createElement('a');
const fileContent = JSON.stringify(data, null, 2);
const blob = new Blob([fileContent], {
type: 'application/json',
});
const url = URL.createObjectURL(blob);
link.setAttribute('href', url);
link.setAttribute('download', filename);
link.style.visibility = 'hidden';
_document.body.appendChild(link);
link.click();
_document.body.removeChild(link);
downloadFile(fileContent, 'application/json', filename, _document);
return fileContent;
}
/**
* Download a text file containing data
*/
export function downloadTextFile(
text: string,
filename: string,
/* istanbul ignore next - test seam */
_document = document,
) {
downloadFile(text, 'text/plain', filename, _document);
}
import { downloadJSONFile } from '../download-json-file';
import { downloadJSONFile, downloadTextFile } from '../download-file';
describe('download-json-file', () => {
describe('download-file', () => {
let fakeLink;
let fakeDocument;
......@@ -18,20 +18,21 @@ describe('download-json-file', () => {
removeChild: sinon.stub(),
},
};
});
it('generates export file with provided annotations', () => {
const filename = 'my-file.json';
const data = { foo: ['bar', 'baz'] };
const fileContent = downloadJSONFile(data, filename, fakeDocument);
sinon.spy(window, 'Blob');
});
assert.equal(fileContent, JSON.stringify(data, null, 2));
afterEach(() => {
window.Blob.restore();
});
function assertDownloadHappened(filename, fileContent, type) {
assert.calledWith(fakeDocument.createElement, 'a');
assert.calledWith(fakeDocument.body.appendChild, fakeLink);
assert.calledWith(fakeDocument.body.removeChild, fakeLink);
assert.calledWith(window.Blob, [fileContent], { type });
assert.calledWith(
fakeLink.setAttribute.firstCall,
'href',
......@@ -39,5 +40,24 @@ describe('download-json-file', () => {
);
assert.calledWith(fakeLink.setAttribute.secondCall, 'download', filename);
assert.equal('hidden', fakeLink.style.visibility);
}
it('downloadJSONFile generates JSON file with provided data', () => {
const data = { foo: ['bar', 'baz'] };
const filename = 'my-file.json';
const fileContent = downloadJSONFile(data, filename, fakeDocument);
assert.equal(fileContent, JSON.stringify(data, null, 2));
assertDownloadHappened(filename, fileContent, 'application/json');
});
it('downloadTextFile generates text file with provided data', () => {
const data = 'The content of the file';
const filename = 'my-file.txt';
downloadTextFile(data, filename, fakeDocument);
assertDownloadHappened(filename, data, 'text/plain');
});
});
......@@ -7,7 +7,10 @@ import {
} from '@hypothesis/frontend-shared';
import { useCallback, useId, useMemo, useState } from 'preact/hooks';
import { downloadJSONFile } from '../../../shared/download-json-file';
import {
downloadJSONFile,
downloadTextFile,
} from '../../../shared/download-file';
import type { APIAnnotationData } from '../../../types/api';
import { annotationDisplayName } from '../../helpers/annotation-user';
import type { UserAnnotations } from '../../helpers/annotations-by-user';
......@@ -37,6 +40,10 @@ const exportFormats: ExportFormat[] = [
value: 'json',
name: 'JSON',
},
{
value: 'txt',
name: 'Text',
},
// TODO Enable these formats when implemented
// {
......@@ -44,10 +51,6 @@ const exportFormats: ExportFormat[] = [
// name: 'CSV',
// },
// {
// value: 'txt',
// name: 'Text',
// },
// {
// value: 'html',
// name: 'HTML',
// },
......@@ -126,10 +129,21 @@ function ExportAnnotations({
selectedUser?.annotations ?? exportableAnnotations;
const filename = `${customFilename ?? defaultFilename}.${format}`;
if (format === 'json') {
switch (format) {
case 'json': {
const exportData =
annotationsExporter.buildJSONExportContent(annotationsToExport);
downloadJSONFile(exportData, filename);
break;
}
case 'txt': {
const exportData = annotationsExporter.buildTextExportContent(
annotationsToExport,
group?.name,
);
downloadTextFile(exportData, filename);
break;
}
}
} catch (e) {
toastMessenger.error('Exporting annotations failed');
......@@ -187,6 +201,7 @@ function ExportAnnotations({
onChange={setExportFormat}
buttonContent={exportFormat.name}
data-testid="export-format-select"
right
>
{exportFormats.map(exportFormat => (
<SelectNext.Option
......
......@@ -15,6 +15,7 @@ describe('ExportAnnotations', () => {
let fakeAnnotationsExporter;
let fakeToastMessenger;
let fakeDownloadJSONFile;
let fakeDownloadTextFile;
let fakeSuggestedFilename;
const fakePrivateGroup = {
......@@ -35,11 +36,13 @@ describe('ExportAnnotations', () => {
beforeEach(() => {
fakeAnnotationsExporter = {
buildJSONExportContent: sinon.stub().returns({}),
buildTextExportContent: sinon.stub().returns(''),
};
fakeToastMessenger = {
error: sinon.stub(),
};
fakeDownloadJSONFile = sinon.stub();
fakeDownloadTextFile = sinon.stub();
fakeStore = {
defaultAuthority: sinon.stub().returns('example.com'),
isFeatureEnabled: sinon.stub().returns(true),
......@@ -59,8 +62,9 @@ describe('ExportAnnotations', () => {
$imports.$mock(mockImportedComponents());
$imports.$mock({
'../../../shared/download-json-file': {
'../../../shared/download-file': {
downloadJSONFile: fakeDownloadJSONFile,
downloadTextFile: fakeDownloadTextFile,
},
'../../helpers/export-annotations': {
suggestedFilename: fakeSuggestedFilename,
......@@ -226,11 +230,44 @@ describe('ExportAnnotations', () => {
});
});
it('lists supported export formats', async () => {
const wrapper = createComponent();
const select = await waitForElement(
wrapper,
'[data-testid="export-format-select"]',
);
const options = select.find(SelectNext.Option);
assert.equal(options.length, 2);
assert.equal(options.at(0).text(), 'JSON');
assert.equal(options.at(1).text(), 'Text');
});
describe('export form submitted', () => {
const submitExportForm = wrapper =>
wrapper.find('[data-testid="export-form"]').simulate('submit');
const selectExportFormat = async (wrapper, format) => {
const select = await waitForElement(
wrapper,
'[data-testid="export-format-select"]',
);
select.props().onChange({ value: format });
wrapper.update();
};
it('builds an export file from all non-draft annotations', () => {
[
{
format: 'json',
getExpectedInvokedContentBuilder: () =>
fakeAnnotationsExporter.buildJSONExportContent,
},
{
format: 'txt',
getExpectedInvokedContentBuilder: () =>
fakeAnnotationsExporter.buildTextExportContent,
},
].forEach(({ format, getExpectedInvokedContentBuilder }) => {
it('builds an export file from all non-draft annotations', async () => {
const wrapper = createComponent();
const annotationsToExport = [
fixtures.oldAnnotation(),
......@@ -238,15 +275,16 @@ describe('ExportAnnotations', () => {
];
fakeStore.savedAnnotations.returns(annotationsToExport);
await selectExportFormat(wrapper, format);
submitExportForm(wrapper);
assert.calledOnce(fakeAnnotationsExporter.buildJSONExportContent);
assert.calledWith(
fakeAnnotationsExporter.buildJSONExportContent,
annotationsToExport,
);
const invokedContentBuilder = getExpectedInvokedContentBuilder();
assert.calledOnce(invokedContentBuilder);
assert.calledWith(invokedContentBuilder, annotationsToExport);
assert.notCalled(fakeToastMessenger.error);
});
});
it('builds an export file from selected user annotations', async () => {
const selectedUserAnnotations = [
......@@ -305,7 +343,17 @@ describe('ExportAnnotations', () => {
);
});
it('downloads a file using user-entered filename appended with `.json`', () => {
[
{
format: 'json',
getExpectedInvokedDownloader: () => fakeDownloadJSONFile,
},
{
format: 'txt',
getExpectedInvokedDownloader: () => fakeDownloadTextFile,
},
].forEach(({ format, getExpectedInvokedDownloader }) => {
it('downloads a file using user-entered filename appended with proper extension', async () => {
const wrapper = createComponent();
const filenameInput = wrapper.find(
'input[data-testid="export-filename"]',
......@@ -314,15 +362,19 @@ describe('ExportAnnotations', () => {
filenameInput.getDOMNode().value = 'my-filename';
filenameInput.simulate('change');
await selectExportFormat(wrapper, format);
submitExportForm(wrapper);
assert.calledOnce(fakeDownloadJSONFile);
const invokedDownloader = getExpectedInvokedDownloader();
assert.calledOnce(invokedDownloader);
assert.calledWith(
fakeDownloadJSONFile,
sinon.match.object,
'my-filename.json',
invokedDownloader,
sinon.match.any,
`my-filename.${format}`,
);
});
});
context('when exporting annotations fails', () => {
it('displays error toast message', () => {
......
......@@ -20,7 +20,9 @@ export type DocumentMetadata = {
/**
* Extract document metadata from an annotation.
*/
export function documentMetadata(annotation: Annotation): DocumentMetadata {
export function documentMetadata(
annotation: APIAnnotationData,
): DocumentMetadata {
const uri = annotation.uri;
let domain;
......
import { isObject } from '../../shared/is-object';
import { readJSONFile } from '../../shared/read-json-file';
import type { APIAnnotationData } from '../../types/api';
import type { ExportContent } from '../services/annotations-exporter';
import type { JSONExportContent } from '../services/annotations-exporter';
/**
* Parse a file generated by the annotation exporter and return the extracted
......@@ -21,5 +21,5 @@ export async function readExportFile(file: File): Promise<APIAnnotationData[]> {
throw new Error('Not a valid Hypothesis JSON file');
}
return (json as ExportContent).annotations;
return (json as JSONExportContent).annotations;
}
import { trimAndDedent } from '../../shared/trim-and-dedent';
import type { APIAnnotationData } from '../../types/api';
import { username } from '../helpers/account-id';
import {
documentMetadata,
isReply,
quote,
} from '../helpers/annotation-metadata';
import { stripInternalProperties } from '../helpers/strip-internal-properties';
import { VersionData } from '../helpers/version-data';
import type { SidebarStore } from '../store';
export type ExportContent = {
export type JSONExportContent = {
export_date: string;
export_userid: string;
client_version: string;
......@@ -26,7 +33,7 @@ export class AnnotationsExporter {
annotations: APIAnnotationData[],
/* istanbul ignore next - test seam */
now = new Date(),
): ExportContent {
): JSONExportContent {
const profile = this._store.profile();
const versionData = new VersionData(profile, []);
......@@ -39,4 +46,50 @@ export class AnnotationsExporter {
) as APIAnnotationData[],
};
}
buildTextExportContent(
annotations: APIAnnotationData[],
groupName = '',
/* istanbul ignore next - test seam */
now = new Date(),
): string {
const [firstAnnotation] = annotations;
if (!firstAnnotation) {
throw new Error('No annotations to export');
}
const { uri, title } = documentMetadata(firstAnnotation);
const uniqueUsers = [
...new Set(
annotations
.map(annotation => username(annotation.user))
.filter(Boolean),
),
];
const annotationsText = annotations
.map(
(annotation, index) =>
trimAndDedent`
Annotation ${index + 1}:
${annotation.created}
${annotation.text}
${username(annotation.user)}
"${quote(annotation)}"
Tags: ${annotation.tags.join(', ')}`,
)
.join('\n\n');
return trimAndDedent`
${now.toISOString()}
${title}
${uri}
Group: ${groupName}
Total users: ${uniqueUsers.length}
Users: ${uniqueUsers.join(', ')}
Total annotations: ${annotations.length}
Total replies: ${annotations.filter(isReply).length}
${annotationsText}`;
}
}
import { publicAnnotation } from '../../test/annotation-fixtures';
import {
newAnnotation,
newReply,
publicAnnotation,
} from '../../test/annotation-fixtures';
import { AnnotationsExporter } from '../annotations-exporter';
describe('AnnotationsExporter', () => {
let fakeStore;
let now;
let exporter;
beforeEach(() => {
fakeStore = {
profile: sinon.stub().returns({ userid: 'userId' }),
};
now = new Date();
exporter = new AnnotationsExporter(fakeStore);
});
it('generates export content with provided annotations', () => {
const now = new Date();
it('generates JSON content with provided annotations', () => {
const firstBaseAnnotation = publicAnnotation();
const secondBaseAnnotation = publicAnnotation();
const annotations = [
......@@ -36,4 +42,84 @@ describe('AnnotationsExporter', () => {
annotations: [firstBaseAnnotation, secondBaseAnnotation],
});
});
describe('buildTextExportContent', () => {
it('throws error when empty list of annotations is provided', () => {
assert.throws(
() => exporter.buildTextExportContent([]),
'No annotations to export',
);
});
it('generates text content with provided annotations', () => {
const isoDate = now.toISOString();
const annotation = {
...newAnnotation(),
...publicAnnotation(),
created: isoDate,
};
// Title should actually be an array
annotation.document.title = [annotation.document.title];
const annotations = [
annotation,
annotation,
{
...annotation,
user: 'acct:jane@localhost',
tags: ['foo', 'bar'],
},
{
...annotation,
...newReply(),
},
];
const groupName = 'My group';
const result = exporter.buildTextExportContent(
annotations,
groupName,
now,
);
assert.equal(
result,
`${isoDate}
A special document
http://example.com
Group: ${groupName}
Total users: 2
Users: bill, jane
Total annotations: 4
Total replies: 1
Annotation 1:
${isoDate}
Annotation text
bill
"null"
Tags: tag_1, tag_2
Annotation 2:
${isoDate}
Annotation text
bill
"null"
Tags: tag_1, tag_2
Annotation 3:
${isoDate}
Annotation text
jane
"null"
Tags: foo, bar
Annotation 4:
${isoDate}
Annotation text
bill
"null"
Tags: tag_1, tag_2`,
);
});
});
});
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