Commit 6bd2b21f authored by Alejandro Celaya's avatar Alejandro Celaya Committed by Alejandro Celaya

Add logic to export annotations in CSV format

parent d0ba7522
...@@ -39,7 +39,7 @@ export function downloadJSONFile( ...@@ -39,7 +39,7 @@ export function downloadJSONFile(
} }
/** /**
* Download a text file containing data * Download a text file containing text
*/ */
export function downloadTextFile( export function downloadTextFile(
text: string, text: string,
...@@ -49,3 +49,12 @@ export function downloadTextFile( ...@@ -49,3 +49,12 @@ export function downloadTextFile(
) { ) {
downloadFile(text, 'text/plain', filename, _document); downloadFile(text, 'text/plain', filename, _document);
} }
export function downloadCSVFile(
text: string,
filename: string,
/* istanbul ignore next - test seam */
_document = document,
) {
downloadFile(text, 'text/csv', filename, _document);
}
import { downloadJSONFile, downloadTextFile } from '../download-file'; import {
downloadCSVFile,
downloadJSONFile,
downloadTextFile,
} from '../download-file';
describe('download-file', () => { describe('download-file', () => {
let fakeLink; let fakeLink;
...@@ -60,4 +64,13 @@ describe('download-file', () => { ...@@ -60,4 +64,13 @@ describe('download-file', () => {
assertDownloadHappened(filename, data, 'text/plain'); assertDownloadHappened(filename, data, 'text/plain');
}); });
it('downloadCSVFile generates csv file with provided data', () => {
const data = 'foo,bar,baz';
const filename = 'my-file.csv';
downloadCSVFile(data, filename, fakeDocument);
assertDownloadHappened(filename, data, 'text/csv');
});
}); });
...@@ -8,6 +8,7 @@ import { ...@@ -8,6 +8,7 @@ import {
import { useCallback, useId, useMemo, useState } from 'preact/hooks'; import { useCallback, useId, useMemo, useState } from 'preact/hooks';
import { import {
downloadCSVFile,
downloadJSONFile, downloadJSONFile,
downloadTextFile, downloadTextFile,
} from '../../../shared/download-file'; } from '../../../shared/download-file';
...@@ -44,13 +45,13 @@ const exportFormats: ExportFormat[] = [ ...@@ -44,13 +45,13 @@ const exportFormats: ExportFormat[] = [
value: 'txt', value: 'txt',
name: 'Text', name: 'Text',
}, },
{
value: 'csv',
name: 'CSV',
},
// TODO Enable these formats when implemented // TODO Enable these formats when implemented
// { // {
// value: 'csv',
// name: 'CSV',
// },
// {
// value: 'html', // value: 'html',
// name: 'HTML', // name: 'HTML',
// }, // },
...@@ -151,6 +152,18 @@ function ExportAnnotations({ ...@@ -151,6 +152,18 @@ function ExportAnnotations({
downloadTextFile(exportData, filename); downloadTextFile(exportData, filename);
break; break;
} }
case 'csv': {
const exportData = annotationsExporter.buildCSVExportContent(
annotationsToExport,
{
groupName: group?.name,
defaultAuthority,
displayNamesEnabled,
},
);
downloadCSVFile(exportData, filename);
break;
}
} }
} catch (e) { } catch (e) {
toastMessenger.error('Exporting annotations failed'); toastMessenger.error('Exporting annotations failed');
......
...@@ -16,6 +16,7 @@ describe('ExportAnnotations', () => { ...@@ -16,6 +16,7 @@ describe('ExportAnnotations', () => {
let fakeToastMessenger; let fakeToastMessenger;
let fakeDownloadJSONFile; let fakeDownloadJSONFile;
let fakeDownloadTextFile; let fakeDownloadTextFile;
let fakeDownloadCSVFile;
let fakeSuggestedFilename; let fakeSuggestedFilename;
const fakePrivateGroup = { const fakePrivateGroup = {
...@@ -37,12 +38,14 @@ describe('ExportAnnotations', () => { ...@@ -37,12 +38,14 @@ describe('ExportAnnotations', () => {
fakeAnnotationsExporter = { fakeAnnotationsExporter = {
buildJSONExportContent: sinon.stub().returns({}), buildJSONExportContent: sinon.stub().returns({}),
buildTextExportContent: sinon.stub().returns(''), buildTextExportContent: sinon.stub().returns(''),
buildCSVExportContent: sinon.stub().returns(''),
}; };
fakeToastMessenger = { fakeToastMessenger = {
error: sinon.stub(), error: sinon.stub(),
}; };
fakeDownloadJSONFile = sinon.stub(); fakeDownloadJSONFile = sinon.stub();
fakeDownloadTextFile = sinon.stub(); fakeDownloadTextFile = sinon.stub();
fakeDownloadCSVFile = 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),
...@@ -65,6 +68,7 @@ describe('ExportAnnotations', () => { ...@@ -65,6 +68,7 @@ describe('ExportAnnotations', () => {
'../../../shared/download-file': { '../../../shared/download-file': {
downloadJSONFile: fakeDownloadJSONFile, downloadJSONFile: fakeDownloadJSONFile,
downloadTextFile: fakeDownloadTextFile, downloadTextFile: fakeDownloadTextFile,
downloadCSVFile: fakeDownloadCSVFile,
}, },
'../../helpers/export-annotations': { '../../helpers/export-annotations': {
suggestedFilename: fakeSuggestedFilename, suggestedFilename: fakeSuggestedFilename,
...@@ -238,9 +242,10 @@ describe('ExportAnnotations', () => { ...@@ -238,9 +242,10 @@ describe('ExportAnnotations', () => {
); );
const options = select.find(SelectNext.Option); const options = select.find(SelectNext.Option);
assert.equal(options.length, 2); assert.equal(options.length, 3);
assert.equal(options.at(0).text(), 'JSON'); assert.equal(options.at(0).text(), 'JSON');
assert.equal(options.at(1).text(), 'Text'); assert.equal(options.at(1).text(), 'Text');
assert.equal(options.at(2).text(), 'CSV');
}); });
describe('export form submitted', () => { describe('export form submitted', () => {
...@@ -266,6 +271,11 @@ describe('ExportAnnotations', () => { ...@@ -266,6 +271,11 @@ describe('ExportAnnotations', () => {
getExpectedInvokedContentBuilder: () => getExpectedInvokedContentBuilder: () =>
fakeAnnotationsExporter.buildTextExportContent, fakeAnnotationsExporter.buildTextExportContent,
}, },
{
format: 'csv',
getExpectedInvokedContentBuilder: () =>
fakeAnnotationsExporter.buildCSVExportContent,
},
].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();
...@@ -352,6 +362,10 @@ describe('ExportAnnotations', () => { ...@@ -352,6 +362,10 @@ describe('ExportAnnotations', () => {
format: 'txt', format: 'txt',
getExpectedInvokedDownloader: () => fakeDownloadTextFile, getExpectedInvokedDownloader: () => fakeDownloadTextFile,
}, },
{
format: 'csv',
getExpectedInvokedDownloader: () => fakeDownloadCSVFile,
},
].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();
......
...@@ -22,7 +22,7 @@ export type JSONExportOptions = { ...@@ -22,7 +22,7 @@ export type JSONExportOptions = {
now?: Date; now?: Date;
}; };
export type TextExportOptions = { export type ExportOptions = {
defaultAuthority?: string; defaultAuthority?: string;
displayNamesEnabled?: boolean; displayNamesEnabled?: boolean;
groupName?: string; groupName?: string;
...@@ -63,22 +63,13 @@ export class AnnotationsExporter { ...@@ -63,22 +63,13 @@ export class AnnotationsExporter {
defaultAuthority = '', defaultAuthority = '',
/* istanbul ignore next - test seam */ /* istanbul ignore next - test seam */
now = new Date(), now = new Date(),
}: TextExportOptions = {}, }: ExportOptions = {},
): string { ): string {
const [firstAnnotation] = annotations; const { uri, title, uniqueUsers, replies, extractUsername } =
if (!firstAnnotation) { this._exportCommon(annotations, {
throw new Error('No annotations to export'); displayNamesEnabled,
} defaultAuthority,
});
const extractUsername = (annotation: APIAnnotationData) =>
annotationDisplayName(annotation, defaultAuthority, displayNamesEnabled);
const { uri, title } = documentMetadata(firstAnnotation);
const uniqueUsers = [
...new Set(
annotations.map(anno => extractUsername(anno)).filter(Boolean),
),
];
const annotationsText = annotations const annotationsText = annotations
.map((annotation, index) => { .map((annotation, index) => {
...@@ -108,8 +99,90 @@ export class AnnotationsExporter { ...@@ -108,8 +99,90 @@ export class AnnotationsExporter {
Total users: ${uniqueUsers.length} Total users: ${uniqueUsers.length}
Users: ${uniqueUsers.join(', ')} Users: ${uniqueUsers.join(', ')}
Total annotations: ${annotations.length} Total annotations: ${annotations.length}
Total replies: ${annotations.filter(isReply).length} Total replies: ${replies.length}
${annotationsText}`; ${annotationsText}`;
} }
buildCSVExportContent(
annotations: APIAnnotationData[],
{
groupName = '',
defaultAuthority = '',
displayNamesEnabled = false,
}: Exclude<ExportOptions, 'now'> = {},
): string {
const { uri, extractUsername } = this._exportCommon(annotations, {
displayNamesEnabled,
defaultAuthority,
});
const escapeCSVValue = (value: string): string => {
// If the value contains a comma, newline or double quote, then wrap it in
// double quotes and escape any existing double quotes.
if (/[",\n]/.test(value)) {
return `"${value.replace(/"/g, '""')}"`;
}
return value;
};
const annotationToRow = (annotation: APIAnnotationData) =>
[
annotation.created,
uri,
groupName,
isReply(annotation) ? 'Reply' : 'Annotation',
quote(annotation) ?? '',
extractUsername(annotation),
annotation.text,
annotation.tags.join(','),
pageLabel(annotation) ?? '',
]
.map(escapeCSVValue)
.join(',');
const headers = [
'Creation Date',
'URL',
'Group',
'Annotation/Reply Type',
'Quote',
'User',
'Body',
'Tags',
'Page',
].join(',');
const annotationsContent = annotations
.map(anno => annotationToRow(anno))
.join('\n');
return `${headers}\n${annotationsContent}`;
}
private _exportCommon(
annotations: APIAnnotationData[],
{
displayNamesEnabled,
defaultAuthority,
}: Required<
Pick<ExportOptions, 'displayNamesEnabled' | 'defaultAuthority'>
>,
) {
const [firstAnnotation] = annotations;
if (!firstAnnotation) {
throw new Error('No annotations to export');
}
const extractUsername = (annotation: APIAnnotationData) =>
annotationDisplayName(annotation, defaultAuthority, displayNamesEnabled);
const { uri, title } = documentMetadata(firstAnnotation);
const uniqueUsers = [
...new Set(
annotations.map(anno => extractUsername(anno)).filter(Boolean),
),
];
const replies = annotations.filter(anno => isReply(anno));
return { uri, title, uniqueUsers, replies, extractUsername };
}
} }
...@@ -7,10 +7,34 @@ import { AnnotationsExporter } from '../annotations-exporter'; ...@@ -7,10 +7,34 @@ import { AnnotationsExporter } from '../annotations-exporter';
describe('AnnotationsExporter', () => { describe('AnnotationsExporter', () => {
let now; let now;
let baseAnnotation;
let exporter; let exporter;
const groupName = 'My group';
const pageSelector = page => ({
type: 'PageSelector',
label: `${page}`,
});
const quoteSelector = quote => ({
type: 'TextQuoteSelector',
exact: quote,
});
const targetWithSelectors = (...selectors) => [
{
selector: selectors,
},
];
beforeEach(() => { beforeEach(() => {
now = new Date(); now = new Date();
baseAnnotation = {
...newAnnotation(),
...publicAnnotation(),
created: now.toISOString(),
};
// Title should actually be an array
baseAnnotation.document.title = [baseAnnotation.document.title];
exporter = new AnnotationsExporter(); exporter = new AnnotationsExporter();
}); });
...@@ -45,18 +69,6 @@ describe('AnnotationsExporter', () => { ...@@ -45,18 +69,6 @@ describe('AnnotationsExporter', () => {
}); });
describe('buildTextExportContent', () => { describe('buildTextExportContent', () => {
let baseAnnotation;
beforeEach(() => {
baseAnnotation = {
...newAnnotation(),
...publicAnnotation(),
created: now.toISOString(),
};
// Title should actually be an array
baseAnnotation.document.title = [baseAnnotation.document.title];
});
it('throws error when empty list of annotations is provided', () => { it('throws error when empty list of annotations is provided', () => {
assert.throws( assert.throws(
() => exporter.buildTextExportContent([]), () => exporter.buildTextExportContent([]),
...@@ -66,16 +78,6 @@ describe('AnnotationsExporter', () => { ...@@ -66,16 +78,6 @@ describe('AnnotationsExporter', () => {
it('generates text content with provided annotations', () => { it('generates text content with provided annotations', () => {
const isoDate = baseAnnotation.created; const isoDate = baseAnnotation.created;
const targetWithPageSelector = page => [
{
selector: [
{
type: 'PageSelector',
label: `${page}`,
},
],
},
];
const annotations = [ const annotations = [
baseAnnotation, baseAnnotation,
baseAnnotation, baseAnnotation,
...@@ -87,15 +89,14 @@ describe('AnnotationsExporter', () => { ...@@ -87,15 +89,14 @@ describe('AnnotationsExporter', () => {
{ {
...baseAnnotation, ...baseAnnotation,
...newReply(), ...newReply(),
target: targetWithPageSelector(23), target: targetWithSelectors(pageSelector(23)),
}, },
{ {
...baseAnnotation, ...baseAnnotation,
tags: [], tags: [],
target: targetWithPageSelector('iii'), target: targetWithSelectors(pageSelector('iii')),
}, },
]; ];
const groupName = 'My group';
const result = exporter.buildTextExportContent(annotations, { const result = exporter.buildTextExportContent(annotations, {
groupName, groupName,
...@@ -159,13 +160,13 @@ Page: iii`, ...@@ -159,13 +160,13 @@ Page: iii`,
}, },
}; };
const isoDate = annotation.created; const isoDate = annotation.created;
const groupName = 'My group';
const result = exporter.buildTextExportContent([annotation], { const result = exporter.buildTextExportContent([annotation], {
displayNamesEnabled: true, displayNamesEnabled: true,
groupName, groupName,
now, now,
}); });
assert.equal( assert.equal(
result, result,
`${isoDate} `${isoDate}
...@@ -186,4 +187,70 @@ Tags: tag_1, tag_2`, ...@@ -186,4 +187,70 @@ Tags: tag_1, tag_2`,
); );
}); });
}); });
describe('buildCSVExportContent', () => {
it('throws error when empty list of annotations is provided', () => {
assert.throws(
() => exporter.buildCSVExportContent([]),
'No annotations to export',
);
});
it('generates CSV content with expected annotations', () => {
const isoDate = baseAnnotation.created;
const annotations = [
{
...baseAnnotation,
user: 'acct:jane@localhost',
tags: ['foo', 'bar'],
},
{
...baseAnnotation,
...newReply(),
target: targetWithSelectors(
quoteSelector('includes "double quotes", and commas'),
pageSelector(23),
),
},
{
...baseAnnotation,
tags: [],
target: targetWithSelectors(pageSelector('iii')),
},
];
const result = exporter.buildCSVExportContent(annotations, {
groupName,
});
assert.equal(
result,
`Creation Date,URL,Group,Annotation/Reply Type,Quote,User,Body,Tags,Page
${isoDate},http://example.com,My group,Annotation,,jane,Annotation text,"foo,bar",
${isoDate},http://example.com,My group,Reply,"includes ""double quotes"", and commas",bill,Annotation text,"tag_1,tag_2",23
${isoDate},http://example.com,My group,Annotation,,bill,Annotation text,,iii`,
);
});
it('uses display names if `displayNamesEnabled` is set', () => {
const annotation = {
...baseAnnotation,
user_info: {
display_name: 'John Doe',
},
};
const isoDate = annotation.created;
const result = exporter.buildCSVExportContent([annotation], {
displayNamesEnabled: true,
groupName,
});
assert.equal(
result,
`Creation Date,URL,Group,Annotation/Reply Type,Quote,User,Body,Tags,Page
${isoDate},http://example.com,My group,Annotation,,John Doe,Annotation text,"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