jskos-server 2.4.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.
Files changed (112) hide show
  1. package/.dockerignore +20 -0
  2. package/.editorconfig +9 -0
  3. package/.github/workflows/docker.yml +59 -0
  4. package/.github/workflows/gh-pages.yml +23 -0
  5. package/.github/workflows/gh-release.yml +19 -0
  6. package/.github/workflows/test.yml +39 -0
  7. package/.husky/pre-commit +1 -0
  8. package/CHANGELOG.md +18 -0
  9. package/LICENSE +21 -0
  10. package/README.md +2710 -0
  11. package/bin/extra.js +81 -0
  12. package/bin/import.js +438 -0
  13. package/bin/mongodb.js +21 -0
  14. package/bin/reset.js +257 -0
  15. package/bin/upgrade.js +34 -0
  16. package/config/config.default.json +88 -0
  17. package/config/config.schema.json +877 -0
  18. package/config/config.test.json +107 -0
  19. package/config/index.js +77 -0
  20. package/config/setup.js +212 -0
  21. package/depdendencies.png +0 -0
  22. package/docker/.env +1 -0
  23. package/docker/Dockerfile +20 -0
  24. package/docker/README.md +175 -0
  25. package/docker/docker-compose.yml +29 -0
  26. package/docker/docker-entrypoint.sh +8 -0
  27. package/docker/mongo-initdb.d/mongo_setup.sh +22 -0
  28. package/ecosystem.example.json +7 -0
  29. package/errors/index.js +94 -0
  30. package/eslint.config.js +17 -0
  31. package/index.js +10 -0
  32. package/models/annotations.js +13 -0
  33. package/models/concepts.js +12 -0
  34. package/models/concordances.js +12 -0
  35. package/models/index.js +33 -0
  36. package/models/mappings.js +20 -0
  37. package/models/meta.js +21 -0
  38. package/models/registries.js +12 -0
  39. package/models/schemes.js +12 -0
  40. package/package.json +91 -0
  41. package/routes/annotations.js +83 -0
  42. package/routes/common.js +86 -0
  43. package/routes/concepts.js +64 -0
  44. package/routes/concordances.js +86 -0
  45. package/routes/data.js +19 -0
  46. package/routes/mappings.js +108 -0
  47. package/routes/registries.js +24 -0
  48. package/routes/schemes.js +72 -0
  49. package/routes/validate.js +37 -0
  50. package/server.js +190 -0
  51. package/services/abstract.js +328 -0
  52. package/services/annotations.js +237 -0
  53. package/services/concepts.js +459 -0
  54. package/services/concordances.js +264 -0
  55. package/services/data.js +30 -0
  56. package/services/index.js +34 -0
  57. package/services/mappings.js +978 -0
  58. package/services/registries.js +319 -0
  59. package/services/schemes.js +318 -0
  60. package/services/validate.js +39 -0
  61. package/status.schema.json +145 -0
  62. package/test/abstract-service.js +36 -0
  63. package/test/annotations/annotation.json +13 -0
  64. package/test/api.js +2481 -0
  65. package/test/chai.js +14 -0
  66. package/test/changes.js +179 -0
  67. package/test/concepts/conceptNoFileEnding +4 -0
  68. package/test/concepts/concepts-ddc-6-60-61-62.json +123 -0
  69. package/test/concordances/concordances.ndjson +2 -0
  70. package/test/config.js +26 -0
  71. package/test/configs/complex-config.json +90 -0
  72. package/test/configs/empty-object.json +1 -0
  73. package/test/configs/fail-array.json +1 -0
  74. package/test/configs/fail-empty.json +0 -0
  75. package/test/configs/fail-mapping-only-props1.json +5 -0
  76. package/test/configs/fail-mapping-only-props2.json +5 -0
  77. package/test/configs/fail-mapping-only-props3.json +5 -0
  78. package/test/configs/fail-mapping-only-props4.json +5 -0
  79. package/test/configs/fail-nonexisting-prop.json +3 -0
  80. package/test/configs/fail-port-string.json +3 -0
  81. package/test/configs/fail-registry-types.json +16 -0
  82. package/test/configs/registry-types.json +16 -0
  83. package/test/data-write.js +784 -0
  84. package/test/eslint.js +22 -0
  85. package/test/import-reset.js +287 -0
  86. package/test/infer-mappings.js +340 -0
  87. package/test/ipcheck.js +287 -0
  88. package/test/mappings/README.md +1 -0
  89. package/test/mappings/ddc-gnd-1.mapping.json +33 -0
  90. package/test/mappings/ddc-gnd-2.mapping.json +67 -0
  91. package/test/mappings/mapping-ddc-gnd-noScheme.json +145 -0
  92. package/test/mappings/mapping-ddc-gnd.json +175 -0
  93. package/test/mappings/mappings-ddc.json +214 -0
  94. package/test/registries/registries.ndjson +2 -0
  95. package/test/services.js +557 -0
  96. package/test/terminologies/terminologies.json +94 -0
  97. package/test/test-utils.js +182 -0
  98. package/test/utils.js +425 -0
  99. package/test/validate.js +226 -0
  100. package/utils/adjust.js +206 -0
  101. package/utils/auth.js +154 -0
  102. package/utils/changes.js +88 -0
  103. package/utils/db.js +106 -0
  104. package/utils/ipcheck.js +76 -0
  105. package/utils/middleware.js +636 -0
  106. package/utils/searchHelper.js +153 -0
  107. package/utils/status.js +77 -0
  108. package/utils/users.js +7 -0
  109. package/utils/utils.js +114 -0
  110. package/utils/uuid.js +6 -0
  111. package/utils/version.js +324 -0
  112. package/views/base.ejs +172 -0
@@ -0,0 +1,319 @@
1
+ import _ from "lodash"
2
+ import { removeNullProperties } from "../utils/utils.js"
3
+ import { validate } from "jskos-validate"
4
+ import { Registry } from "../models/registries.js"
5
+ import { addKeywords } from "../utils/searchHelper.js"
6
+ import { EntityNotFoundError, DatabaseAccessError, InvalidBodyError, MalformedBodyError } from "../errors/index.js"
7
+
8
+ import { AbstractService } from "./abstract.js"
9
+
10
+ // TODO: get via factory Method
11
+ import { models } from "../models/index.js"
12
+
13
+ export class RegistryService extends AbstractService {
14
+ static allMemberTypes = ["schemes", "concepts", "mappings", "concordances", "annotations", "registries"]
15
+
16
+ constructor(config) {
17
+ super(config)
18
+ this.config = config.registries || {}
19
+ this.types = {}
20
+
21
+ this.model = Registry
22
+
23
+ // TODO: duplicated code in config.setup
24
+ for (let type of RegistryService.allMemberTypes) {
25
+ this.types[type] = config.types?.[type]
26
+ if (this.types[type] === true) {
27
+ this.types[type] = { mustExist: false, skipInvalid: false }
28
+ }
29
+ if (this.types[type] && !("uriRequired" in this.types[type])) {
30
+ this.types[type].uriRequired = true
31
+ }
32
+ }
33
+ }
34
+
35
+ /**
36
+ * Retrieves registry entries.
37
+ *
38
+ * @param {Object} query - Query parameters controlling pagination.
39
+ * @param {number|string} [query.limit=100] - Maximum number of registries to fetch.
40
+ * @param {number|string} [query.offset=0] - Number of registries to skip before fetching.
41
+ * @returns {Promise<Object[]>} A promise that resolves to the matching registries.
42
+ */
43
+ async queryItems(query) {
44
+ const { limit, offset } = this._getLimitAndOffset(query)
45
+ return this.model.find({}).skip(offset).limit(limit).lean().exec()
46
+ }
47
+
48
+ /**
49
+ * Prepares and checks a registry before inserting/updating:
50
+ * - validates object, throws error if it doesn't (create/update)
51
+ * - add `_id` property (create/update)
52
+ * - add search keyword fields for text index (create/update)
53
+ *
54
+ * @param {Object} registry registry object
55
+ * @param {string} action one of "create" or "update"
56
+ * @returns {Object} prepared registry
57
+ */
58
+ async prepareAndCheckItemForAction(registry, action) {
59
+ if (typeof registry !== "object") {
60
+ throw new MalformedBodyError("Invalid registry object")
61
+ }
62
+
63
+ if (["create", "update"].includes(action)) {
64
+ // Validate registry
65
+ if (!validate.registry(registry)) {
66
+ // TODO: use error object
67
+ const msgs = validate.registry.errorMessages || ["Registry validation failed"]
68
+ throw new InvalidBodyError(msgs.join("; "))
69
+ }
70
+ if (!registry.uri) {
71
+ // TODO: how about minting URIs?
72
+ throw new InvalidBodyError("Registry lacks uri")
73
+ }
74
+
75
+ await this.processMembers(registry)
76
+
77
+ // Add _id
78
+ registry._id = registry.uri
79
+
80
+ // Add index keywords
81
+ addKeywords(registry)
82
+
83
+ // Remove created for update action // TODO: why?
84
+ if (action === "update") {
85
+ delete registry.created
86
+ }
87
+ }
88
+ return registry
89
+ }
90
+
91
+
92
+ /**
93
+ * Updates an existing registry entry, while preserving immutable fields.
94
+ *
95
+ * @param {Object} params - Parameters for updating the registry.
96
+ * @param {Object} params.body - The new registry data to store.
97
+ * @param {Object} params.existing - The existing registry document from the database.
98
+ * @returns {Promise<Object>} The updated registry document.
99
+ * @throws {InvalidBodyError} If the request body is missing or fails validation.
100
+ * @throws {EntityNotFoundError} If the targeted registry is not found.
101
+ * @throws {DatabaseAccessError} If the database operation fails.
102
+ */
103
+ async updateItem({ body, existing }) {
104
+ if (!body) {
105
+ throw new InvalidBodyError()
106
+ }
107
+
108
+ body.modified = new Date().toISOString()
109
+
110
+ delete body.type
111
+
112
+ // Validate registry
113
+ if (!validate.registry(body)) {
114
+ const msgs = validate.registry?.errorMessages || []
115
+ throw new InvalidBodyError(
116
+ msgs.join("; ") || "Registry validation failed",
117
+ )
118
+ }
119
+
120
+ await this.processMembers(body)
121
+
122
+ // Preserve existing property: created date
123
+ body.created = existing.created
124
+
125
+ // Override _id and id properties
126
+ body.id = existing.id
127
+ body._id = existing._id
128
+
129
+ // Replace in database
130
+ const result = await this.model.replaceOne({ _id: existing._id }, body)
131
+ if (!result.matchedCount) {
132
+ throw new EntityNotFoundError(`Registry not found: ${existing._id}`)
133
+ }
134
+
135
+ // Confirm that the update was acknowledged
136
+ if (!result.acknowledged) {
137
+ throw new DatabaseAccessError()
138
+ }
139
+
140
+ // Return the updated registry entry
141
+ const doc = await this.model.findById(existing._id).lean()
142
+ if (!doc) {
143
+ throw new DatabaseAccessError()
144
+ }
145
+
146
+ return doc
147
+ }
148
+
149
+ /**
150
+ * Partially updates a registry entry.
151
+ *
152
+ * @async
153
+ * @function patchRegistry
154
+ * @param {Object} params - Parameters for updating a registry.
155
+ * @param {Object} params.body - Incoming registry fields to merge into the existing entry.
156
+ * @param {Object} params.existing - The existing registry document from the database to be updated.
157
+ * @throws {InvalidBodyError} If the request body is missing or fails validation.
158
+ * @throws {EntityNotFoundError} If the target registry does not exist.
159
+ * @throws {DatabaseAccessError} If the database update fails.
160
+ * @returns {Promise<Object>} The updated registry document from the database.
161
+ */
162
+ async patchRegistry({ body, existing }) {
163
+ if (!body) {
164
+ throw new InvalidBodyError()
165
+ }
166
+ if (!existing?._id) {
167
+ throw new EntityNotFoundError("Registry not found")
168
+ }
169
+
170
+ existing.modified = new Date().toISOString()
171
+
172
+ // Protect identity + server-managed fields
173
+ for (let key of ["_id", "id", "uri", "created"]) {
174
+ delete body[key]
175
+ }
176
+
177
+ // Merge existing with updates
178
+ _.assign(existing, body)
179
+
180
+ removeNullProperties(existing)
181
+
182
+ await this.processMembers(existing)
183
+
184
+ // Validate merged object
185
+ if (!validate.registry(existing)) {
186
+ const msgs = validate.registry.errorMessages || []
187
+ throw new InvalidBodyError(
188
+ msgs.join("; ") || "Registry validation failed",
189
+ )
190
+ }
191
+
192
+ // Replace in database
193
+ const result = await this.model.replaceOne({ _id: existing._id }, existing)
194
+ if (!result.matchedCount) {
195
+ throw new EntityNotFoundError(`Registry not found: ${existing._id}`)
196
+ }
197
+
198
+ // Confirm that the update was acknowledged
199
+ if (!result.acknowledged) {
200
+ throw new DatabaseAccessError()
201
+ }
202
+
203
+ // Return the updated registry entry
204
+ const doc = await this.model.findById(existing._id).lean()
205
+ if (!doc) {
206
+ throw new DatabaseAccessError()
207
+ }
208
+
209
+ return doc
210
+ }
211
+
212
+ /**
213
+ * Initializes the Registry collection by creating it if absent, removing any existing indexes,
214
+ * and establishing the full set of required indexes
215
+ */
216
+ async createIndexes() {
217
+ const indexes = []
218
+ indexes.push([{ uri: 1 }, {}])
219
+ indexes.push([{ identifier: 1 }, {}])
220
+ indexes.push([{ notation: 1 }, {}])
221
+ indexes.push([{ type: 1 }, {}])
222
+
223
+ indexes.push([{ created: 1 }, {}])
224
+ indexes.push([{ modified: 1 }, {}])
225
+ indexes.push([{ startDate: 1 }, {}])
226
+ indexes.push([{ endDate: 1 }, {}])
227
+
228
+ indexes.push([{ url: 1 }, {}])
229
+ indexes.push([{ "subject.uri": 1 }, {}])
230
+
231
+ // TODO: check this
232
+ indexes.push([{ _keywordsLabels: 1 }, {}])
233
+ indexes.push([{ _keywordsPublisher: 1 }, {}])
234
+ indexes.push([{ "_keywordsLabels.0": 1 }, {}])
235
+ indexes.push([
236
+ {
237
+ _keywordsNotation: "text",
238
+ _keywordsLabels: "text",
239
+ _keywordsOther: "text",
240
+ _keywordsPublisher: "text",
241
+ },
242
+ {
243
+ name: "text",
244
+ default_language: "german", // ??
245
+ weights: {
246
+ _keywordsNotation: 10,
247
+ _keywordsLabels: 6,
248
+ _keywordsOther: 3,
249
+ _keywordsPublisher: 3,
250
+ },
251
+ },
252
+ ])
253
+
254
+ // Create collection if necessary
255
+ try {
256
+ await this.model.createCollection()
257
+ } catch (error) {
258
+ this.error("Error creating collection:", error)
259
+ // Ignore error
260
+ }
261
+
262
+ // Drop existing indexes
263
+ await this.model.collection.dropIndexes()
264
+ for (const [index, options] of indexes) {
265
+ await this.model.collection.createIndex(index, options)
266
+ }
267
+ }
268
+
269
+ /**
270
+ * Validates and filters registry member fields based on config.
271
+ *
272
+ * @param {Object} registry registry object
273
+ * @throws {InvalidBodyError} When a disallowed membership field is present.
274
+ */
275
+ async processMembers(registry) {
276
+ const usedTypes = RegistryService.allMemberTypes.filter(type => registry[type])
277
+
278
+ if (!this.config.mixedTypes) {
279
+ if (usedTypes.length > 1) {
280
+ throw new InvalidBodyError(`Registry must not have multiple member types, found: ${usedTypes.join(", ")}`)
281
+ }
282
+ }
283
+
284
+ for (let type of usedTypes) {
285
+ if (!this.config.types[type]) {
286
+ throw new InvalidBodyError(`Registry member type not allowed: ${type}`)
287
+ }
288
+
289
+ const { uriRequired, mustExist, skipInvalid } = this.config.types[type]
290
+
291
+ const validator = validate[type.replace(/ies$/,"y").replace(/s$/,"")]
292
+
293
+ let items = []
294
+ for (let item of registry[type].filter(item => item !== null)) {
295
+ let error
296
+ if (uriRequired && !item.uri) {
297
+ error = `missing ${type} uri in registry`
298
+ } else if (!validator(item)) {
299
+ error = `invalid ${type} in registry`
300
+ // TODO: use validator.errors error object
301
+ } else if (mustExist) {
302
+ const found = await models[type].findOne({ uri: item.uri }).lean()
303
+ if (!found) {
304
+ error = `${type} not found with uri: ${item.uri}`
305
+ }
306
+ }
307
+ if (error) {
308
+ if (!skipInvalid) {
309
+ throw new InvalidBodyError(error)
310
+ }
311
+ } else {
312
+ items.push(item)
313
+ }
314
+ }
315
+ registry[type] = items
316
+ }
317
+
318
+ }
319
+ }
@@ -0,0 +1,318 @@
1
+ import _ from "lodash"
2
+ import { validate } from "jskos-validate"
3
+
4
+ import { addKeywords } from "../utils/searchHelper.js"
5
+ import { MalformedBodyError, MalformedRequestError, EntityNotFoundError, DatabaseAccessError, InvalidBodyError } from "../errors/index.js"
6
+ import { Scheme } from "../models/schemes.js"
7
+ import { Concept } from "../models/concepts.js"
8
+
9
+ import { AbstractService } from "./abstract.js"
10
+
11
+ export class SchemeService extends AbstractService {
12
+
13
+ constructor(config) {
14
+ super(config)
15
+ this.baseUrl = config.baseUrl
16
+ this.model = Scheme
17
+ }
18
+
19
+ /**
20
+ * Return a Promise with an array of vocabularies.
21
+ */
22
+ async queryItems(query) {
23
+ let mongoQuery = {}
24
+ if (query.uri) {
25
+ mongoQuery = {
26
+ $or: query.uri.split("|").map(uri => ({ uri })).concat(query.uri.split("|").map(uri => ({ identifier: uri }))),
27
+ }
28
+ }
29
+ if (query.type) {
30
+ mongoQuery.type = query.type
31
+ }
32
+ if (query.languages) {
33
+ mongoQuery.languages = {
34
+ $in: query.languages.split(","),
35
+ }
36
+ }
37
+ if (query.subject) {
38
+ mongoQuery["subject.uri"] = {
39
+ $in: query.subject.split("|"),
40
+ }
41
+ }
42
+ if (query.license) {
43
+ mongoQuery["license.uri"] = {
44
+ $in: query.license.split("|"),
45
+ }
46
+ }
47
+ if (query.partOf) {
48
+ mongoQuery["partOf.uri"] = {
49
+ $in: query.partOf.split("|"),
50
+ }
51
+ }
52
+ if (query.publisher) {
53
+ mongoQuery._keywordsPublisher = query.publisher
54
+ }
55
+ if (query.notation) {
56
+ const notations = query.notation.split("|")
57
+ mongoQuery.notation = { $in: notations }
58
+ }
59
+
60
+ // Sort order (default: asc = 1)
61
+ const order = query.order === "desc" ? -1 : 1
62
+ const sort = {}
63
+ switch (query.sort) {
64
+ case "label":
65
+ sort["_keywordsLabels.0"] = order
66
+ break
67
+ case "notation":
68
+ sort["_keywordsNotation.0"] = order
69
+ break
70
+ case "created":
71
+ sort["created"] = order
72
+ break
73
+ case "modified":
74
+ sort["modified"] = order
75
+ break
76
+ case "counter":
77
+ sort["_uriSuffixNumber"] = order
78
+ break
79
+ }
80
+
81
+ // Build aggregation pipeline
82
+ const pipeline = [
83
+ { $match: mongoQuery },
84
+ ]
85
+ if (query.sort === "counter") {
86
+ // Add an additional aggregation step to add _uriSuffixNumber property
87
+ pipeline.push({
88
+ $set: {
89
+ _uriSuffixNumber: {
90
+ $function: {
91
+ body: function (uri) {
92
+ return parseInt(uri.substring(uri.lastIndexOf("/") + 1))
93
+ },
94
+ args: ["$uri"],
95
+ lang: "js",
96
+ },
97
+ },
98
+ },
99
+ })
100
+ }
101
+ if (Object.keys(sort).length > 0) {
102
+ pipeline.push({ $sort: sort })
103
+ }
104
+ const { limit, offset } = this._getLimitAndOffset(query)
105
+ pipeline.push({ $skip: offset })
106
+ pipeline.push({ $limit: limit })
107
+
108
+ const schemes = await this.model.aggregate(pipeline)
109
+ schemes.totalCount = await this._count(Scheme, [{ $match: mongoQuery }])
110
+
111
+ return schemes
112
+ }
113
+
114
+ async get(uri) {
115
+ return this.getScheme(uri)
116
+ }
117
+
118
+ async getScheme(identifierOrNotation) {
119
+ // TODO: Should we just throw an error 404 here?
120
+ if (!identifierOrNotation) {
121
+ return null
122
+ }
123
+ return await this.model.findOne({ $or: [{ uri: identifierOrNotation }, { identifier: identifierOrNotation }, { notation: new RegExp(`^${_.escapeRegExp(identifierOrNotation)}$`, "i") }] }).lean().exec()
124
+ }
125
+
126
+ async replaceSchemeProperties(entity, propertyPaths, ignoreError = true) {
127
+ await Promise.all(propertyPaths.map(async path => {
128
+ const uri = _.get(entity, `${path}.uri`)
129
+ let scheme
130
+ try {
131
+ scheme = await this.getScheme(uri)
132
+ } catch (error) {
133
+ if (!ignoreError) {
134
+ throw new InvalidBodyError(`Scheme with URI ${uri} not found. Only known schemes can be used.`)
135
+ }
136
+ }
137
+ if (scheme) {
138
+ _.set(entity, path, _.pick(scheme, ["uri", "notation"]))
139
+ } else if (!ignoreError) {
140
+ throw new InvalidBodyError(`Scheme with URI ${uri} not found. Only known schemes can be used.`)
141
+ }
142
+ }))
143
+ }
144
+
145
+ /**
146
+ * Return a Promise with an array of suggestions in JSKOS format.
147
+ */
148
+ async search(query) {
149
+ let search = query.query || query.search || ""
150
+ let results = await this._searchItems({ search })
151
+ const searchResults = results.slice(query.offset, query.offset + query.limit)
152
+ searchResults.totalCount = results.length
153
+ return searchResults
154
+ }
155
+
156
+ // Write endpoints start here
157
+
158
+ async updateItem({ body, existing, ...args }) {
159
+ let item = body
160
+
161
+ // Prepare
162
+ item = await this.prepareAndCheckItemForAction(item, "update")
163
+
164
+ // Override _id, uri, and created properties
165
+ item._id = existing._id
166
+ item.uri = existing.uri
167
+ item.created = existing.created
168
+
169
+ // Write item to database
170
+ const result = await this.model.replaceOne({ _id: item.uri }, item)
171
+ if (!result.acknowledged) {
172
+ throw new DatabaseAccessError()
173
+ }
174
+ if (!result.matchedCount) {
175
+ throw new EntityNotFoundError()
176
+ }
177
+
178
+ return (await this.postAdjustmentsForItems([item], args))[0]
179
+ }
180
+
181
+ async deleteItem({ uri, existing }) {
182
+ if (!uri) {
183
+ throw new MalformedRequestError()
184
+ }
185
+ if (existing.concepts.length) {
186
+ // Disallow deletion
187
+ // ? Which error type?
188
+ throw new MalformedRequestError(`Concept scheme ${uri} still has concepts in the database and therefore can't be deleted.`)
189
+ }
190
+ super.deleteItem({ existing })
191
+ }
192
+
193
+ /**
194
+ * Prepares and checks a concept scheme before inserting/updating:
195
+ * - validates object, throws error if it doesn't (create/update)
196
+ * - add `_id` property (create/update)
197
+ * - check if it exists, throws error if it doesn't (delete)
198
+ * - check if it has existing concepts in database, throws error if it has (delete)
199
+ *
200
+ * @param {Object} scheme concept scheme object
201
+ * @param {string} action one of "create", "update", and "delete"
202
+ * @returns {Object} prepared concept scheme
203
+ */
204
+ async prepareAndCheckItemForAction(scheme, action) {
205
+ if (!_.isObject(scheme)) {
206
+ throw new MalformedBodyError()
207
+ }
208
+ if (["create", "update"].includes(action)) {
209
+ if (!validate.scheme(scheme) || !scheme.uri) {
210
+ throw new InvalidBodyError()
211
+ }
212
+ scheme._id = scheme.uri
213
+ addKeywords(scheme)
214
+ if (action === "update") {
215
+ delete scheme.created
216
+ }
217
+ }
218
+ return scheme
219
+ }
220
+
221
+ /**
222
+ * Post-adjustments for concept schemes:
223
+ * - update `concepts` property
224
+ * - update `topConcepts` property
225
+ * - get updated concept scheme from database
226
+ *
227
+ * @param {[Object]} schemes array of concept schemes to be adjusted
228
+ * @param {Boolean} options.bulk indicates whether the adjustments are performaned as part of a bulk operation
229
+ * @returns {[Object]} array of adjusted concept schemes
230
+ */
231
+ async postAdjustmentsForItems(schemes, { bulk = false, setApi = false } = {}) {
232
+ // Get schemes from database instead
233
+ schemes = await this.model.find({ _id: { $in: schemes.map(s => s.uri) } }).lean().exec()
234
+ // First, set created field if necessary
235
+ await this.model.updateMany(
236
+ {
237
+ _id: { $in: schemes.map(s => s.uri) },
238
+ $or: [
239
+ { created: { $eq: null } }, { created: { $exists: false } },
240
+ ],
241
+ },
242
+ {
243
+ $set: {
244
+ created: (new Date()).toISOString(),
245
+ },
246
+ },
247
+ )
248
+ const result = []
249
+ for (let scheme of schemes) {
250
+ const hasTopConcepts = !!(await Concept.findOne({ $or: [scheme.uri].concat(scheme.identifier || []).map(uri => ({ "topConceptOf.uri": uri })) }))
251
+ const hasConcepts = hasTopConcepts || !!(await Concept.findOne({ $or: [scheme.uri].concat(scheme.identifier || []).map(uri => ({ "inScheme.uri": uri })) }))
252
+ const update = {
253
+ $set: {
254
+ concepts: hasConcepts ? [null] : [],
255
+ topConcepts: hasTopConcepts ? [null] : [],
256
+ modified: (new Date()).toISOString(),
257
+ },
258
+ }
259
+ if (setApi) {
260
+ let API = scheme.API || []
261
+ API = API.filter(entry => entry.url !== this.baseUrl)
262
+ if (hasConcepts) {
263
+ API = [
264
+ {
265
+ type: "http://bartoc.org/api-type/jskos",
266
+ url: this.baseUrl,
267
+ },
268
+ ].concat(API)
269
+ }
270
+ if (API.length) {
271
+ _.set(update, "$set.API", API)
272
+ } else {
273
+ _.set(update, "$unset.API", "")
274
+ }
275
+ }
276
+ if (bulk) {
277
+ delete update.$set.modified
278
+ }
279
+ await this.model.updateOne({ _id: scheme.uri }, update)
280
+ result.push(await this.model.findById(scheme.uri))
281
+ }
282
+ return result
283
+ }
284
+
285
+ async createIndexes() {
286
+ return this._createIndexes([
287
+ [{ uri: 1 }, {}],
288
+ [{ identifier: 1 }, {}],
289
+ [{ notation: 1 }, {}],
290
+ [{ created: 1 }, {}],
291
+ [{ modified: 1 }, {}],
292
+ [{ "subject.uri": 1 }, {}],
293
+ [{ "license.uri": 1 }, {}],
294
+ [{ "partOf.uri": 1 }, {}],
295
+ [{ _keywordsLabels: 1 }, {}],
296
+ [{ _keywordsPublisher: 1 }, {}],
297
+ // Add additional index for first entry which is used for sorting
298
+ [{ "_keywordsLabels.0": 1 }, {}],
299
+ [
300
+ {
301
+ _keywordsNotation: "text",
302
+ _keywordsLabels: "text",
303
+ _keywordsOther: "text",
304
+ _keywordsPublisher: "text",
305
+ },
306
+ {
307
+ name: "text",
308
+ default_language: "german",
309
+ weights: {
310
+ _keywordsNotation: 10,
311
+ _keywordsLabels: 6,
312
+ _keywordsOther: 3,
313
+ _keywordsPublisher: 3,
314
+ },
315
+ },
316
+ ]])
317
+ }
318
+ }
@@ -0,0 +1,39 @@
1
+ import { validate } from "jskos-validate"
2
+ import jskos from "jskos-tools"
3
+ import { SchemeService } from "./schemes.js"
4
+ const guessObjectType = jskos.guessObjectType
5
+
6
+ import { AbstractService } from "./abstract.js"
7
+
8
+ export class ValidateService extends AbstractService {
9
+
10
+ constructor(config) {
11
+ super(config)
12
+ this.schemeService = new SchemeService(config)
13
+ }
14
+
15
+ async validate(data, { unknownFields, type, knownSchemes = false } = {}) {
16
+ if (!Array.isArray(data)) {
17
+ data = [data]
18
+ }
19
+
20
+ // additional parameters (optional)
21
+ type = (guessObjectType(type, true) || "").toLowerCase()
22
+
23
+ const rememberSchemes = type ? null : []
24
+ if (knownSchemes) {
25
+ // Get schemes from schemeService
26
+ knownSchemes = await this.schemeService.queryItems({})
27
+ type = "concept"
28
+ }
29
+ const validator = type ? validate[type] : validate
30
+
31
+ const result = data.map(item => {
32
+ const result = validator(item, { unknownFields, knownSchemes, rememberSchemes })
33
+ return result ? true : validator.errors
34
+ })
35
+
36
+ return result
37
+ }
38
+
39
+ }