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

Export CSV separated with tab when copied to clipboard

parent 9be0b64c
export type CSVSeparator = ',' | '\t';
/** /**
* Escape a CSV field value (see https://www.ietf.org/rfc/rfc4180.txt) * Escape a CSV field value (see https://www.ietf.org/rfc/rfc4180.txt)
* *
* - foo -> foo * - foo -> foo
* - foo,bar -> "foo,bar" * - foo,bar -> "foo,bar"
* - with "quoted" text -> "with ""quoted"" text" * - with "quoted" text -> "with ""quoted"" text"
*
* @param separator - Indicates the separator used in the CSV
*/ */
export function escapeCSVValue(value: string): string { export function escapeCSVValue(value: string, separator: CSVSeparator): string {
if (/[",\n\r]/.test(value)) { const regexp = new RegExp(`["\n\r${separator}]`);
return `"${value.replace(/"/g, '""')}"`; return regexp.test(value) ? `"${value.replace(/"/g, '""')}"` : value;
}
return value;
} }
...@@ -4,6 +4,7 @@ describe('escapeCSVValue', () => { ...@@ -4,6 +4,7 @@ describe('escapeCSVValue', () => {
[ [
{ value: 'foo', expected: 'foo' }, { value: 'foo', expected: 'foo' },
{ value: 'foo,bar', expected: '"foo,bar"' }, { value: 'foo,bar', expected: '"foo,bar"' },
{ value: 'foo,bar', expected: 'foo,bar', separator: '\t' },
{ value: 'with \r carriage return', expected: '"with \r carriage return"' }, { value: 'with \r carriage return', expected: '"with \r carriage return"' },
{ {
value: `multiple value: `multiple
...@@ -12,9 +13,11 @@ describe('escapeCSVValue', () => { ...@@ -12,9 +13,11 @@ describe('escapeCSVValue', () => {
lines"`, lines"`,
}, },
{ value: 'with "quotes"', expected: '"with ""quotes"""' }, { value: 'with "quotes"', expected: '"with ""quotes"""' },
].forEach(({ value, expected }) => { { value: 'foo\tbar', expected: 'foo\tbar' },
{ value: 'foo\tbar', expected: '"foo\tbar"', separator: '\t' },
].forEach(({ value, expected, separator = ',' }) => {
it('escapes values', () => { it('escapes values', () => {
assert.equal(escapeCSVValue(value), expected); assert.equal(escapeCSVValue(value, separator), expected);
}); });
}); });
}); });
...@@ -148,7 +148,7 @@ function ExportAnnotations({ ...@@ -148,7 +148,7 @@ function ExportAnnotations({
const [customFilename, setCustomFilename] = useState<string>(); const [customFilename, setCustomFilename] = useState<string>();
const buildExportContent = useCallback( const buildExportContent = useCallback(
(format: ExportFormat['value']): string => { (format: ExportFormat['value'], context: 'file' | 'clipboard'): string => {
const annotationsToExport = const annotationsToExport =
selectedUserAnnotations?.annotations ?? exportableAnnotations; selectedUserAnnotations?.annotations ?? exportableAnnotations;
switch (format) { switch (format) {
...@@ -173,6 +173,10 @@ function ExportAnnotations({ ...@@ -173,6 +173,10 @@ function ExportAnnotations({
return annotationsExporter.buildCSVExportContent( return annotationsExporter.buildCSVExportContent(
annotationsToExport, annotationsToExport,
{ {
// We want to use tabs when copying to clipboard, so that it's
// possible to paste in apps like Google Sheets or OneDrive Excel.
// They do not properly populate a grid for comma-based CSV.
separator: context === 'file' ? ',' : '\t',
groupName: group?.name, groupName: group?.name,
defaultAuthority, defaultAuthority,
displayNamesEnabled, displayNamesEnabled,
...@@ -211,7 +215,7 @@ function ExportAnnotations({ ...@@ -211,7 +215,7 @@ function ExportAnnotations({
try { try {
const format = exportFormat.value; const format = exportFormat.value;
const filename = `${customFilename ?? defaultFilename}.${format}`; const filename = `${customFilename ?? defaultFilename}.${format}`;
const exportData = buildExportContent(format); const exportData = buildExportContent(format, 'file');
const mimeType = formatToMimeType(format); const mimeType = formatToMimeType(format);
downloadFile(exportData, mimeType, filename); downloadFile(exportData, mimeType, filename);
...@@ -231,7 +235,7 @@ function ExportAnnotations({ ...@@ -231,7 +235,7 @@ function ExportAnnotations({
); );
const copyAnnotationsExport = useCallback(async () => { const copyAnnotationsExport = useCallback(async () => {
const format = exportFormat.value; const format = exportFormat.value;
const exportData = buildExportContent(format); const exportData = buildExportContent(format, 'clipboard');
try { try {
if (format === 'html') { if (format === 'html') {
......
...@@ -302,23 +302,28 @@ describe('ExportAnnotations', () => { ...@@ -302,23 +302,28 @@ describe('ExportAnnotations', () => {
[ [
{ {
format: 'json', format: 'json',
getExpectedInvokedContentBuilder: () => getExpectedInvokedContentBuilder: () => [
fakeAnnotationsExporter.buildJSONExportContent, fakeAnnotationsExporter.buildJSONExportContent,
],
}, },
{ {
format: 'txt', format: 'txt',
getExpectedInvokedContentBuilder: () => getExpectedInvokedContentBuilder: () => [
fakeAnnotationsExporter.buildTextExportContent, fakeAnnotationsExporter.buildTextExportContent,
],
}, },
{ {
format: 'csv', format: 'csv',
getExpectedInvokedContentBuilder: () => getExpectedInvokedContentBuilder: () => [
fakeAnnotationsExporter.buildCSVExportContent, fakeAnnotationsExporter.buildCSVExportContent,
sinon.match({ separator: ',' }),
],
}, },
{ {
format: 'html', format: 'html',
getExpectedInvokedContentBuilder: () => getExpectedInvokedContentBuilder: () => [
fakeAnnotationsExporter.buildHTMLExportContent, 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 () => {
...@@ -333,9 +338,14 @@ describe('ExportAnnotations', () => { ...@@ -333,9 +338,14 @@ describe('ExportAnnotations', () => {
submitExportForm(wrapper); submitExportForm(wrapper);
const invokedContentBuilder = getExpectedInvokedContentBuilder(); const [invokedContentBuilder, contentBuilderOptions] =
getExpectedInvokedContentBuilder();
assert.calledOnce(invokedContentBuilder); assert.calledOnce(invokedContentBuilder);
assert.calledWith(invokedContentBuilder, annotationsToExport); assert.calledWith(
invokedContentBuilder,
annotationsToExport,
contentBuilderOptions ?? sinon.match.any,
);
assert.notCalled(fakeToastMessenger.error); assert.notCalled(fakeToastMessenger.error);
}); });
}); });
...@@ -472,35 +482,63 @@ describe('ExportAnnotations', () => { ...@@ -472,35 +482,63 @@ describe('ExportAnnotations', () => {
{ {
format: 'json', format: 'json',
getExpectedInvokedCallback: () => fakeCopyPlainText, getExpectedInvokedCallback: () => fakeCopyPlainText,
getExpectedInvokedContentBuilder: () => [
fakeAnnotationsExporter.buildJSONExportContent,
],
}, },
{ {
format: 'txt', format: 'txt',
getExpectedInvokedCallback: () => fakeCopyPlainText, getExpectedInvokedCallback: () => fakeCopyPlainText,
getExpectedInvokedContentBuilder: () => [
fakeAnnotationsExporter.buildTextExportContent,
],
}, },
{ {
format: 'csv', format: 'csv',
getExpectedInvokedCallback: () => fakeCopyPlainText, getExpectedInvokedCallback: () => fakeCopyPlainText,
getExpectedInvokedContentBuilder: () => [
fakeAnnotationsExporter.buildCSVExportContent,
sinon.match({ separator: '\t' }),
],
}, },
{ {
format: 'html', format: 'html',
getExpectedInvokedCallback: () => fakeCopyHTML, getExpectedInvokedCallback: () => fakeCopyHTML,
getExpectedInvokedContentBuilder: () => [
fakeAnnotationsExporter.buildHTMLExportContent,
],
}, },
].forEach(({ format, getExpectedInvokedCallback }) => { ].forEach(
it('copies export content as rich or plain text depending on format', async () => { ({
fakeStore.isFeatureEnabled.callsFake(ff => ff === 'export_formats'); format,
getExpectedInvokedCallback,
const wrapper = createComponent(); getExpectedInvokedContentBuilder,
const copyButton = wrapper.find('button[data-testid="copy-button"]'); }) => {
it('copies export content as rich or plain text depending on format', async () => {
await selectExportFormat(wrapper, format); fakeStore.isFeatureEnabled.callsFake(ff => ff === 'export_formats');
await act(() => {
copyButton.simulate('click'); const wrapper = createComponent();
const copyButton = wrapper.find('button[data-testid="copy-button"]');
await selectExportFormat(wrapper, format);
await act(() => {
copyButton.simulate('click');
});
assert.called(getExpectedInvokedCallback());
assert.calledWith(fakeToastMessenger.success, 'Annotations copied');
const [invokedContentBuilder, contentBuilderOptions] =
getExpectedInvokedContentBuilder();
assert.calledOnce(invokedContentBuilder);
assert.calledWith(
invokedContentBuilder,
sinon.match.any,
contentBuilderOptions ?? sinon.match.any,
);
}); });
},
assert.called(getExpectedInvokedCallback()); );
assert.calledWith(fakeToastMessenger.success, 'Annotations copied');
});
});
it('adds error toast message when copying annotations fails', async () => { it('adds error toast message when copying annotations fails', async () => {
fakeStore.isFeatureEnabled.callsFake(ff => ff === 'export_formats'); fakeStore.isFeatureEnabled.callsFake(ff => ff === 'export_formats');
......
import renderToString from 'preact-render-to-string/jsx'; import renderToString from 'preact-render-to-string/jsx';
import type { CSVSeparator } from '../../shared/csv';
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';
...@@ -27,13 +28,22 @@ export type JSONExportOptions = { ...@@ -27,13 +28,22 @@ export type JSONExportOptions = {
now?: Date; now?: Date;
}; };
export type ExportOptions = { type CommonExportOptions = {
defaultAuthority?: string; defaultAuthority?: string;
displayNamesEnabled?: boolean; displayNamesEnabled?: boolean;
groupName?: string; groupName?: string;
};
export type TextExportOptions = CommonExportOptions & {
now?: Date; now?: Date;
}; };
export type CSVExportOptions = CommonExportOptions & {
separator?: CSVSeparator;
};
export type HTMLExportOptions = TextExportOptions;
/** /**
* Generates annotations exports * Generates annotations exports
* *
...@@ -68,7 +78,7 @@ export class AnnotationsExporter { ...@@ -68,7 +78,7 @@ export class AnnotationsExporter {
defaultAuthority = '', defaultAuthority = '',
/* istanbul ignore next - test seam */ /* istanbul ignore next - test seam */
now = new Date(), now = new Date(),
}: ExportOptions = {}, }: TextExportOptions = {},
): string { ): string {
const { uri, title, uniqueUsers, replies, extractUsername } = const { uri, title, uniqueUsers, replies, extractUsername } =
this._exportCommon(annotations, { this._exportCommon(annotations, {
...@@ -115,13 +125,13 @@ export class AnnotationsExporter { ...@@ -115,13 +125,13 @@ export class AnnotationsExporter {
groupName = '', groupName = '',
defaultAuthority = '', defaultAuthority = '',
displayNamesEnabled = false, displayNamesEnabled = false,
}: Exclude<ExportOptions, 'now'> = {}, separator = ',',
}: CSVExportOptions = {},
): string { ): string {
const { uri, extractUsername } = this._exportCommon(annotations, { const { uri, extractUsername } = this._exportCommon(annotations, {
displayNamesEnabled, displayNamesEnabled,
defaultAuthority, defaultAuthority,
}); });
const annotationToRow = (annotation: APIAnnotationData) => const annotationToRow = (annotation: APIAnnotationData) =>
[ [
formatDateTime(new Date(annotation.created)), formatDateTime(new Date(annotation.created)),
...@@ -134,8 +144,8 @@ export class AnnotationsExporter { ...@@ -134,8 +144,8 @@ export class AnnotationsExporter {
annotation.text, annotation.text,
annotation.tags.join(','), annotation.tags.join(','),
] ]
.map(escapeCSVValue) .map(value => escapeCSVValue(value, separator))
.join(','); .join(separator);
const headers = [ const headers = [
'Created at', 'Created at',
...@@ -147,7 +157,7 @@ export class AnnotationsExporter { ...@@ -147,7 +157,7 @@ export class AnnotationsExporter {
'Quote', 'Quote',
'Comment', 'Comment',
'Tags', 'Tags',
].join(','); ].join(separator);
const annotationsContent = annotations const annotationsContent = annotations
.map(anno => annotationToRow(anno)) .map(anno => annotationToRow(anno))
.join('\n'); .join('\n');
...@@ -163,7 +173,7 @@ export class AnnotationsExporter { ...@@ -163,7 +173,7 @@ export class AnnotationsExporter {
defaultAuthority = '', defaultAuthority = '',
/* istanbul ignore next - test seam */ /* istanbul ignore next - test seam */
now = new Date(), now = new Date(),
}: ExportOptions = {}, }: HTMLExportOptions = {},
): string { ): string {
const { uri, title, uniqueUsers, replies, extractUsername } = const { uri, title, uniqueUsers, replies, extractUsername } =
this._exportCommon(annotations, { this._exportCommon(annotations, {
...@@ -292,9 +302,7 @@ export class AnnotationsExporter { ...@@ -292,9 +302,7 @@ export class AnnotationsExporter {
{ {
displayNamesEnabled, displayNamesEnabled,
defaultAuthority, defaultAuthority,
}: Required< }: Required<Omit<CommonExportOptions, 'groupName'>>,
Pick<ExportOptions, 'displayNamesEnabled' | 'defaultAuthority'>
>,
) { ) {
const [firstAnnotation] = annotations; const [firstAnnotation] = annotations;
if (!firstAnnotation) { if (!firstAnnotation) {
......
...@@ -204,41 +204,56 @@ Tags: tag_1, tag_2`, ...@@ -204,41 +204,56 @@ Tags: tag_1, tag_2`,
); );
}); });
it('generates CSV content with expected annotations', () => { [
const annotations = [ {
{ separator: ',',
...baseAnnotation, buildExpectedContent:
user: 'acct:jane@localhost', date => `Created at,Author,Page,URL,Group,Type,Quote,Comment,Tags
tags: ['foo', 'bar'], ${date},jane,,http://example.com,My group,Annotation,includes \t tabs,Annotation text,"foo,bar"
}, ${date},bill,23,http://example.com,My group,Reply,"includes ""double quotes"", and commas",Annotation text,"tag_1,tag_2"
{ ${date},bill,iii,http://example.com,My group,Highlight,,Annotation text,`,
...baseAnnotation, },
...newReply(), {
target: targetWithSelectors( separator: '\t',
quoteSelector('includes "double quotes", and commas'), buildExpectedContent:
pageSelector(23), date => `Created at\tAuthor\tPage\tURL\tGroup\tType\tQuote\tComment\tTags
), ${date}\tjane\t\thttp://example.com\tMy group\tAnnotation\t"includes \t tabs"\tAnnotation text\tfoo,bar
}, ${date}\tbill\t23\thttp://example.com\tMy group\tReply\t"includes ""double quotes"", and commas"\tAnnotation text\ttag_1,tag_2
{ ${date}\tbill\tiii\thttp://example.com\tMy group\tHighlight\t\tAnnotation text\t`,
...baseAnnotation, },
...newHighlight(), ].forEach(({ separator, buildExpectedContent }) => {
tags: [], it('generates CSV content with expected annotations and separator', () => {
target: targetWithSelectors(pageSelector('iii')), const annotations = [
}, {
]; ...baseAnnotation,
user: 'acct:jane@localhost',
tags: ['foo', 'bar'],
target: targetWithSelectors(quoteSelector('includes \t tabs')),
},
{
...baseAnnotation,
...newReply(),
target: targetWithSelectors(
quoteSelector('includes "double quotes", and commas'),
pageSelector(23),
),
},
{
...baseAnnotation,
...newHighlight(),
tags: [],
target: targetWithSelectors(pageSelector('iii')),
},
];
const result = exporter.buildCSVExportContent(annotations, { const result = exporter.buildCSVExportContent(annotations, {
groupName, groupName,
now, now,
}); separator,
});
assert.equal( assert.equal(result, buildExpectedContent(formattedNow));
result, });
`Created at,Author,Page,URL,Group,Type,Quote,Comment,Tags
${formattedNow},jane,,http://example.com,My group,Annotation,,Annotation text,"foo,bar"
${formattedNow},bill,23,http://example.com,My group,Reply,"includes ""double quotes"", and commas",Annotation text,"tag_1,tag_2"
${formattedNow},bill,iii,http://example.com,My group,Highlight,,Annotation text,`,
);
}); });
it('uses display names if `displayNamesEnabled` is set', () => { it('uses display names if `displayNamesEnabled` is set', () => {
......
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