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
6bd2b21f
Commit
6bd2b21f
authored
Jan 05, 2024
by
Alejandro Celaya
Committed by
Alejandro Celaya
Jan 08, 2024
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
Add logic to export annotations in CSV format
parent
d0ba7522
Changes
6
Hide whitespace changes
Inline
Side-by-side
Showing
6 changed files
with
239 additions
and
50 deletions
+239
-50
download-file.ts
src/shared/download-file.ts
+10
-1
download-file-test.js
src/shared/test/download-file-test.js
+14
-1
ExportAnnotations.tsx
src/sidebar/components/ShareDialog/ExportAnnotations.tsx
+17
-4
ExportAnnotations-test.js
...bar/components/ShareDialog/test/ExportAnnotations-test.js
+15
-1
annotations-exporter.ts
src/sidebar/services/annotations-exporter.ts
+90
-17
annotations-exporter-test.js
src/sidebar/services/test/annotations-exporter-test.js
+93
-26
No files found.
src/shared/download-file.ts
View file @
6bd2b21f
...
...
@@ -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
);
}
src/shared/test/download-file-test.js
View file @
6bd2b21f
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'
);
});
});
src/sidebar/components/ShareDialog/ExportAnnotations.tsx
View file @
6bd2b21f
...
...
@@ -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');
...
...
src/sidebar/components/ShareDialog/test/ExportAnnotations-test.js
View file @
6bd2b21f
...
...
@@ -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
();
...
...
src/sidebar/services/annotations-exporter.ts
View file @
6bd2b21f
...
...
@@ -22,7 +22,7 @@ export type JSONExportOptions = {
now
?:
Date
;
};
export
type
Text
ExportOptions
=
{
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
(),
}:
Text
ExportOptions
=
{},
}:
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
};
}
}
src/sidebar/services/test/annotations-exporter-test.js
View file @
6bd2b21f
...
...
@@ -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
:
targetWith
PageSelector
(
23
),
target
:
targetWith
Selectors
(
pageSelector
(
23
)
),
},
{
...
baseAnnotation
,
tags
:
[],
target
:
targetWith
PageSelector
(
'iii'
),
target
:
targetWith
Selectors
(
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",`
,
);
});
});
});
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