Skip to content
Merged
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ This changelog follows the principles of [Keep a Changelog](https://keepachangel

### Added

- Files: Added `getFileCitationByFormat` use case, repository method, and `FileCitationFormat` enum to support Dataverse file citation exports in `EndNote`, `RIS`, `BibTeX`, `CSL`, and `Internal` formats.
- 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.
- Collections: Added theme information when retrieving a collection using `getCollection`.

Expand Down
27 changes: 27 additions & 0 deletions docs/useCases.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ The different use cases currently available in the package are classified below,
- [Get a File](#get-a-file)
- [Get a File and its Dataset](#get-a-file-and-its-dataset)
- [Get File Citation Text](#get-file-citation-text)
- [Get File Citation By Format](#get-file-citation-by-format)
- [Get File Counts in a Dataset](#get-file-counts-in-a-dataset)
- [Get File Data Tables](#get-file-data-tables)
- [Get File Download Count](#get-file-download-count)
Expand Down Expand Up @@ -1919,6 +1920,32 @@ The `fileId` parameter can be a string, for persistent identifiers, or a number,

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`.

#### Get File Citation By Format

Returns the File citation in the requested citation export format.

##### Example call:

```typescript
import { FileCitationFormat, getFileCitationByFormat } from '@iqss/dataverse-client-javascript'

/* ... */

const fileId = 3

getFileCitationByFormat.execute(fileId, FileCitationFormat.BIBTEX).then((citationText: string) => {
/* ... */
})

/* ... */
```

_See [use case](../src/files/domain/useCases/GetFileCitationByFormat.ts) implementation_.

The `fileId` parameter can be a string, for persistent identifiers, or a number, for numeric identifiers.

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`.

#### Get File Counts in a Dataset

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:
Expand Down
7 changes: 7 additions & 0 deletions src/files/domain/models/FileCitationFormat.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export enum FileCitationFormat {
ENDNOTE = 'EndNote',
RIS = 'RIS',
BIBTEX = 'BibTeX',
CSL = 'CSL',
INTERNAL = 'Internal'
}
3 changes: 3 additions & 0 deletions src/files/domain/repositories/IFilesRepository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { UploadedFileDTO } from '../dtos/UploadedFileDTO'
import { UpdateFileMetadataDTO } from '../dtos/UpdateFileMetadataDTO'
import { RestrictFileDTO } from '../dtos/RestrictFileDTO'
import { FileVersionSummarySubset } from '../models/FileVersionSummaryInfo'
import { FileCitationFormat } from '../models/FileCitationFormat'

export interface IFilesRepository {
getDatasetFiles(
Expand Down Expand Up @@ -57,6 +58,8 @@ export interface IFilesRepository {
includeDeaccessioned: boolean
): Promise<string>

getFileCitationByFormat(fileId: number | string, format: FileCitationFormat): Promise<string>

getFileUploadDestination(datasetId: number | string, file: File): Promise<FileUploadDestination>

addUploadedFilesToDataset(
Expand Down
22 changes: 22 additions & 0 deletions src/files/domain/useCases/GetFileCitationByFormat.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { UseCase } from '../../../core/domain/useCases/UseCase'
import { IFilesRepository } from '../repositories/IFilesRepository'
import { FileCitationFormat } from '../models/FileCitationFormat'

export class GetFileCitationByFormat implements UseCase<string> {
private filesRepository: IFilesRepository

constructor(filesRepository: IFilesRepository) {
this.filesRepository = filesRepository
}

/**
* Returns the File citation in the requested format (EndNote XML, RIS, BibTeX, CSL JSON, or Internal HTML).
*
* @param {number | string} [fileId] - The File identifier, which can be a string (for persistent identifiers), or a number (for numeric identifiers).
* @param {FileCitationFormat} [format] - The citation format to return.
* @returns {Promise<string>}
*/
async execute(fileId: number | string, format: FileCitationFormat): Promise<string> {
return await this.filesRepository.getFileCitationByFormat(fileId, format)
}
}
4 changes: 4 additions & 0 deletions src/files/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { GetFileDataTables } from './domain/useCases/GetFileDataTables'
import { GetDatasetFilesTotalDownloadSize } from './domain/useCases/GetDatasetFilesTotalDownloadSize'
import { GetFile } from './domain/useCases/GetFile'
import { GetFileCitation } from './domain/useCases/GetFileCitation'
import { GetFileCitationByFormat } from './domain/useCases/GetFileCitationByFormat'
import { GetFileAndDataset } from './domain/useCases/GetFileAndDataset'
import { UploadFile } from './domain/useCases/UploadFile'
import { DirectUploadClient } from './infra/clients/DirectUploadClient'
Expand All @@ -32,6 +33,7 @@ const getDatasetFilesTotalDownloadSize = new GetDatasetFilesTotalDownloadSize(fi
const getFile = new GetFile(filesRepository)
const getFileAndDataset = new GetFileAndDataset(filesRepository)
const getFileCitation = new GetFileCitation(filesRepository)
const getFileCitationByFormat = new GetFileCitationByFormat(filesRepository)
const uploadFile = new UploadFile(directUploadClient)
const addUploadedFilesToDataset = new AddUploadedFilesToDataset(filesRepository)
const deleteFile = new DeleteFile(filesRepository)
Expand All @@ -53,6 +55,7 @@ export {
getFile,
getFileAndDataset,
getFileCitation,
getFileCitationByFormat,
uploadFile,
addUploadedFilesToDataset,
deleteFile,
Expand Down Expand Up @@ -89,6 +92,7 @@ export {
FileDataVariableFormatType
} from './domain/models/FileDataTable'
export { FileDownloadSizeMode } from './domain/models/FileDownloadSizeMode'
export { FileCitationFormat } from './domain/models/FileCitationFormat'
export { FilesSubset } from './domain/models/FilesSubset'
export { FilePreview, FilePreviewChecksum } from './domain/models/FilePreview'
export { UploadedFileDTO } from './domain/dtos/UploadedFileDTO'
Expand Down
17 changes: 17 additions & 0 deletions src/files/infra/repositories/FilesRepository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { ApiConstants } from '../../../core/infra/repositories/ApiConstants'
import { RestrictFileDTO } from '../../domain/dtos/RestrictFileDTO'
import { FileVersionSummarySubset } from '../../domain/models/FileVersionSummaryInfo'
import { transformFileVersionSummaryInfoResponseToFileVersionSummaryInfo } from './transformers/fileVersionSummaryInfoTransformers'
import { FileCitationFormat } from '../../domain/models/FileCitationFormat'

export interface GetFilesQueryParams {
includeDeaccessioned: boolean
Expand Down Expand Up @@ -234,6 +235,22 @@ export class FilesRepository extends ApiRepository implements IFilesRepository {
})
}

public async getFileCitationByFormat(
fileId: number | string,
format: FileCitationFormat
): Promise<string> {
return this.doGet(
this.buildApiEndpoint(this.accessResourceName, `citation/${format}`, fileId),
true
)
.then((response) =>
typeof response.data === 'string' ? response.data : JSON.stringify(response.data)
)
.catch((error) => {
throw error
})
}

public async getFileUploadDestination(
datasetId: number | string,
file: File
Expand Down
123 changes: 123 additions & 0 deletions test/functional/files/GetFileCitationByFormat.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import {
ApiConfig,
createDataset,
CreatedDatasetIdentifiers,
FileCitationFormat,
getDatasetFiles,
getFileCitationByFormat,
ReadError
} from '../../../src'
import { DataverseApiAuthMechanism } from '../../../src/core/infra/repositories/ApiConfig'
import {
createCollectionViaApi,
deleteCollectionViaApi
} from '../../testHelpers/collections/collectionHelper'
import { deleteUnpublishedDatasetViaApi } from '../../testHelpers/datasets/datasetHelper'
import { uploadFileViaApi } from '../../testHelpers/files/filesHelper'
import { TestConstants } from '../../testHelpers/TestConstants'

describe('execute', () => {
const testCollectionAlias = 'getFileCitationByFormatFunctionalTest'
const testTextFile1Name = 'test-file-1.txt'
let testDatasetIds: CreatedDatasetIdentifiers

beforeAll(async () => {
ApiConfig.init(
TestConstants.TEST_API_URL,
DataverseApiAuthMechanism.API_KEY,
process.env.TEST_API_KEY
)
await createCollectionViaApi(testCollectionAlias)

try {
testDatasetIds = await createDataset.execute(
TestConstants.TEST_NEW_DATASET_DTO,
testCollectionAlias
)
} catch (error) {
throw new Error('Tests beforeAll(): Error while creating test dataset')
}

await uploadFileViaApi(testDatasetIds.numericId, testTextFile1Name).catch(() => {
throw new Error(`Tests beforeAll(): Error while uploading file ${testTextFile1Name}`)
})
})

afterAll(async () => {
try {
await deleteUnpublishedDatasetViaApi(testDatasetIds.numericId)
} catch (error) {
throw new Error('Tests afterAll(): Error while deleting test dataset')
}

try {
await deleteCollectionViaApi(testCollectionAlias)
} catch (error) {
throw new Error('Tests afterAll(): Error while deleting test collection')
}
})

const getTestFileId = async (): Promise<number> => {
const datasetFiles = await getDatasetFiles.execute(testDatasetIds.numericId)
return datasetFiles.files[0].id
}

test('should successfully get file citation in EndNote (XML) format', async () => {
const fileId = await getTestFileId()

const citation = await getFileCitationByFormat.execute(fileId, FileCitationFormat.ENDNOTE)

expect(typeof citation).toBe('string')
expect(citation.trimStart()).toMatch(/^<\?xml/)
})

test('should successfully get file citation in RIS (plain text) format', async () => {
const fileId = await getTestFileId()

const citation = await getFileCitationByFormat.execute(fileId, FileCitationFormat.RIS)

expect(typeof citation).toBe('string')
// RIS records use TY (type) and ER (end of record) tags
expect(citation).toMatch(/TY\s+-/)
expect(citation).toMatch(/ER\s+-/)
})

test('should successfully get file citation in BibTeX (plain text) format', async () => {
const fileId = await getTestFileId()

const citation = await getFileCitationByFormat.execute(fileId, FileCitationFormat.BIBTEX)

expect(typeof citation).toBe('string')
// BibTeX entries start with @<entry-type>{
expect(citation.trimStart()).toMatch(/^@\w+\{/)
})

test('should successfully get file citation in CSL (JSON) format', async () => {
const fileId = await getTestFileId()

const citation = await getFileCitationByFormat.execute(fileId, FileCitationFormat.CSL)

expect(typeof citation).toBe('string')
const parsed = JSON.parse(citation)
expect(typeof parsed).toBe('object')
expect(parsed).not.toBeNull()
})

test('should successfully get file citation in Internal (HTML) format', async () => {
const fileId = await getTestFileId()

const citation = await getFileCitationByFormat.execute(fileId, FileCitationFormat.INTERNAL)

expect(typeof citation).toBe('string')
// Internal HTML format includes anchor tags linking to the dataset
expect(citation).toMatch(/<a\s+href=/i)
})

test('should throw an error when the file id does not exist', async () => {
const nonExistentFileId = 5

await expect(
getFileCitationByFormat.execute(nonExistentFileId, FileCitationFormat.BIBTEX)
).rejects.toThrow(ReadError)
})
})
7 changes: 4 additions & 3 deletions test/integration/collections/CollectionsRepository.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,9 +117,10 @@ describe('CollectionsRepository', () => {
// Root collection might or might not have a theme, but the property should be present if it does
// and we want to ensure the transformer doesn't fail.
// In a default Dataverse installation, root theme is usually undefined or has some default values.
if (actual.theme) {
expect(actual.theme).toHaveProperty('id')
}
const hasNoThemeOrThemeWithId =
actual.theme === undefined || Object.prototype.hasOwnProperty.call(actual.theme, 'id')

expect(hasNoThemeOrThemeWithId).toBe(true)
})
})
describe('by string alias', () => {
Expand Down
63 changes: 63 additions & 0 deletions test/integration/files/FilesRepository.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
} from '../../../src/datasets'
import { FileModel } from '../../../src/files/domain/models/FileModel'
import { FileCounts } from '../../../src/files/domain/models/FileCounts'
import { FileCitationFormat } from '../../../src/files/domain/models/FileCitationFormat'
import { FileDownloadSizeMode, WriteError } from '../../../src'
import {
deaccessionDatasetViaApi,
Expand Down Expand Up @@ -656,6 +657,68 @@ describe('FilesRepository', () => {
})
})

describe('getFileCitationByFormat', () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since this use case takes a fileId or a persistentId, can you add a test that uses persistentId?

test('should return EndNote citation as XML', async () => {
const citation = await sut.getFileCitationByFormat(testFileId, FileCitationFormat.ENDNOTE)

expect(typeof citation).toBe('string')
expect(citation.trimStart()).toMatch(/^<\?xml/)
})

test('should return RIS citation as plain text', async () => {
const citation = await sut.getFileCitationByFormat(testFileId, FileCitationFormat.RIS)

expect(typeof citation).toBe('string')
// RIS records use TY (type) and ER (end of record) tags
expect(citation).toMatch(/TY\s+-/)
expect(citation).toMatch(/ER\s+-/)
})

test('should return BibTeX citation as plain text', async () => {
const citation = await sut.getFileCitationByFormat(testFileId, FileCitationFormat.BIBTEX)

expect(typeof citation).toBe('string')
// BibTeX entries start with @<entry-type>{
expect(citation.trimStart()).toMatch(/^@\w+\{/)
})

test('should return BibTeX citation when file is requested by persistent id', async () => {
expect(testFilePersistentId).toBeTruthy()

const citation = await sut.getFileCitationByFormat(
testFilePersistentId,
FileCitationFormat.BIBTEX
)

expect(typeof citation).toBe('string')
// BibTeX entries start with @<entry-type>{
expect(citation.trimStart()).toMatch(/^@\w+\{/)
})

test('should return CSL citation as JSON', async () => {
const citation = await sut.getFileCitationByFormat(testFileId, FileCitationFormat.CSL)

expect(typeof citation).toBe('string')
const parsed = JSON.parse(citation)
expect(typeof parsed).toBe('object')
expect(parsed).not.toBeNull()
})

test('should return Internal citation as HTML', async () => {
const citation = await sut.getFileCitationByFormat(testFileId, FileCitationFormat.INTERNAL)

expect(typeof citation).toBe('string')
// Internal HTML format includes anchor tags linking to the dataset
expect(citation).toMatch(/<a\s+href=/i)
})

test('should return error when file does not exist', async () => {
await expect(
sut.getFileCitationByFormat(nonExistentFiledId, FileCitationFormat.BIBTEX)
).rejects.toThrow(ReadError)
})
})

describe('getFileUploadDestination', () => {
const testCollectionAlias = 'getFileUploadDestinationsTestCollection'
let testDataset2Ids: CreatedDatasetIdentifiers
Expand Down
7 changes: 5 additions & 2 deletions test/unit/collections/CollectionsRepository.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,14 @@ import {
createCollectionFacetRequestPayload,
createCollectionModel,
createCollectionPayload,
createNewCollectionRequestPayload,
createNewCollectionRequestPayload
} from '../../testHelpers/collections/collectionHelper'
import { TestConstants } from '../../testHelpers/TestConstants'
import { ReadError, WriteError } from '../../../src'
import { ROOT_COLLECTION_ID, CollectionTheme } from '../../../src/collections/domain/models/Collection'
import {
ROOT_COLLECTION_ID,
CollectionTheme
} from '../../../src/collections/domain/models/Collection'
import { CollectionThemePayload } from '../../../src/collections/infra/repositories/transformers/CollectionPayload'
import { AllowedStorageDrivers } from '../../../src/collections/domain/models/AllowedStorageDrivers'
import { StorageDriver } from '../../../src/core/domain/models/StorageDriver'
Expand Down
Loading
Loading