@openneuro/server 4.47.6 → 5.0.0-alpha.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +10 -7
- package/src/app.ts +1 -1
- package/src/cache/__tests__/tree.spec.ts +212 -0
- package/src/cache/tree.ts +148 -0
- package/src/datalad/__tests__/dataRetentionNotifications.spec.ts +11 -0
- package/src/datalad/__tests__/files.spec.ts +249 -0
- package/src/datalad/dataRetentionNotifications.ts +5 -0
- package/src/datalad/dataset.ts +29 -1
- package/src/datalad/files.ts +362 -39
- package/src/datalad/snapshots.ts +29 -54
- package/src/graphql/resolvers/__tests__/response-status.spec.ts +42 -0
- package/src/graphql/resolvers/__tests__/user.spec.ts +55 -1
- package/src/graphql/resolvers/build-search-query.ts +391 -0
- package/src/graphql/resolvers/cache.ts +5 -1
- package/src/graphql/resolvers/dataset-search.ts +40 -23
- package/src/graphql/resolvers/datasetEvents.ts +48 -78
- package/src/graphql/resolvers/draft.ts +5 -2
- package/src/graphql/resolvers/holdDeletion.ts +21 -0
- package/src/graphql/resolvers/index.ts +6 -0
- package/src/graphql/resolvers/mutation.ts +2 -0
- package/src/graphql/resolvers/response-status.ts +43 -0
- package/src/graphql/resolvers/snapshots.ts +9 -18
- package/src/graphql/resolvers/summary.ts +17 -0
- package/src/graphql/resolvers/user.ts +1 -1
- package/src/graphql/schema.ts +54 -14
- package/src/handlers/datalad.ts +4 -0
- package/src/handlers/doi.ts +32 -36
- package/src/libs/doi/__tests__/doi.spec.ts +50 -12
- package/src/libs/doi/__tests__/validate.spec.ts +110 -0
- package/src/libs/doi/index.ts +108 -71
- package/src/libs/doi/metadata.ts +101 -0
- package/src/libs/doi/validate.ts +59 -0
- package/src/libs/presign.ts +137 -0
- package/src/models/dataset.ts +2 -0
- package/src/models/doi.ts +7 -0
- package/src/queues/producer-methods.ts +9 -5
- package/src/queues/queue-schedule.ts +1 -1
- package/src/queues/queues.ts +2 -2
- package/src/routes.ts +10 -2
- package/src/types/datacite/LICENSE +37 -0
- package/src/types/datacite/README.md +3 -0
- package/src/types/datacite/datacite-v4.5.json +643 -0
- package/src/types/datacite/datacite-v4.5.ts +281 -0
- package/src/types/datacite.ts +53 -63
- package/src/utils/datacite-mapper.ts +7 -3
- package/src/utils/datacite-utils.ts +12 -15
- package/src/libs/doi/__tests__/__snapshots__/doi.spec.ts.snap +0 -17
package/src/handlers/doi.ts
CHANGED
|
@@ -1,44 +1,43 @@
|
|
|
1
1
|
import config from "../config"
|
|
2
|
-
import
|
|
2
|
+
import { createDraftDoi } from "../libs/doi"
|
|
3
|
+
import { assembleMetadata } from "../libs/doi/metadata"
|
|
3
4
|
import Doi from "../models/doi"
|
|
4
5
|
import Snapshot from "../models/snapshot"
|
|
5
6
|
|
|
6
7
|
export async function createSnapshotDoi(req, res) {
|
|
7
|
-
let doiRes = null
|
|
8
8
|
if (!config.doi.username || !config.doi.password) {
|
|
9
|
-
return res.send({
|
|
9
|
+
return res.send({ doi: null })
|
|
10
10
|
}
|
|
11
11
|
const datasetId = req.params.datasetId
|
|
12
12
|
const snapshotId = req.params.snapshotId
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
snapshotId: snapshotId,
|
|
17
|
-
})
|
|
13
|
+
|
|
14
|
+
// Return existing DOI if already registered
|
|
15
|
+
const doiExists = await Doi.findOne({ datasetId, snapshotId })
|
|
18
16
|
if (doiExists) {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
17
|
+
return res.send({ doi: doiExists.doi })
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const snapExists = await Snapshot.findOne({
|
|
21
|
+
datasetId,
|
|
22
|
+
tag: snapshotId,
|
|
23
|
+
}).exec()
|
|
24
|
+
if (!snapExists) {
|
|
25
|
+
return res.status(404).send({ error: "Snapshot not found" })
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
try {
|
|
29
|
+
const attributes = await assembleMetadata(datasetId, snapshotId)
|
|
30
|
+
const doi = await createDraftDoi(attributes)
|
|
31
|
+
|
|
32
|
+
await Doi.updateOne(
|
|
33
|
+
{ datasetId, snapshotId },
|
|
34
|
+
{ $set: { doi, state: "draft" } },
|
|
35
|
+
{ upsert: true },
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
return res.send({ doi })
|
|
39
|
+
} catch (err) {
|
|
40
|
+
return res.status(500).send({ error: err.message })
|
|
42
41
|
}
|
|
43
42
|
}
|
|
44
43
|
|
|
@@ -47,11 +46,8 @@ export async function getDoi(req, res) {
|
|
|
47
46
|
const datasetId = req.params.datasetId
|
|
48
47
|
const snapshotId = req.params.snapshotId
|
|
49
48
|
const doi = await Doi.findOne(
|
|
50
|
-
{
|
|
51
|
-
|
|
52
|
-
snapshotId: snapshotId,
|
|
53
|
-
},
|
|
54
|
-
"doi",
|
|
49
|
+
{ datasetId, snapshotId },
|
|
50
|
+
"doi state",
|
|
55
51
|
).exec()
|
|
56
52
|
return res.send(doi)
|
|
57
53
|
}
|
|
@@ -1,25 +1,63 @@
|
|
|
1
1
|
import { vi } from "vitest"
|
|
2
|
-
import {
|
|
2
|
+
import { buildPayload, createDOI, formatBasicAuth } from "../index.js"
|
|
3
3
|
|
|
4
4
|
vi.mock("ioredis")
|
|
5
5
|
|
|
6
6
|
describe("DOI minting utils", () => {
|
|
7
|
-
describe("
|
|
7
|
+
describe("formatBasicAuth()", () => {
|
|
8
8
|
it("returns a base64 basic auth string", () => {
|
|
9
9
|
const doiConfig = { username: "test", password: "12345" }
|
|
10
10
|
expect(formatBasicAuth(doiConfig)).toBe("Basic dGVzdDoxMjM0NQ==")
|
|
11
11
|
})
|
|
12
12
|
})
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
13
|
+
|
|
14
|
+
describe("createDOI()", () => {
|
|
15
|
+
it("creates a DOI without snapshot", () => {
|
|
16
|
+
const doi = createDOI("ds000001")
|
|
17
|
+
expect(doi).toMatch(/\/openneuro\.ds000001$/)
|
|
18
|
+
})
|
|
19
|
+
it("creates a DOI with snapshot", () => {
|
|
20
|
+
const doi = createDOI("ds000001", "1.0.0")
|
|
21
|
+
expect(doi).toMatch(/\/openneuro\.ds000001\.v1\.0\.0$/)
|
|
22
|
+
})
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
describe("buildPayload()", () => {
|
|
26
|
+
const attributes = {
|
|
27
|
+
doi: "10.18112/openneuro.ds000001.v1.0.0",
|
|
28
|
+
url: "https://openneuro.org/datasets/ds000001/versions/1.0.0",
|
|
29
|
+
creators: [{ name: "A. User", nameType: "Personal" as const }],
|
|
30
|
+
titles: [{ title: "Test Dataset" }],
|
|
31
|
+
publisher: { name: "OpenNeuro" },
|
|
32
|
+
publicationYear: "2024",
|
|
33
|
+
types: { resourceTypeGeneral: "Dataset" as const },
|
|
34
|
+
schemaVersion: "http://datacite.org/schema/kernel-4" as const,
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
it("builds a valid Datacite JSON API payload", () => {
|
|
38
|
+
const payload = buildPayload(attributes)
|
|
39
|
+
expect(payload.data.type).toBe("dois")
|
|
40
|
+
expect(payload.data.attributes.doi).toBe(
|
|
41
|
+
"10.18112/openneuro.ds000001.v1.0.0",
|
|
42
|
+
)
|
|
43
|
+
expect(payload.data.attributes.event).toBeUndefined()
|
|
44
|
+
expect(payload.data.attributes.schemaVersion).toBe(
|
|
45
|
+
"http://datacite.org/schema/kernel-4",
|
|
46
|
+
)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it("omits event when not provided", () => {
|
|
50
|
+
const payload = buildPayload(attributes)
|
|
51
|
+
expect(payload.data.attributes.event).toBeUndefined()
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it("preserves all metadata attributes", () => {
|
|
55
|
+
const payload = buildPayload(attributes, "publish")
|
|
56
|
+
expect(payload.data.attributes.creators).toHaveLength(1)
|
|
57
|
+
expect(payload.data.attributes.titles[0].title).toBe("Test Dataset")
|
|
58
|
+
expect(payload.data.attributes.publisher.name).toBe("OpenNeuro")
|
|
59
|
+
expect(payload.data.attributes.publicationYear).toBe("2024")
|
|
60
|
+
expect(payload.data.attributes.types.resourceTypeGeneral).toBe("Dataset")
|
|
23
61
|
})
|
|
24
62
|
})
|
|
25
63
|
})
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { vi } from "vitest"
|
|
2
|
+
import { validateDataciteMetadata } from "../validate.js"
|
|
3
|
+
import type { ResourceTypeGeneral } from "../../../types/datacite/datacite-v4.5.ts"
|
|
4
|
+
|
|
5
|
+
vi.mock("ioredis")
|
|
6
|
+
|
|
7
|
+
describe("validateDataciteMetadata", () => {
|
|
8
|
+
const validAttrs = {
|
|
9
|
+
doi: "10.18112/openneuro.ds000001.v1.0.0",
|
|
10
|
+
url: "https://openneuro.org/datasets/ds000001/versions/1.0.0",
|
|
11
|
+
creators: [{ name: "A. User", nameType: "Personal" as const }],
|
|
12
|
+
titles: [{ title: "Test Dataset" }],
|
|
13
|
+
publisher: { name: "OpenNeuro" },
|
|
14
|
+
publicationYear: "2024",
|
|
15
|
+
types: { resourceTypeGeneral: "Dataset" as const },
|
|
16
|
+
schemaVersion: "http://datacite.org/schema/kernel-4" as const,
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
it("returns no errors for valid metadata", () => {
|
|
20
|
+
expect(validateDataciteMetadata(validAttrs)).toEqual([])
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it("requires at least one creator", () => {
|
|
24
|
+
const errors = validateDataciteMetadata({ ...validAttrs, creators: [] })
|
|
25
|
+
expect(errors).toEqual(
|
|
26
|
+
expect.arrayContaining([
|
|
27
|
+
expect.objectContaining({ field: "creators" }),
|
|
28
|
+
]),
|
|
29
|
+
)
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it("requires each creator to have a name", () => {
|
|
33
|
+
const errors = validateDataciteMetadata({
|
|
34
|
+
...validAttrs,
|
|
35
|
+
creators: [{ name: "", nameType: "Personal" }],
|
|
36
|
+
})
|
|
37
|
+
expect(errors).toEqual(
|
|
38
|
+
expect.arrayContaining([
|
|
39
|
+
expect.objectContaining({ field: "creators" }),
|
|
40
|
+
]),
|
|
41
|
+
)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it("requires at least one title", () => {
|
|
45
|
+
const errors = validateDataciteMetadata({ ...validAttrs, titles: [] })
|
|
46
|
+
expect(errors).toEqual(
|
|
47
|
+
expect.arrayContaining([
|
|
48
|
+
expect.objectContaining({ field: "titles" }),
|
|
49
|
+
]),
|
|
50
|
+
)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it("requires a non-empty title", () => {
|
|
54
|
+
const errors = validateDataciteMetadata({
|
|
55
|
+
...validAttrs,
|
|
56
|
+
titles: [{ title: "" }],
|
|
57
|
+
})
|
|
58
|
+
expect(errors).toEqual(
|
|
59
|
+
expect.arrayContaining([
|
|
60
|
+
expect.objectContaining({ field: "titles" }),
|
|
61
|
+
]),
|
|
62
|
+
)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it("requires publisher name", () => {
|
|
66
|
+
const errors = validateDataciteMetadata({
|
|
67
|
+
...validAttrs,
|
|
68
|
+
publisher: { name: "" },
|
|
69
|
+
})
|
|
70
|
+
expect(errors).toEqual(
|
|
71
|
+
expect.arrayContaining([
|
|
72
|
+
expect.objectContaining({ field: "publisher" }),
|
|
73
|
+
]),
|
|
74
|
+
)
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it("requires a four-digit year string for publicationYear", () => {
|
|
78
|
+
const errors = validateDataciteMetadata({
|
|
79
|
+
...validAttrs,
|
|
80
|
+
publicationYear: "0",
|
|
81
|
+
})
|
|
82
|
+
expect(errors).toEqual(
|
|
83
|
+
expect.arrayContaining([
|
|
84
|
+
expect.objectContaining({ field: "publicationYear" }),
|
|
85
|
+
]),
|
|
86
|
+
)
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it("requires resourceTypeGeneral", () => {
|
|
90
|
+
const errors = validateDataciteMetadata({
|
|
91
|
+
...validAttrs,
|
|
92
|
+
types: {
|
|
93
|
+
resourceTypeGeneral: "" as unknown as ResourceTypeGeneral,
|
|
94
|
+
},
|
|
95
|
+
})
|
|
96
|
+
expect(errors).toEqual(
|
|
97
|
+
expect.arrayContaining([
|
|
98
|
+
expect.objectContaining({ field: "types" }),
|
|
99
|
+
]),
|
|
100
|
+
)
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it("returns multiple errors when multiple fields are invalid", () => {
|
|
104
|
+
const errors = validateDataciteMetadata({
|
|
105
|
+
doi: "10.18112/test",
|
|
106
|
+
url: "https://example.com",
|
|
107
|
+
})
|
|
108
|
+
expect(errors.length).toBeGreaterThanOrEqual(4)
|
|
109
|
+
})
|
|
110
|
+
})
|
package/src/libs/doi/index.ts
CHANGED
|
@@ -1,32 +1,5 @@
|
|
|
1
|
-
import request from "superagent"
|
|
2
1
|
import config from "../../config"
|
|
3
|
-
|
|
4
|
-
export const template = ({
|
|
5
|
-
doi,
|
|
6
|
-
creators,
|
|
7
|
-
title,
|
|
8
|
-
year,
|
|
9
|
-
resourceType,
|
|
10
|
-
}) =>
|
|
11
|
-
`<?xml version="1.0" encoding="UTF-8"?>
|
|
12
|
-
<resource xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns="http://datacite.org/schema/kernel-4" xsi:schemaLocation="http://datacite.org/schema/kernel-4 http://schema.datacite.org/meta/kernel-4/metadata.xsd">
|
|
13
|
-
<identifier identifierType="DOI">${doi}</identifier>
|
|
14
|
-
<creators>
|
|
15
|
-
${
|
|
16
|
-
creators
|
|
17
|
-
.map((creator) =>
|
|
18
|
-
`<creator><creatorName>${creator}</creatorName></creator>`
|
|
19
|
-
)
|
|
20
|
-
.join("")
|
|
21
|
-
}
|
|
22
|
-
</creators>
|
|
23
|
-
<titles>
|
|
24
|
-
<title xml:lang="en-us">${title}</title>
|
|
25
|
-
</titles>
|
|
26
|
-
<publisher>Openneuro</publisher>
|
|
27
|
-
<publicationYear>${year}</publicationYear>
|
|
28
|
-
<resourceType resourceTypeGeneral="Dataset">${resourceType}</resourceType>
|
|
29
|
-
</resource>`
|
|
2
|
+
import type { DataCite, DataciteDoiRequest } from "../../types/datacite"
|
|
30
3
|
|
|
31
4
|
/**
|
|
32
5
|
* @param {Object} doiConfig
|
|
@@ -37,50 +10,114 @@ export const formatBasicAuth = (doiConfig) =>
|
|
|
37
10
|
"Basic " +
|
|
38
11
|
Buffer.from(doiConfig.username + ":" + doiConfig.password).toString("base64")
|
|
39
12
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
13
|
+
/**
|
|
14
|
+
* Build a DOI string from dataset accession number and optional snapshot ID.
|
|
15
|
+
*/
|
|
16
|
+
export function createDOI(accNumber: string, snapshotId?: string): string {
|
|
17
|
+
let doi = config.doi.prefix + "/openneuro." + accNumber
|
|
18
|
+
if (snapshotId) {
|
|
19
|
+
doi = doi + ".v" + snapshotId
|
|
20
|
+
}
|
|
21
|
+
return doi
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Build the Datacite JSON API request payload.
|
|
26
|
+
*/
|
|
27
|
+
export function buildPayload(
|
|
28
|
+
attributes: DataCite,
|
|
29
|
+
event?: DataCite["event"],
|
|
30
|
+
): DataciteDoiRequest {
|
|
31
|
+
return {
|
|
32
|
+
data: {
|
|
33
|
+
type: "dois",
|
|
34
|
+
attributes: {
|
|
35
|
+
...attributes,
|
|
36
|
+
...(event ? { event } : {}),
|
|
37
|
+
schemaVersion: "http://datacite.org/schema/kernel-4",
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Create or update a DOI via the Datacite JSON REST API.
|
|
45
|
+
* Uses PUT to {baseUrl}dois/{doi} which handles both create and update.
|
|
46
|
+
*/
|
|
47
|
+
export async function upsertDoi(
|
|
48
|
+
payload: DataciteDoiRequest,
|
|
49
|
+
): Promise<Response> {
|
|
50
|
+
const doi = payload.data.attributes.doi
|
|
51
|
+
const url = `${config.doi.url}dois/${encodeURIComponent(doi)}`
|
|
52
|
+
const response = await fetch(url, {
|
|
53
|
+
method: "PUT",
|
|
54
|
+
headers: {
|
|
55
|
+
"Authorization": formatBasicAuth(config.doi),
|
|
56
|
+
"Content-Type": "application/vnd.api+json",
|
|
57
|
+
},
|
|
58
|
+
body: JSON.stringify(payload),
|
|
59
|
+
})
|
|
60
|
+
if (!response.ok) {
|
|
61
|
+
const body = await response.text()
|
|
62
|
+
throw new Error(
|
|
63
|
+
`Datacite API error ${response.status} for ${doi}: ${body}`,
|
|
64
|
+
)
|
|
65
|
+
}
|
|
66
|
+
return response
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Transition a DOI's state without re-sending full metadata.
|
|
71
|
+
*/
|
|
72
|
+
export async function updateDoiState(
|
|
73
|
+
doi: string,
|
|
74
|
+
event: DataCite["event"],
|
|
75
|
+
): Promise<void> {
|
|
76
|
+
const url = `${config.doi.url}dois/${encodeURIComponent(doi)}`
|
|
77
|
+
const payload = {
|
|
78
|
+
data: {
|
|
79
|
+
type: "dois",
|
|
80
|
+
attributes: { event },
|
|
81
|
+
},
|
|
82
|
+
}
|
|
83
|
+
const response = await fetch(url, {
|
|
84
|
+
method: "PUT",
|
|
85
|
+
headers: {
|
|
86
|
+
"Authorization": formatBasicAuth(config.doi),
|
|
87
|
+
"Content-Type": "application/vnd.api+json",
|
|
88
|
+
},
|
|
89
|
+
body: JSON.stringify(payload),
|
|
90
|
+
})
|
|
91
|
+
if (!response.ok) {
|
|
92
|
+
const body = await response.text()
|
|
93
|
+
throw new Error(
|
|
94
|
+
`Datacite API state transition error ${response.status} for ${doi}: ${body}`,
|
|
95
|
+
)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
49
98
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
99
|
+
/**
|
|
100
|
+
* Create a draft DOI for a dataset snapshot.
|
|
101
|
+
* Returns the DOI string.
|
|
102
|
+
*/
|
|
103
|
+
export async function createDraftDoi(
|
|
104
|
+
attributes: DataCite,
|
|
105
|
+
): Promise<string> {
|
|
106
|
+
const payload = buildPayload(attributes)
|
|
107
|
+
await upsertDoi(payload)
|
|
108
|
+
return attributes.doi
|
|
109
|
+
}
|
|
57
110
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
.send(xml)
|
|
65
|
-
},
|
|
111
|
+
/**
|
|
112
|
+
* Transition a DOI from draft to findable.
|
|
113
|
+
*/
|
|
114
|
+
export async function publishDoi(doi: string): Promise<void> {
|
|
115
|
+
await updateDoiState(doi, "publish")
|
|
116
|
+
}
|
|
66
117
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
doi: baseDoi,
|
|
73
|
-
creators: oldDesc.Authors.filter((x) => x),
|
|
74
|
-
title: oldDesc.Name,
|
|
75
|
-
year: new Date().getFullYear(),
|
|
76
|
-
resourceType: "fMRI",
|
|
77
|
-
}
|
|
78
|
-
return this.registerMetadata(context)
|
|
79
|
-
.then(() => {
|
|
80
|
-
return this.mintDOI(baseDoi, url)
|
|
81
|
-
})
|
|
82
|
-
.then(() => {
|
|
83
|
-
return baseDoi
|
|
84
|
-
})
|
|
85
|
-
},
|
|
118
|
+
/**
|
|
119
|
+
* Transition a DOI from findable to registered (hidden but reserved).
|
|
120
|
+
*/
|
|
121
|
+
export async function hideDoi(doi: string): Promise<void> {
|
|
122
|
+
await updateDoiState(doi, "hide")
|
|
86
123
|
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import config from "../../config"
|
|
2
|
+
import { createDOI } from "./index"
|
|
3
|
+
import { validateDataciteMetadata } from "./validate"
|
|
4
|
+
import { getDataciteYml } from "../../utils/datacite-utils"
|
|
5
|
+
import { description } from "../../datalad/description"
|
|
6
|
+
import { getPrimaryModality } from "../../graphql/resolvers/summary"
|
|
7
|
+
import type { Creator, DataCite } from "../../types/datacite"
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Assemble Datacite metadata for a DOI from datacite.yml or BIDS fallback.
|
|
11
|
+
*
|
|
12
|
+
* Priority:
|
|
13
|
+
* 1. If datacite.yml exists and has creators, use its attributes as the base.
|
|
14
|
+
* 2. Otherwise, build minimal metadata from dataset_description.json.
|
|
15
|
+
*
|
|
16
|
+
* Always ensures publisher, publicationYear, types.resourceTypeGeneral,
|
|
17
|
+
* doi, and url are set.
|
|
18
|
+
*/
|
|
19
|
+
export async function assembleMetadata(
|
|
20
|
+
datasetId: string,
|
|
21
|
+
snapshotId: string,
|
|
22
|
+
revision?: string,
|
|
23
|
+
): Promise<DataCite> {
|
|
24
|
+
const doi = createDOI(datasetId, snapshotId)
|
|
25
|
+
const url = `${config.url}/datasets/${datasetId}/versions/${snapshotId}`
|
|
26
|
+
|
|
27
|
+
const dataciteYml = await getDataciteYml(datasetId, revision)
|
|
28
|
+
const ymlAttrs = dataciteYml?.data?.attributes
|
|
29
|
+
|
|
30
|
+
// Check if datacite.yml provided meaningful creator data
|
|
31
|
+
const hasDataciteCreators = Array.isArray(ymlAttrs?.creators) &&
|
|
32
|
+
ymlAttrs.creators.length > 0
|
|
33
|
+
|
|
34
|
+
let creators: Creator[]
|
|
35
|
+
let titles: DataCite["titles"]
|
|
36
|
+
let descriptions: DataCite["descriptions"]
|
|
37
|
+
let contributors: DataCite["contributors"]
|
|
38
|
+
let resourceType: string | undefined
|
|
39
|
+
|
|
40
|
+
if (hasDataciteCreators) {
|
|
41
|
+
// Use datacite.yml metadata
|
|
42
|
+
creators = ymlAttrs.creators
|
|
43
|
+
titles = ymlAttrs.descriptions?.length
|
|
44
|
+
? [{ title: ymlAttrs.descriptions[0].description }]
|
|
45
|
+
: []
|
|
46
|
+
descriptions = ymlAttrs.descriptions
|
|
47
|
+
contributors = ymlAttrs.contributors
|
|
48
|
+
resourceType = ymlAttrs.types?.resourceType
|
|
49
|
+
} else {
|
|
50
|
+
// Fall back to BIDS dataset_description.json
|
|
51
|
+
const desc = await description({
|
|
52
|
+
id: datasetId,
|
|
53
|
+
revision: revision || "HEAD",
|
|
54
|
+
})
|
|
55
|
+
creators = (desc.Authors || [])
|
|
56
|
+
.filter((author: string) => author)
|
|
57
|
+
.map((author: string) => ({
|
|
58
|
+
name: author,
|
|
59
|
+
nameType: "Personal" as const,
|
|
60
|
+
}))
|
|
61
|
+
titles = [{ title: desc.Name || datasetId }]
|
|
62
|
+
descriptions = desc.Description
|
|
63
|
+
? [{ description: desc.Description, descriptionType: "Abstract" }]
|
|
64
|
+
: undefined
|
|
65
|
+
contributors = undefined
|
|
66
|
+
resourceType = await getPrimaryModality(datasetId)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// If datacite.yml had titles via a different path, use them
|
|
70
|
+
if (hasDataciteCreators && titles.length === 0) {
|
|
71
|
+
const desc = await description({
|
|
72
|
+
id: datasetId,
|
|
73
|
+
revision: revision || "HEAD",
|
|
74
|
+
})
|
|
75
|
+
titles = [{ title: desc.Name || datasetId }]
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const attributes: DataCite = {
|
|
79
|
+
doi,
|
|
80
|
+
url,
|
|
81
|
+
creators: creators as DataCite["creators"],
|
|
82
|
+
titles: titles as DataCite["titles"],
|
|
83
|
+
publisher: { name: "OpenNeuro" },
|
|
84
|
+
publicationYear: String(new Date().getFullYear()),
|
|
85
|
+
types: {
|
|
86
|
+
resourceTypeGeneral: "Dataset",
|
|
87
|
+
...(resourceType ? { resourceType } : {}),
|
|
88
|
+
},
|
|
89
|
+
schemaVersion: "http://datacite.org/schema/kernel-4",
|
|
90
|
+
...(descriptions ? { descriptions } : {}),
|
|
91
|
+
...(contributors?.length ? { contributors } : {}),
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const errors = validateDataciteMetadata(attributes)
|
|
95
|
+
if (errors.length > 0) {
|
|
96
|
+
const messages = errors.map((e) => `${e.field}: ${e.message}`).join("; ")
|
|
97
|
+
throw new Error(`DOI metadata validation failed: ${messages}`)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return attributes
|
|
101
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { DataCite } from "../../types/datacite"
|
|
2
|
+
|
|
3
|
+
export interface ValidationError {
|
|
4
|
+
field: string
|
|
5
|
+
message: string
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Validate required Datacite metadata fields before submitting to the API.
|
|
10
|
+
* Returns an empty array if valid.
|
|
11
|
+
*/
|
|
12
|
+
export function validateDataciteMetadata(
|
|
13
|
+
attrs: Partial<DataCite>,
|
|
14
|
+
): ValidationError[] {
|
|
15
|
+
const errors: ValidationError[] = []
|
|
16
|
+
|
|
17
|
+
if (!Array.isArray(attrs.creators) || attrs.creators.length === 0) {
|
|
18
|
+
errors.push({
|
|
19
|
+
field: "creators",
|
|
20
|
+
message: "At least one creator is required",
|
|
21
|
+
})
|
|
22
|
+
} else {
|
|
23
|
+
for (const creator of attrs.creators) {
|
|
24
|
+
if (!creator.name) {
|
|
25
|
+
errors.push({
|
|
26
|
+
field: "creators",
|
|
27
|
+
message: "Each creator must have a name",
|
|
28
|
+
})
|
|
29
|
+
break
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (!Array.isArray(attrs.titles) || attrs.titles.length === 0) {
|
|
35
|
+
errors.push({ field: "titles", message: "At least one title is required" })
|
|
36
|
+
} else if (!attrs.titles[0].title) {
|
|
37
|
+
errors.push({ field: "titles", message: "Title must not be empty" })
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (!attrs.publisher?.name) {
|
|
41
|
+
errors.push({ field: "publisher", message: "Publisher name is required" })
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (!attrs.publicationYear || !/^[0-9]{4}$/.test(attrs.publicationYear)) {
|
|
45
|
+
errors.push({
|
|
46
|
+
field: "publicationYear",
|
|
47
|
+
message: "Publication year must be a four-digit year string",
|
|
48
|
+
})
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (!attrs.types?.resourceTypeGeneral) {
|
|
52
|
+
errors.push({
|
|
53
|
+
field: "types",
|
|
54
|
+
message: "resourceTypeGeneral is required",
|
|
55
|
+
})
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return errors
|
|
59
|
+
}
|