Skip to content

Commit eb0cae7

Browse files
authored
Merge pull request #446 from IQSS/436-use-cases-of-get-file-citation-in-other-formats
Use Case for File Citation Formats
2 parents 57409da + 5b2ee6c commit eb0cae7

13 files changed

Lines changed: 398 additions & 5 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ This changelog follows the principles of [Keep a Changelog](https://keepachangel
88

99
### Added
1010

11+
- Files: Added `getFileCitationByFormat` use case, repository method, and `FileCitationFormat` enum to support Dataverse file citation exports in `EndNote`, `RIS`, `BibTeX`, `CSL`, and `Internal` formats.
1112
- Collections: Added `allowedDatasetTypes` field to the [Collection](./src/collections/domain/models/Collection.ts) model. This field is optional and only populated the feature is enabled on the installation and configured on the collection.
1213
- Collections: Added theme information when retrieving a collection using `getCollection`.
1314

docs/useCases.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ The different use cases currently available in the package are classified below,
7878
- [Get a File](#get-a-file)
7979
- [Get a File and its Dataset](#get-a-file-and-its-dataset)
8080
- [Get File Citation Text](#get-file-citation-text)
81+
- [Get File Citation By Format](#get-file-citation-by-format)
8182
- [Get File Counts in a Dataset](#get-file-counts-in-a-dataset)
8283
- [Get File Data Tables](#get-file-data-tables)
8384
- [Get File Download Count](#get-file-download-count)
@@ -1919,6 +1920,32 @@ The `fileId` parameter can be a string, for persistent identifiers, or a number,
19191920

19201921
There is an optional third parameter called `includeDeaccessioned`, which indicates whether to consider deaccessioned versions or not in the file search. If not set, the default value is `false`.
19211922

1923+
#### Get File Citation By Format
1924+
1925+
Returns the File citation in the requested citation export format.
1926+
1927+
##### Example call:
1928+
1929+
```typescript
1930+
import { FileCitationFormat, getFileCitationByFormat } from '@iqss/dataverse-client-javascript'
1931+
1932+
/* ... */
1933+
1934+
const fileId = 3
1935+
1936+
getFileCitationByFormat.execute(fileId, FileCitationFormat.BIBTEX).then((citationText: string) => {
1937+
/* ... */
1938+
})
1939+
1940+
/* ... */
1941+
```
1942+
1943+
_See [use case](../src/files/domain/useCases/GetFileCitationByFormat.ts) implementation_.
1944+
1945+
The `fileId` parameter can be a string, for persistent identifiers, or a number, for numeric identifiers.
1946+
1947+
The `format` parameter must be one of the available [FileCitationFormat](../src/files/domain/models/FileCitationFormat.ts) enum values: `FileCitationFormat.ENDNOTE`, `FileCitationFormat.RIS`, `FileCitationFormat.BIBTEX`, `FileCitationFormat.CSL`, or `FileCitationFormat.INTERNAL`.
1948+
19221949
#### Get File Counts in a Dataset
19231950

19241951
Returns an instance of [FileCounts](../src/files/domain/models/FileCounts.ts), containing the requested Dataset total file count, as well as file counts for the following file properties:
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
export enum FileCitationFormat {
2+
ENDNOTE = 'EndNote',
3+
RIS = 'RIS',
4+
BIBTEX = 'BibTeX',
5+
CSL = 'CSL',
6+
INTERNAL = 'Internal'
7+
}

src/files/domain/repositories/IFilesRepository.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { UploadedFileDTO } from '../dtos/UploadedFileDTO'
1111
import { UpdateFileMetadataDTO } from '../dtos/UpdateFileMetadataDTO'
1212
import { RestrictFileDTO } from '../dtos/RestrictFileDTO'
1313
import { FileVersionSummarySubset } from '../models/FileVersionSummaryInfo'
14+
import { FileCitationFormat } from '../models/FileCitationFormat'
1415

1516
export interface IFilesRepository {
1617
getDatasetFiles(
@@ -57,6 +58,8 @@ export interface IFilesRepository {
5758
includeDeaccessioned: boolean
5859
): Promise<string>
5960

61+
getFileCitationByFormat(fileId: number | string, format: FileCitationFormat): Promise<string>
62+
6063
getFileUploadDestination(datasetId: number | string, file: File): Promise<FileUploadDestination>
6164

6265
addUploadedFilesToDataset(
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { UseCase } from '../../../core/domain/useCases/UseCase'
2+
import { IFilesRepository } from '../repositories/IFilesRepository'
3+
import { FileCitationFormat } from '../models/FileCitationFormat'
4+
5+
export class GetFileCitationByFormat implements UseCase<string> {
6+
private filesRepository: IFilesRepository
7+
8+
constructor(filesRepository: IFilesRepository) {
9+
this.filesRepository = filesRepository
10+
}
11+
12+
/**
13+
* Returns the File citation in the requested format (EndNote XML, RIS, BibTeX, CSL JSON, or Internal HTML).
14+
*
15+
* @param {number | string} [fileId] - The File identifier, which can be a string (for persistent identifiers), or a number (for numeric identifiers).
16+
* @param {FileCitationFormat} [format] - The citation format to return.
17+
* @returns {Promise<string>}
18+
*/
19+
async execute(fileId: number | string, format: FileCitationFormat): Promise<string> {
20+
return await this.filesRepository.getFileCitationByFormat(fileId, format)
21+
}
22+
}

src/files/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { GetFileDataTables } from './domain/useCases/GetFileDataTables'
77
import { GetDatasetFilesTotalDownloadSize } from './domain/useCases/GetDatasetFilesTotalDownloadSize'
88
import { GetFile } from './domain/useCases/GetFile'
99
import { GetFileCitation } from './domain/useCases/GetFileCitation'
10+
import { GetFileCitationByFormat } from './domain/useCases/GetFileCitationByFormat'
1011
import { GetFileAndDataset } from './domain/useCases/GetFileAndDataset'
1112
import { UploadFile } from './domain/useCases/UploadFile'
1213
import { DirectUploadClient } from './infra/clients/DirectUploadClient'
@@ -32,6 +33,7 @@ const getDatasetFilesTotalDownloadSize = new GetDatasetFilesTotalDownloadSize(fi
3233
const getFile = new GetFile(filesRepository)
3334
const getFileAndDataset = new GetFileAndDataset(filesRepository)
3435
const getFileCitation = new GetFileCitation(filesRepository)
36+
const getFileCitationByFormat = new GetFileCitationByFormat(filesRepository)
3537
const uploadFile = new UploadFile(directUploadClient)
3638
const addUploadedFilesToDataset = new AddUploadedFilesToDataset(filesRepository)
3739
const deleteFile = new DeleteFile(filesRepository)
@@ -53,6 +55,7 @@ export {
5355
getFile,
5456
getFileAndDataset,
5557
getFileCitation,
58+
getFileCitationByFormat,
5659
uploadFile,
5760
addUploadedFilesToDataset,
5861
deleteFile,
@@ -89,6 +92,7 @@ export {
8992
FileDataVariableFormatType
9093
} from './domain/models/FileDataTable'
9194
export { FileDownloadSizeMode } from './domain/models/FileDownloadSizeMode'
95+
export { FileCitationFormat } from './domain/models/FileCitationFormat'
9296
export { FilesSubset } from './domain/models/FilesSubset'
9397
export { FilePreview, FilePreviewChecksum } from './domain/models/FilePreview'
9498
export { UploadedFileDTO } from './domain/dtos/UploadedFileDTO'

src/files/infra/repositories/FilesRepository.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import { ApiConstants } from '../../../core/infra/repositories/ApiConstants'
2424
import { RestrictFileDTO } from '../../domain/dtos/RestrictFileDTO'
2525
import { FileVersionSummarySubset } from '../../domain/models/FileVersionSummaryInfo'
2626
import { transformFileVersionSummaryInfoResponseToFileVersionSummaryInfo } from './transformers/fileVersionSummaryInfoTransformers'
27+
import { FileCitationFormat } from '../../domain/models/FileCitationFormat'
2728

2829
export interface GetFilesQueryParams {
2930
includeDeaccessioned: boolean
@@ -234,6 +235,22 @@ export class FilesRepository extends ApiRepository implements IFilesRepository {
234235
})
235236
}
236237

238+
public async getFileCitationByFormat(
239+
fileId: number | string,
240+
format: FileCitationFormat
241+
): Promise<string> {
242+
return this.doGet(
243+
this.buildApiEndpoint(this.accessResourceName, `citation/${format}`, fileId),
244+
true
245+
)
246+
.then((response) =>
247+
typeof response.data === 'string' ? response.data : JSON.stringify(response.data)
248+
)
249+
.catch((error) => {
250+
throw error
251+
})
252+
}
253+
237254
public async getFileUploadDestination(
238255
datasetId: number | string,
239256
file: File
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import {
2+
ApiConfig,
3+
createDataset,
4+
CreatedDatasetIdentifiers,
5+
FileCitationFormat,
6+
getDatasetFiles,
7+
getFileCitationByFormat,
8+
ReadError
9+
} from '../../../src'
10+
import { DataverseApiAuthMechanism } from '../../../src/core/infra/repositories/ApiConfig'
11+
import {
12+
createCollectionViaApi,
13+
deleteCollectionViaApi
14+
} from '../../testHelpers/collections/collectionHelper'
15+
import { deleteUnpublishedDatasetViaApi } from '../../testHelpers/datasets/datasetHelper'
16+
import { uploadFileViaApi } from '../../testHelpers/files/filesHelper'
17+
import { TestConstants } from '../../testHelpers/TestConstants'
18+
19+
describe('execute', () => {
20+
const testCollectionAlias = 'getFileCitationByFormatFunctionalTest'
21+
const testTextFile1Name = 'test-file-1.txt'
22+
let testDatasetIds: CreatedDatasetIdentifiers
23+
24+
beforeAll(async () => {
25+
ApiConfig.init(
26+
TestConstants.TEST_API_URL,
27+
DataverseApiAuthMechanism.API_KEY,
28+
process.env.TEST_API_KEY
29+
)
30+
await createCollectionViaApi(testCollectionAlias)
31+
32+
try {
33+
testDatasetIds = await createDataset.execute(
34+
TestConstants.TEST_NEW_DATASET_DTO,
35+
testCollectionAlias
36+
)
37+
} catch (error) {
38+
throw new Error('Tests beforeAll(): Error while creating test dataset')
39+
}
40+
41+
await uploadFileViaApi(testDatasetIds.numericId, testTextFile1Name).catch(() => {
42+
throw new Error(`Tests beforeAll(): Error while uploading file ${testTextFile1Name}`)
43+
})
44+
})
45+
46+
afterAll(async () => {
47+
try {
48+
await deleteUnpublishedDatasetViaApi(testDatasetIds.numericId)
49+
} catch (error) {
50+
throw new Error('Tests afterAll(): Error while deleting test dataset')
51+
}
52+
53+
try {
54+
await deleteCollectionViaApi(testCollectionAlias)
55+
} catch (error) {
56+
throw new Error('Tests afterAll(): Error while deleting test collection')
57+
}
58+
})
59+
60+
const getTestFileId = async (): Promise<number> => {
61+
const datasetFiles = await getDatasetFiles.execute(testDatasetIds.numericId)
62+
return datasetFiles.files[0].id
63+
}
64+
65+
test('should successfully get file citation in EndNote (XML) format', async () => {
66+
const fileId = await getTestFileId()
67+
68+
const citation = await getFileCitationByFormat.execute(fileId, FileCitationFormat.ENDNOTE)
69+
70+
expect(typeof citation).toBe('string')
71+
expect(citation.trimStart()).toMatch(/^<\?xml/)
72+
})
73+
74+
test('should successfully get file citation in RIS (plain text) format', async () => {
75+
const fileId = await getTestFileId()
76+
77+
const citation = await getFileCitationByFormat.execute(fileId, FileCitationFormat.RIS)
78+
79+
expect(typeof citation).toBe('string')
80+
// RIS records use TY (type) and ER (end of record) tags
81+
expect(citation).toMatch(/TY\s+-/)
82+
expect(citation).toMatch(/ER\s+-/)
83+
})
84+
85+
test('should successfully get file citation in BibTeX (plain text) format', async () => {
86+
const fileId = await getTestFileId()
87+
88+
const citation = await getFileCitationByFormat.execute(fileId, FileCitationFormat.BIBTEX)
89+
90+
expect(typeof citation).toBe('string')
91+
// BibTeX entries start with @<entry-type>{
92+
expect(citation.trimStart()).toMatch(/^@\w+\{/)
93+
})
94+
95+
test('should successfully get file citation in CSL (JSON) format', async () => {
96+
const fileId = await getTestFileId()
97+
98+
const citation = await getFileCitationByFormat.execute(fileId, FileCitationFormat.CSL)
99+
100+
expect(typeof citation).toBe('string')
101+
const parsed = JSON.parse(citation)
102+
expect(typeof parsed).toBe('object')
103+
expect(parsed).not.toBeNull()
104+
})
105+
106+
test('should successfully get file citation in Internal (HTML) format', async () => {
107+
const fileId = await getTestFileId()
108+
109+
const citation = await getFileCitationByFormat.execute(fileId, FileCitationFormat.INTERNAL)
110+
111+
expect(typeof citation).toBe('string')
112+
// Internal HTML format includes anchor tags linking to the dataset
113+
expect(citation).toMatch(/<a\s+href=/i)
114+
})
115+
116+
test('should throw an error when the file id does not exist', async () => {
117+
const nonExistentFileId = 5
118+
119+
await expect(
120+
getFileCitationByFormat.execute(nonExistentFileId, FileCitationFormat.BIBTEX)
121+
).rejects.toThrow(ReadError)
122+
})
123+
})

test/integration/collections/CollectionsRepository.test.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -117,9 +117,10 @@ describe('CollectionsRepository', () => {
117117
// Root collection might or might not have a theme, but the property should be present if it does
118118
// and we want to ensure the transformer doesn't fail.
119119
// In a default Dataverse installation, root theme is usually undefined or has some default values.
120-
if (actual.theme) {
121-
expect(actual.theme).toHaveProperty('id')
122-
}
120+
const hasNoThemeOrThemeWithId =
121+
actual.theme === undefined || Object.prototype.hasOwnProperty.call(actual.theme, 'id')
122+
123+
expect(hasNoThemeOrThemeWithId).toBe(true)
123124
})
124125
})
125126
describe('by string alias', () => {

test/integration/files/FilesRepository.test.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import {
2929
} from '../../../src/datasets'
3030
import { FileModel } from '../../../src/files/domain/models/FileModel'
3131
import { FileCounts } from '../../../src/files/domain/models/FileCounts'
32+
import { FileCitationFormat } from '../../../src/files/domain/models/FileCitationFormat'
3233
import { FileDownloadSizeMode, WriteError } from '../../../src'
3334
import {
3435
deaccessionDatasetViaApi,
@@ -656,6 +657,68 @@ describe('FilesRepository', () => {
656657
})
657658
})
658659

660+
describe('getFileCitationByFormat', () => {
661+
test('should return EndNote citation as XML', async () => {
662+
const citation = await sut.getFileCitationByFormat(testFileId, FileCitationFormat.ENDNOTE)
663+
664+
expect(typeof citation).toBe('string')
665+
expect(citation.trimStart()).toMatch(/^<\?xml/)
666+
})
667+
668+
test('should return RIS citation as plain text', async () => {
669+
const citation = await sut.getFileCitationByFormat(testFileId, FileCitationFormat.RIS)
670+
671+
expect(typeof citation).toBe('string')
672+
// RIS records use TY (type) and ER (end of record) tags
673+
expect(citation).toMatch(/TY\s+-/)
674+
expect(citation).toMatch(/ER\s+-/)
675+
})
676+
677+
test('should return BibTeX citation as plain text', async () => {
678+
const citation = await sut.getFileCitationByFormat(testFileId, FileCitationFormat.BIBTEX)
679+
680+
expect(typeof citation).toBe('string')
681+
// BibTeX entries start with @<entry-type>{
682+
expect(citation.trimStart()).toMatch(/^@\w+\{/)
683+
})
684+
685+
test('should return BibTeX citation when file is requested by persistent id', async () => {
686+
expect(testFilePersistentId).toBeTruthy()
687+
688+
const citation = await sut.getFileCitationByFormat(
689+
testFilePersistentId,
690+
FileCitationFormat.BIBTEX
691+
)
692+
693+
expect(typeof citation).toBe('string')
694+
// BibTeX entries start with @<entry-type>{
695+
expect(citation.trimStart()).toMatch(/^@\w+\{/)
696+
})
697+
698+
test('should return CSL citation as JSON', async () => {
699+
const citation = await sut.getFileCitationByFormat(testFileId, FileCitationFormat.CSL)
700+
701+
expect(typeof citation).toBe('string')
702+
const parsed = JSON.parse(citation)
703+
expect(typeof parsed).toBe('object')
704+
expect(parsed).not.toBeNull()
705+
})
706+
707+
test('should return Internal citation as HTML', async () => {
708+
const citation = await sut.getFileCitationByFormat(testFileId, FileCitationFormat.INTERNAL)
709+
710+
expect(typeof citation).toBe('string')
711+
// Internal HTML format includes anchor tags linking to the dataset
712+
expect(citation).toMatch(/<a\s+href=/i)
713+
})
714+
715+
test('should return error when file does not exist', async () => {
716+
await expect(
717+
sut.getFileCitationByFormat(nonExistentFiledId, FileCitationFormat.BIBTEX)
718+
).rejects.toThrow(ReadError)
719+
})
720+
})
721+
659722
describe('getFileUploadDestination', () => {
660723
const testCollectionAlias = 'getFileUploadDestinationsTestCollection'
661724
let testDataset2Ids: CreatedDatasetIdentifiers

0 commit comments

Comments
 (0)