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(
}
/**
* Download a text file containing data
* Download a text file containing text
*/
export function downloadTextFile(
text: string,
......@@ -49,3 +49,12 @@ export function downloadTextFile(
) {
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', () => {
let fakeLink;
......@@ -60,4 +64,13 @@ describe('download-file', () => {
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 {
import { useCallback, useId, useMemo, useState } from 'preact/hooks';
import {
downloadCSVFile,
downloadJSONFile,
downloadTextFile,
} from '../../../shared/download-file';
......@@ -44,13 +45,13 @@ const exportFormats: ExportFormat[] = [
value: 'txt',
name: 'Text',
},
{
value: 'csv',
name: 'CSV',
},
// TODO Enable these formats when implemented
// {
// value: 'csv',
// name: 'CSV',
// },
// {
// value: 'html',
// name: 'HTML',
// },
......@@ -151,6 +152,18 @@ function ExportAnnotations({
downloadTextFile(exportData, filename);
break;
}
case 'csv': {
const exportData = annotationsExporter.buildCSVExportContent(
annotationsToExport,
{
groupName: group?.name,
defaultAuthority,
displayNamesEnabled,
},
);
downloadCSVFile(exportData, filename);
break;
}
}
} catch (e) {
toastMessenger.error('Exporting annotations failed');
......
......@@ -16,6 +16,7 @@ describe('ExportAnnotations', () => {
let fakeToastMessenger;
let fakeDownloadJSONFile;
let fakeDownloadTextFile;
let fakeDownloadCSVFile;
let fakeSuggestedFilename;
const fakePrivateGroup = {
......@@ -37,12 +38,14 @@ describe('ExportAnnotations', () => {
fakeAnnotationsExporter = {
buildJSONExportContent: sinon.stub().returns({}),
buildTextExportContent: sinon.stub().returns(''),
buildCSVExportContent: sinon.stub().returns(''),
};
fakeToastMessenger = {
error: sinon.stub(),
};
fakeDownloadJSONFile = sinon.stub();
fakeDownloadTextFile = sinon.stub();
fakeDownloadCSVFile = sinon.stub();
fakeStore = {
defaultAuthority: sinon.stub().returns('example.com'),
isFeatureEnabled: sinon.stub().returns(true),
......@@ -65,6 +68,7 @@ describe('ExportAnnotations', () => {
'../../../shared/download-file': {
downloadJSONFile: fakeDownloadJSONFile,
downloadTextFile: fakeDownloadTextFile,
downloadCSVFile: fakeDownloadCSVFile,
},
'../../helpers/export-annotations': {
suggestedFilename: fakeSuggestedFilename,
......@@ -238,9 +242,10 @@ describe('ExportAnnotations', () => {
);
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(1).text(), 'Text');
assert.equal(options.at(2).text(), 'CSV');
});
describe('export form submitted', () => {
......@@ -266,6 +271,11 @@ describe('ExportAnnotations', () => {
getExpectedInvokedContentBuilder: () =>
fakeAnnotationsExporter.buildTextExportContent,
},
{
format: 'csv',
getExpectedInvokedContentBuilder: () =>
fakeAnnotationsExporter.buildCSVExportContent,
},
].forEach(({ format, getExpectedInvokedContentBuilder }) => {
it('builds an export file from all non-draft annotations', async () => {
const wrapper = createComponent();
......@@ -352,6 +362,10 @@ describe('ExportAnnotations', () => {
format: 'txt',
getExpectedInvokedDownloader: () => fakeDownloadTextFile,
},
{
format: 'csv',
getExpectedInvokedDownloader: () => fakeDownloadCSVFile,
},
].forEach(({ format, getExpectedInvokedDownloader }) => {
it('downloads a file using user-entered filename appended with proper extension', async () => {
const wrapper = createComponent();
......
......@@ -22,7 +22,7 @@ export type JSONExportOptions = {
now?: Date;
};
export type TextExportOptions = {
export type ExportOptions = {
defaultAuthority?: string;
displayNamesEnabled?: boolean;
groupName?: string;
......@@ -63,22 +63,13 @@ export class AnnotationsExporter {
defaultAuthority = '',
/* istanbul ignore next - test seam */
now = new Date(),
}: TextExportOptions = {},
}: ExportOptions = {},
): string {
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 { uri, title, uniqueUsers, replies, extractUsername } =
this._exportCommon(annotations, {
displayNamesEnabled,
defaultAuthority,
});
const annotationsText = annotations
.map((annotation, index) => {
......@@ -108,8 +99,90 @@ export class AnnotationsExporter {
Total users: ${uniqueUsers.length}
Users: ${uniqueUsers.join(', ')}
Total annotations: ${annotations.length}
Total replies: ${annotations.filter(isReply).length}
Total replies: ${replies.length}
${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';
describe('AnnotationsExporter', () => {
let now;
let baseAnnotation;
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(() => {
now = new Date();
baseAnnotation = {
...newAnnotation(),
...publicAnnotation(),
created: now.toISOString(),
};
// Title should actually be an array
baseAnnotation.document.title = [baseAnnotation.document.title];
exporter = new AnnotationsExporter();
});
......@@ -45,18 +69,6 @@ describe('AnnotationsExporter', () => {
});
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', () => {
assert.throws(
() => exporter.buildTextExportContent([]),
......@@ -66,16 +78,6 @@ describe('AnnotationsExporter', () => {
it('generates text content with provided annotations', () => {
const isoDate = baseAnnotation.created;
const targetWithPageSelector = page => [
{
selector: [
{
type: 'PageSelector',
label: `${page}`,
},
],
},
];
const annotations = [
baseAnnotation,
baseAnnotation,
......@@ -87,15 +89,14 @@ describe('AnnotationsExporter', () => {
{
...baseAnnotation,
...newReply(),
target: targetWithPageSelector(23),
target: targetWithSelectors(pageSelector(23)),
},
{
...baseAnnotation,
tags: [],
target: targetWithPageSelector('iii'),
target: targetWithSelectors(pageSelector('iii')),
},
];
const groupName = 'My group';
const result = exporter.buildTextExportContent(annotations, {
groupName,
......@@ -159,13 +160,13 @@ Page: iii`,
},
};
const isoDate = annotation.created;
const groupName = 'My group';
const result = exporter.buildTextExportContent([annotation], {
displayNamesEnabled: true,
groupName,
now,
});
assert.equal(
result,
`${isoDate}
......@@ -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