Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Submit feedback
Contribute to GitLab
Sign in
Toggle navigation
C
coopwire-hypothesis
Project
Project
Details
Activity
Releases
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
孙灵跃 Leon Sun
coopwire-hypothesis
Commits
bbf1eab4
Commit
bbf1eab4
authored
Jan 19, 2024
by
Alejandro Celaya
Committed by
Alejandro Celaya
Jan 22, 2024
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
Export CSV separated with tab when copied to clipboard
parent
9be0b64c
Changes
6
Hide whitespace changes
Inline
Side-by-side
Showing
6 changed files
with
145 additions
and
75 deletions
+145
-75
csv.ts
src/shared/csv.ts
+7
-5
csv-test.js
src/shared/test/csv-test.js
+5
-2
ExportAnnotations.tsx
src/sidebar/components/ShareDialog/ExportAnnotations.tsx
+7
-3
ExportAnnotations-test.js
...bar/components/ShareDialog/test/ExportAnnotations-test.js
+59
-21
annotations-exporter.tsx
src/sidebar/services/annotations-exporter.tsx
+19
-11
annotations-exporter-test.js
src/sidebar/services/test/annotations-exporter-test.js
+48
-33
No files found.
src/shared/csv.ts
View file @
bbf1eab4
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
;
}
}
src/shared/test/csv-test.js
View file @
bbf1eab4
...
@@ -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
);
});
});
});
});
});
});
src/sidebar/components/ShareDialog/ExportAnnotations.tsx
View file @
bbf1eab4
...
@@ -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') {
...
...
src/sidebar/components/ShareDialog/test/ExportAnnotations-test.js
View file @
bbf1eab4
...
@@ -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'
);
...
...
src/sidebar/services/annotations-exporter.tsx
View file @
bbf1eab4
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
Common
ExportOptions
=
{
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
=
{},
}:
Text
ExportOptions
=
{},
):
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
=
{},
}:
HTML
ExportOptions
=
{},
):
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
)
{
...
...
src/sidebar/services/test/annotations-exporter-test.js
View file @
bbf1eab4
...
@@ -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'
,
()
=>
{
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment