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
8e1232ee
Commit
8e1232ee
authored
Dec 14, 2023
by
Alejandro Celaya
Committed by
Alejandro Celaya
Dec 15, 2023
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
Add support to export annotations in text format
parent
393a2aca
Changes
8
Show whitespace changes
Inline
Side-by-side
Showing
8 changed files
with
334 additions
and
55 deletions
+334
-55
download-file.ts
src/shared/download-file.ts
+51
-0
download-file-test.js
src/shared/test/download-file-test.js
+29
-9
ExportAnnotations.tsx
src/sidebar/components/ShareDialog/ExportAnnotations.tsx
+24
-9
ExportAnnotations-test.js
...bar/components/ShareDialog/test/ExportAnnotations-test.js
+81
-29
annotation-metadata.ts
src/sidebar/helpers/annotation-metadata.ts
+3
-1
import.ts
src/sidebar/helpers/import.ts
+2
-2
annotations-exporter.ts
src/sidebar/services/annotations-exporter.ts
+55
-2
annotations-exporter-test.js
src/sidebar/services/test/annotations-exporter-test.js
+89
-3
No files found.
src/shared/download-
json-
file.ts
→
src/shared/download-file.ts
View file @
8e1232ee
function
downloadFile
(
content
:
string
,
type
:
string
,
filename
:
string
,
document
:
Document
,
):
void
{
const
blob
=
new
Blob
([
content
],
{
type
});
const
url
=
URL
.
createObjectURL
(
blob
);
const
link
=
document
.
createElement
(
'a'
);
link
.
setAttribute
(
'href'
,
url
);
link
.
setAttribute
(
'download'
,
filename
);
link
.
style
.
visibility
=
'hidden'
;
document
.
body
.
appendChild
(
link
);
link
.
click
();
document
.
body
.
removeChild
(
link
);
URL
.
revokeObjectURL
(
url
);
}
/**
* Download a file containing JSON-serialized `object` as `filename`
*
* @param data - JSON-serializable object
* @param _document - Test seam
* @return The contents of the downloaded file
* @throws {Error} If provided data cannot be JSON-serialized
*/
export
function
downloadJSONFile
(
data
:
object
,
filename
:
string
,
/* istanbul ignore next */
/* istanbul ignore next
- test seam
*/
_document
=
document
,
):
string
{
const
link
=
_document
.
createElement
(
'a'
);
const
fileContent
=
JSON
.
stringify
(
data
,
null
,
2
);
const
blob
=
new
Blob
([
fileContent
],
{
type
:
'application/json'
,
});
const
url
=
URL
.
createObjectURL
(
blob
);
link
.
setAttribute
(
'href'
,
url
);
link
.
setAttribute
(
'download'
,
filename
);
link
.
style
.
visibility
=
'hidden'
;
_document
.
body
.
appendChild
(
link
);
link
.
click
();
_document
.
body
.
removeChild
(
link
);
downloadFile
(
fileContent
,
'application/json'
,
filename
,
_document
);
return
fileContent
;
}
/**
* Download a text file containing data
*/
export
function
downloadTextFile
(
text
:
string
,
filename
:
string
,
/* istanbul ignore next - test seam */
_document
=
document
,
)
{
downloadFile
(
text
,
'text/plain'
,
filename
,
_document
);
}
src/shared/test/download-
json-
file-test.js
→
src/shared/test/download-file-test.js
View file @
8e1232ee
import
{
downloadJSONFile
}
from
'../download-json
-file'
;
import
{
downloadJSONFile
,
downloadTextFile
}
from
'../download
-file'
;
describe
(
'download-
json-
file'
,
()
=>
{
describe
(
'download-file'
,
()
=>
{
let
fakeLink
;
let
fakeDocument
;
...
...
@@ -18,20 +18,21 @@ describe('download-json-file', () => {
removeChild
:
sinon
.
stub
(),
},
};
});
it
(
'generates export file with provided annotations'
,
()
=>
{
const
filename
=
'my-file.json'
;
const
data
=
{
foo
:
[
'bar'
,
'baz'
]
};
const
fileContent
=
downloadJSONFile
(
data
,
filename
,
fakeDocument
);
sinon
.
spy
(
window
,
'Blob'
);
});
assert
.
equal
(
fileContent
,
JSON
.
stringify
(
data
,
null
,
2
));
afterEach
(()
=>
{
window
.
Blob
.
restore
();
});
function
assertDownloadHappened
(
filename
,
fileContent
,
type
)
{
assert
.
calledWith
(
fakeDocument
.
createElement
,
'a'
);
assert
.
calledWith
(
fakeDocument
.
body
.
appendChild
,
fakeLink
);
assert
.
calledWith
(
fakeDocument
.
body
.
removeChild
,
fakeLink
);
assert
.
calledWith
(
window
.
Blob
,
[
fileContent
],
{
type
});
assert
.
calledWith
(
fakeLink
.
setAttribute
.
firstCall
,
'href'
,
...
...
@@ -39,5 +40,24 @@ describe('download-json-file', () => {
);
assert
.
calledWith
(
fakeLink
.
setAttribute
.
secondCall
,
'download'
,
filename
);
assert
.
equal
(
'hidden'
,
fakeLink
.
style
.
visibility
);
}
it
(
'downloadJSONFile generates JSON file with provided data'
,
()
=>
{
const
data
=
{
foo
:
[
'bar'
,
'baz'
]
};
const
filename
=
'my-file.json'
;
const
fileContent
=
downloadJSONFile
(
data
,
filename
,
fakeDocument
);
assert
.
equal
(
fileContent
,
JSON
.
stringify
(
data
,
null
,
2
));
assertDownloadHappened
(
filename
,
fileContent
,
'application/json'
);
});
it
(
'downloadTextFile generates text file with provided data'
,
()
=>
{
const
data
=
'The content of the file'
;
const
filename
=
'my-file.txt'
;
downloadTextFile
(
data
,
filename
,
fakeDocument
);
assertDownloadHappened
(
filename
,
data
,
'text/plain'
);
});
});
src/sidebar/components/ShareDialog/ExportAnnotations.tsx
View file @
8e1232ee
...
...
@@ -7,7 +7,10 @@ import {
}
from
'@hypothesis/frontend-shared'
;
import
{
useCallback
,
useId
,
useMemo
,
useState
}
from
'preact/hooks'
;
import
{
downloadJSONFile
}
from
'../../../shared/download-json-file'
;
import
{
downloadJSONFile
,
downloadTextFile
,
}
from
'../../../shared/download-file'
;
import
type
{
APIAnnotationData
}
from
'../../../types/api'
;
import
{
annotationDisplayName
}
from
'../../helpers/annotation-user'
;
import
type
{
UserAnnotations
}
from
'../../helpers/annotations-by-user'
;
...
...
@@ -37,6 +40,10 @@ const exportFormats: ExportFormat[] = [
value
:
'json'
,
name
:
'JSON'
,
},
{
value
:
'txt'
,
name
:
'Text'
,
},
// TODO Enable these formats when implemented
// {
...
...
@@ -44,10 +51,6 @@ const exportFormats: ExportFormat[] = [
// name: 'CSV',
// },
// {
// value: 'txt',
// name: 'Text',
// },
// {
// value: 'html',
// name: 'HTML',
// },
...
...
@@ -126,10 +129,21 @@ function ExportAnnotations({
selectedUser
?.
annotations
??
exportableAnnotations
;
const
filename
=
`
${
customFilename
??
defaultFilename
}.
$
{
format
}
`;
if (format === 'json') {
switch (format) {
case 'json': {
const exportData =
annotationsExporter.buildJSONExportContent(annotationsToExport);
downloadJSONFile(exportData, filename);
break;
}
case 'txt': {
const exportData = annotationsExporter.buildTextExportContent(
annotationsToExport,
group?.name,
);
downloadTextFile(exportData, filename);
break;
}
}
} catch (e) {
toastMessenger.error('Exporting annotations failed');
...
...
@@ -187,6 +201,7 @@ function ExportAnnotations({
onChange={setExportFormat}
buttonContent={exportFormat.name}
data-testid="export-format-select"
right
>
{exportFormats.map(exportFormat => (
<SelectNext.Option
...
...
src/sidebar/components/ShareDialog/test/ExportAnnotations-test.js
View file @
8e1232ee
...
...
@@ -15,6 +15,7 @@ describe('ExportAnnotations', () => {
let
fakeAnnotationsExporter
;
let
fakeToastMessenger
;
let
fakeDownloadJSONFile
;
let
fakeDownloadTextFile
;
let
fakeSuggestedFilename
;
const
fakePrivateGroup
=
{
...
...
@@ -35,11 +36,13 @@ describe('ExportAnnotations', () => {
beforeEach
(()
=>
{
fakeAnnotationsExporter
=
{
buildJSONExportContent
:
sinon
.
stub
().
returns
({}),
buildTextExportContent
:
sinon
.
stub
().
returns
(
''
),
};
fakeToastMessenger
=
{
error
:
sinon
.
stub
(),
};
fakeDownloadJSONFile
=
sinon
.
stub
();
fakeDownloadTextFile
=
sinon
.
stub
();
fakeStore
=
{
defaultAuthority
:
sinon
.
stub
().
returns
(
'example.com'
),
isFeatureEnabled
:
sinon
.
stub
().
returns
(
true
),
...
...
@@ -59,8 +62,9 @@ describe('ExportAnnotations', () => {
$imports
.
$mock
(
mockImportedComponents
());
$imports
.
$mock
({
'../../../shared/download-
json-
file'
:
{
'../../../shared/download-file'
:
{
downloadJSONFile
:
fakeDownloadJSONFile
,
downloadTextFile
:
fakeDownloadTextFile
,
},
'../../helpers/export-annotations'
:
{
suggestedFilename
:
fakeSuggestedFilename
,
...
...
@@ -226,11 +230,44 @@ describe('ExportAnnotations', () => {
});
});
it
(
'lists supported export formats'
,
async
()
=>
{
const
wrapper
=
createComponent
();
const
select
=
await
waitForElement
(
wrapper
,
'[data-testid="export-format-select"]'
,
);
const
options
=
select
.
find
(
SelectNext
.
Option
);
assert
.
equal
(
options
.
length
,
2
);
assert
.
equal
(
options
.
at
(
0
).
text
(),
'JSON'
);
assert
.
equal
(
options
.
at
(
1
).
text
(),
'Text'
);
});
describe
(
'export form submitted'
,
()
=>
{
const
submitExportForm
=
wrapper
=>
wrapper
.
find
(
'[data-testid="export-form"]'
).
simulate
(
'submit'
);
const
selectExportFormat
=
async
(
wrapper
,
format
)
=>
{
const
select
=
await
waitForElement
(
wrapper
,
'[data-testid="export-format-select"]'
,
);
select
.
props
().
onChange
({
value
:
format
});
wrapper
.
update
();
};
it
(
'builds an export file from all non-draft annotations'
,
()
=>
{
[
{
format
:
'json'
,
getExpectedInvokedContentBuilder
:
()
=>
fakeAnnotationsExporter
.
buildJSONExportContent
,
},
{
format
:
'txt'
,
getExpectedInvokedContentBuilder
:
()
=>
fakeAnnotationsExporter
.
buildTextExportContent
,
},
].
forEach
(({
format
,
getExpectedInvokedContentBuilder
})
=>
{
it
(
'builds an export file from all non-draft annotations'
,
async
()
=>
{
const
wrapper
=
createComponent
();
const
annotationsToExport
=
[
fixtures
.
oldAnnotation
(),
...
...
@@ -238,15 +275,16 @@ describe('ExportAnnotations', () => {
];
fakeStore
.
savedAnnotations
.
returns
(
annotationsToExport
);
await
selectExportFormat
(
wrapper
,
format
);
submitExportForm
(
wrapper
);
assert
.
calledOnce
(
fakeAnnotationsExporter
.
buildJSONExportContent
);
assert
.
calledWith
(
fakeAnnotationsExporter
.
buildJSONExportContent
,
annotationsToExport
,
);
const
invokedContentBuilder
=
getExpectedInvokedContentBuilder
();
assert
.
calledOnce
(
invokedContentBuilder
);
assert
.
calledWith
(
invokedContentBuilder
,
annotationsToExport
);
assert
.
notCalled
(
fakeToastMessenger
.
error
);
});
});
it
(
'builds an export file from selected user annotations'
,
async
()
=>
{
const
selectedUserAnnotations
=
[
...
...
@@ -305,7 +343,17 @@ describe('ExportAnnotations', () => {
);
});
it
(
'downloads a file using user-entered filename appended with `.json`'
,
()
=>
{
[
{
format
:
'json'
,
getExpectedInvokedDownloader
:
()
=>
fakeDownloadJSONFile
,
},
{
format
:
'txt'
,
getExpectedInvokedDownloader
:
()
=>
fakeDownloadTextFile
,
},
].
forEach
(({
format
,
getExpectedInvokedDownloader
})
=>
{
it
(
'downloads a file using user-entered filename appended with proper extension'
,
async
()
=>
{
const
wrapper
=
createComponent
();
const
filenameInput
=
wrapper
.
find
(
'input[data-testid="export-filename"]'
,
...
...
@@ -314,15 +362,19 @@ describe('ExportAnnotations', () => {
filenameInput
.
getDOMNode
().
value
=
'my-filename'
;
filenameInput
.
simulate
(
'change'
);
await
selectExportFormat
(
wrapper
,
format
);
submitExportForm
(
wrapper
);
assert
.
calledOnce
(
fakeDownloadJSONFile
);
const
invokedDownloader
=
getExpectedInvokedDownloader
();
assert
.
calledOnce
(
invokedDownloader
);
assert
.
calledWith
(
fakeDownloadJSONFile
,
sinon
.
match
.
object
,
'my-filename.json'
,
invokedDownloader
,
sinon
.
match
.
any
,
`my-filename.
${
format
}
`
,
);
});
});
context
(
'when exporting annotations fails'
,
()
=>
{
it
(
'displays error toast message'
,
()
=>
{
...
...
src/sidebar/helpers/annotation-metadata.ts
View file @
8e1232ee
...
...
@@ -20,7 +20,9 @@ export type DocumentMetadata = {
/**
* Extract document metadata from an annotation.
*/
export
function
documentMetadata
(
annotation
:
Annotation
):
DocumentMetadata
{
export
function
documentMetadata
(
annotation
:
APIAnnotationData
,
):
DocumentMetadata
{
const
uri
=
annotation
.
uri
;
let
domain
;
...
...
src/sidebar/helpers/import.ts
View file @
8e1232ee
import
{
isObject
}
from
'../../shared/is-object'
;
import
{
readJSONFile
}
from
'../../shared/read-json-file'
;
import
type
{
APIAnnotationData
}
from
'../../types/api'
;
import
type
{
ExportContent
}
from
'../services/annotations-exporter'
;
import
type
{
JSON
ExportContent
}
from
'../services/annotations-exporter'
;
/**
* Parse a file generated by the annotation exporter and return the extracted
...
...
@@ -21,5 +21,5 @@ export async function readExportFile(file: File): Promise<APIAnnotationData[]> {
throw
new
Error
(
'Not a valid Hypothesis JSON file'
);
}
return
(
json
as
ExportContent
).
annotations
;
return
(
json
as
JSON
ExportContent
).
annotations
;
}
src/sidebar/services/annotations-exporter.ts
View file @
8e1232ee
import
{
trimAndDedent
}
from
'../../shared/trim-and-dedent'
;
import
type
{
APIAnnotationData
}
from
'../../types/api'
;
import
{
username
}
from
'../helpers/account-id'
;
import
{
documentMetadata
,
isReply
,
quote
,
}
from
'../helpers/annotation-metadata'
;
import
{
stripInternalProperties
}
from
'../helpers/strip-internal-properties'
;
import
{
VersionData
}
from
'../helpers/version-data'
;
import
type
{
SidebarStore
}
from
'../store'
;
export
type
ExportContent
=
{
export
type
JSON
ExportContent
=
{
export_date
:
string
;
export_userid
:
string
;
client_version
:
string
;
...
...
@@ -26,7 +33,7 @@ export class AnnotationsExporter {
annotations
:
APIAnnotationData
[],
/* istanbul ignore next - test seam */
now
=
new
Date
(),
):
ExportContent
{
):
JSON
ExportContent
{
const
profile
=
this
.
_store
.
profile
();
const
versionData
=
new
VersionData
(
profile
,
[]);
...
...
@@ -39,4 +46,50 @@ export class AnnotationsExporter {
)
as
APIAnnotationData
[],
};
}
buildTextExportContent
(
annotations
:
APIAnnotationData
[],
groupName
=
''
,
/* istanbul ignore next - test seam */
now
=
new
Date
(),
):
string
{
const
[
firstAnnotation
]
=
annotations
;
if
(
!
firstAnnotation
)
{
throw
new
Error
(
'No annotations to export'
);
}
const
{
uri
,
title
}
=
documentMetadata
(
firstAnnotation
);
const
uniqueUsers
=
[
...
new
Set
(
annotations
.
map
(
annotation
=>
username
(
annotation
.
user
))
.
filter
(
Boolean
),
),
];
const
annotationsText
=
annotations
.
map
(
(
annotation
,
index
)
=>
trimAndDedent
`
Annotation
${
index
+
1
}
:
${
annotation
.
created
}
${
annotation
.
text
}
${
username
(
annotation
.
user
)}
"
${
quote
(
annotation
)}
"
Tags:
${
annotation
.
tags
.
join
(
', '
)}
`
,
)
.
join
(
'
\
n
\
n'
);
return
trimAndDedent
`
${
now
.
toISOString
()}
${
title
}
${
uri
}
Group:
${
groupName
}
Total users:
${
uniqueUsers
.
length
}
Users:
${
uniqueUsers
.
join
(
', '
)}
Total annotations:
${
annotations
.
length
}
Total replies:
${
annotations
.
filter
(
isReply
).
length
}
${
annotationsText
}
`
;
}
}
src/sidebar/services/test/annotations-exporter-test.js
View file @
8e1232ee
import
{
publicAnnotation
}
from
'../../test/annotation-fixtures'
;
import
{
newAnnotation
,
newReply
,
publicAnnotation
,
}
from
'../../test/annotation-fixtures'
;
import
{
AnnotationsExporter
}
from
'../annotations-exporter'
;
describe
(
'AnnotationsExporter'
,
()
=>
{
let
fakeStore
;
let
now
;
let
exporter
;
beforeEach
(()
=>
{
fakeStore
=
{
profile
:
sinon
.
stub
().
returns
({
userid
:
'userId'
}),
};
now
=
new
Date
();
exporter
=
new
AnnotationsExporter
(
fakeStore
);
});
it
(
'generates export content with provided annotations'
,
()
=>
{
const
now
=
new
Date
();
it
(
'generates JSON content with provided annotations'
,
()
=>
{
const
firstBaseAnnotation
=
publicAnnotation
();
const
secondBaseAnnotation
=
publicAnnotation
();
const
annotations
=
[
...
...
@@ -36,4 +42,84 @@ describe('AnnotationsExporter', () => {
annotations
:
[
firstBaseAnnotation
,
secondBaseAnnotation
],
});
});
describe
(
'buildTextExportContent'
,
()
=>
{
it
(
'throws error when empty list of annotations is provided'
,
()
=>
{
assert
.
throws
(
()
=>
exporter
.
buildTextExportContent
([]),
'No annotations to export'
,
);
});
it
(
'generates text content with provided annotations'
,
()
=>
{
const
isoDate
=
now
.
toISOString
();
const
annotation
=
{
...
newAnnotation
(),
...
publicAnnotation
(),
created
:
isoDate
,
};
// Title should actually be an array
annotation
.
document
.
title
=
[
annotation
.
document
.
title
];
const
annotations
=
[
annotation
,
annotation
,
{
...
annotation
,
user
:
'acct:jane@localhost'
,
tags
:
[
'foo'
,
'bar'
],
},
{
...
annotation
,
...
newReply
(),
},
];
const
groupName
=
'My group'
;
const
result
=
exporter
.
buildTextExportContent
(
annotations
,
groupName
,
now
,
);
assert
.
equal
(
result
,
`
${
isoDate
}
A special document
http://example.com
Group:
${
groupName
}
Total users: 2
Users: bill, jane
Total annotations: 4
Total replies: 1
Annotation 1:
${
isoDate
}
Annotation text
bill
"null"
Tags: tag_1, tag_2
Annotation 2:
${
isoDate
}
Annotation text
bill
"null"
Tags: tag_1, tag_2
Annotation 3:
${
isoDate
}
Annotation text
jane
"null"
Tags: foo, bar
Annotation 4:
${
isoDate
}
Annotation text
bill
"null"
Tags: 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