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,328 @@
1
+ import _ from "lodash"
2
+ import jskos from "jskos-tools"
3
+
4
+ import { bulkOperationForEntities } from "../utils/utils.js"
5
+ import { MalformedRequestError, MalformedBodyError, EntityNotFoundError, DatabaseAccessError } from "../errors/index.js"
6
+ import { toOpenSearchSuggestFormat } from "../utils/searchHelper.js"
7
+
8
+ export class AbstractService {
9
+ constructor(config) {
10
+ // logging methods
11
+ this.log = config.log
12
+ this.warn = config.warn
13
+ this.error = config.error
14
+ }
15
+
16
+ // Low-level database lookup an item by its id
17
+ async retrieveItem(id) {
18
+ return this.model.findById(id).lean()
19
+ }
20
+
21
+ // Low-level database query for items
22
+ async retrieveItems(query) {
23
+ return this.model.find(query).lean()
24
+ }
25
+
26
+ // High-level lookup an item. Throws an error on failure
27
+ async getItem(id) {
28
+ if (!id) {
29
+ throw new MalformedRequestError()
30
+ }
31
+ const item = await this.retrieveItem(id)
32
+ // TODO: find via identifier?
33
+ if (!item) {
34
+ throw new EntityNotFoundError(null, id)
35
+ }
36
+ return item
37
+ }
38
+
39
+ // Low-level database delete an item by its id
40
+ async deleteItem({ existing }) {
41
+ const result = await this.model.deleteOne({ _id: existing._id })
42
+ if (!result.deletedCount) {
43
+ throw new DatabaseAccessError()
44
+ }
45
+ }
46
+
47
+ // high level access
48
+ async searchItems({ search, voc }) {
49
+ // Don't try to search for an empty query
50
+ if (!search) {
51
+ return []
52
+ }
53
+ // Prepare search query for use in regex
54
+ const searchRegExp = new RegExp(`^${_.escapeRegExp(search).toUpperCase()}`)
55
+ let query, queryOr = [{ _id: search }]
56
+ // let projectAndSort = {}
57
+ if (search.length > 2) {
58
+ // Use text search for queries longer than two characters
59
+ queryOr.push({
60
+ $text: {
61
+ $search: "\"" + search + "\"",
62
+ },
63
+ })
64
+ // Projekt and sort on text score
65
+ // projectAndSort = { score: { $meta: "textScore" } }
66
+ }
67
+ if (search.length <= 2) {
68
+ // Search for notations specifically for one or two characters
69
+ queryOr.push({
70
+ _keywordsNotation: {
71
+ $regex: searchRegExp,
72
+ },
73
+ })
74
+ }
75
+ if (search.length > 1) {
76
+ // Search _keywordsLabels
77
+ // TODO: Rethink this approach.
78
+ queryOr.push({ _keywordsLabels: { $regex: searchRegExp } })
79
+ }
80
+ // Also search for exact matches with the URI (in field _id)
81
+ query = { $or: queryOr }
82
+ // Filter by scheme uri
83
+ if (voc && this.schemeService) {
84
+ let uris
85
+ // Get scheme from database
86
+ let scheme = await this.schemeService.getScheme(voc)
87
+ if (scheme) {
88
+ uris = [scheme.uri].concat(scheme.identifier || [])
89
+ } else {
90
+ uris = [query.uri]
91
+ }
92
+ query = { $and: [query, { $or: uris.map(uri => ({ "inScheme.uri": uri })) }] }
93
+ }
94
+ let results = await this.retrieveItems(query)
95
+ let _search = search.toUpperCase()
96
+ // Prioritize results
97
+ for (let result of results) {
98
+ let priority = 100
99
+ if (result.notation && result.notation.length > 0) {
100
+ let _notation = jskos.notation(result).toUpperCase()
101
+ // Shorter notation equals higher priority
102
+ priority -= _notation.length
103
+ // Notation equals search means highest priority
104
+ if (_search == _notation) {
105
+ priority += 1000
106
+ }
107
+ // Notation starts with serach means higher priority
108
+ if (_notation.startsWith(_search)) {
109
+ priority += 150
110
+ }
111
+ }
112
+ // prefLabel/altLabel equals search means very higher priority
113
+ for (let [labelType, factor] of [["prefLabel", 2.0], ["altLabel", 1.0], ["creator.prefLabel", 0.8], ["definition", 0.7]]) {
114
+ let labels = []
115
+ // Collect all labels
116
+ for (let label of Object.values(_.get(result, labelType, {}))) {
117
+ if (Array.isArray(label)) {
118
+ labels = labels.concat(label)
119
+ } else {
120
+ labels.push(label)
121
+ }
122
+ }
123
+ let matchCount = 0
124
+ let priorityDiff = 0
125
+ for (let label of labels) {
126
+ let _label
127
+ try {
128
+ _label = label.toUpperCase()
129
+ } catch (error) {
130
+ this.error(label, error)
131
+ continue
132
+ }
133
+ if (_search == _label) {
134
+ priorityDiff += 100
135
+ matchCount += 1
136
+ } else if (_label.startsWith(_search)) {
137
+ priorityDiff += 50
138
+ matchCount += 1
139
+ } else if (_label.indexOf(_search) > 0) {
140
+ priorityDiff += 15
141
+ matchCount += 1
142
+ }
143
+ }
144
+ matchCount = Math.pow(matchCount, 2) || 1
145
+ priority += priorityDiff * (factor / matchCount)
146
+ }
147
+ result.priority = priority
148
+ }
149
+ // Sort results first by priority, then by notation
150
+ results = results.sort((a, b) => {
151
+ if (a.priority != b.priority) {
152
+ return b.priority - a.priority
153
+ }
154
+ if (a.notation && a.notation.length && b.notation && b.notation.length) {
155
+ if (jskos.notation(b) > jskos.notation(a)) {
156
+ return -1
157
+ } else {
158
+ return 1
159
+ }
160
+ } else {
161
+ return 0
162
+ }
163
+ })
164
+ return results
165
+ }
166
+
167
+ /**
168
+ * Returns normalized limit and offset from query object for pagination.
169
+ */
170
+ _getLimitAndOffset(query) {
171
+ return {
172
+ limit: Number.isFinite(+query.limit) ? Math.max(0, +query.limit) : 100,
173
+ offset: Number.isFinite(+query.offset) ? Math.max(0, +query.offset) : 0,
174
+ }
175
+ }
176
+
177
+ /**
178
+ * Returns a Promise with suggestions, either in OpenSearch Suggest Format or JSKOS (?format=jskos).
179
+ */
180
+ async getSuggestions(query) {
181
+ const format = query.format || ""
182
+ const results = await this.searchItems(query)
183
+ if (format.toLowerCase() == "jskos") {
184
+ // Return in JSKOS format with pagination
185
+ const { limit, offset } = this._getLimitAndOffset(query)
186
+ return results.slice(offset, offset + limit)
187
+ }
188
+ return toOpenSearchSuggestFormat({ query, results })
189
+ }
190
+
191
+ // to be implemented by subclasses
192
+ async prepareAndCheckItemForAction(item, _action) {
193
+ return item
194
+ }
195
+
196
+ // to be implemented by subclasses
197
+ async postAdjustmentsForItems(items) {
198
+ return items
199
+ }
200
+
201
+ async createItem({ bodyStream, bulk = false, bulkReplace = true, user, admin = false }) {
202
+ let { items, isMultiple } = await this._readBodyStream(bodyStream)
203
+
204
+ items = await Promise.all(items.map(item => {
205
+ return this.prepareAndCheckItemForAction(item, "create", { admin, user, bulk })
206
+ .catch(error => {
207
+ if (bulk) {
208
+ return null
209
+ }
210
+ throw error
211
+ })
212
+ }))
213
+ items = items.filter(Boolean)
214
+
215
+ let response
216
+ if (bulk) {
217
+ // Use bulkWrite for most efficiency
218
+ items.length && await this.model.bulkWrite(bulkOperationForEntities({ entities: items, replace: bulkReplace }))
219
+ items = await this.postAdjustmentsForItems(items, { bulk })
220
+ response = items.map(s => ({ uri: s.uri }))
221
+ } else {
222
+ items = await this.model.insertMany(items, { lean: true })
223
+ response = await this.postAdjustmentsForItems(items, { bulk })
224
+ }
225
+
226
+ return isMultiple ? response : response[0]
227
+ }
228
+
229
+ /**
230
+ * Returns the document count for a certain aggregation pipeline.
231
+ * Uses estimatedDocumentCount() if possible (i.e. if the query is empty).
232
+ *
233
+ * @param {*} model a mongoose model
234
+ * @param {*} pipeline an aggregation pipeline
235
+ */
236
+ async _count(model, pipeline) {
237
+ if (pipeline.length === 1 && pipeline[0].$match && isQueryEmpty(pipeline[0].$match)) {
238
+ // It's an empty query, i.e. we can use estimatedDocumentCount()
239
+ return await model.estimatedDocumentCount()
240
+ } else {
241
+ // Use aggregation instead
242
+ return (await model.aggregate(pipeline).count("count").exec())?.[0]?.count || 0
243
+ }
244
+ }
245
+
246
+ /**
247
+ * Converts a body stream into an array of items
248
+ *
249
+ * @param {NodeJS.ReadableStream} bodyStream - The body stream to convert.
250
+ * @returns {Promise<{items: Array, isMultiple: boolean}>}
251
+ */
252
+ async _readBodyStream(bodyStream) {
253
+ if (!bodyStream) {
254
+ throw new MalformedBodyError("Failed to parse request")
255
+ }
256
+
257
+ let isMultiple = true
258
+ bodyStream.on("isSingleObject", () => {
259
+ isMultiple = false
260
+ })
261
+
262
+ const items = await new Promise((resolve) => {
263
+ const body = []
264
+ bodyStream.on("data", item => {
265
+ body.push(item)
266
+ })
267
+ bodyStream.on("end", () => {
268
+ resolve(body)
269
+ })
270
+ })
271
+
272
+ return { items, isMultiple }
273
+ }
274
+
275
+ /**
276
+ * Initializes a collection by creating it if absent, removing any existing indexes,
277
+ * and establishing the full set of required indexes.
278
+ *
279
+ * @param {Array} indexes - An array of [index, options] pairs.
280
+ */
281
+ async _createIndexes(indexes) {
282
+ const model = this.model
283
+
284
+ // Create collection if necessary
285
+ try {
286
+ await model.createCollection()
287
+ } catch (error) {
288
+ this.error(`Error creating collection for ${model.modelName}:`, error)
289
+ // Ignore error
290
+ }
291
+
292
+ // Drop existing indexes
293
+ try {
294
+ await model.collection.dropIndexes()
295
+ } catch (error) {
296
+ this.error(`Error dropping indexes for ${model.modelName}:`, error)
297
+ // Ignore error
298
+ }
299
+
300
+ for (const [index, options] of indexes) {
301
+ try {
302
+ await model.collection.createIndex(index, options)
303
+ } catch (error) {
304
+ this.error(`Error creating index for ${model.modelName}:`, error, index, options)
305
+ }
306
+ }
307
+ }
308
+
309
+ }
310
+
311
+ // Determines whether a query is actually empty (i.e. returns all documents).
312
+ export function isQueryEmpty(query) {
313
+ const allowedProps = ["$and", "$or"]
314
+ let result = true
315
+ _.forOwn(query, (value, key) => {
316
+ if (!allowedProps.includes(key)) {
317
+ result = false
318
+ } else {
319
+ // for $and and $or, value is an array
320
+ _.forEach(value, (element) => {
321
+ result = result && isQueryEmpty(element)
322
+ })
323
+ }
324
+ })
325
+ return result
326
+ }
327
+
328
+
@@ -0,0 +1,237 @@
1
+ import { uuid, isValidUuid } from "../utils/uuid.js"
2
+ import { removeNullProperties } from "../utils/utils.js"
3
+ import jskos from "jskos-tools"
4
+ import { validate } from "jskos-validate"
5
+ import _ from "lodash"
6
+ import { Annotation, Mapping, Concept } from "../models/index.js"
7
+ import { DatabaseAccessError, InvalidBodyError, ForbiddenAccessError } from "../errors/index.js"
8
+
9
+ import { AbstractService } from "./abstract.js"
10
+
11
+ export class AnnotationService extends AbstractService {
12
+
13
+ constructor(config) {
14
+ super(config)
15
+ this.baseUri = config.baseUrl + "annotations/"
16
+ this.config = config.annotations || {}
17
+ this.model = Annotation
18
+ }
19
+
20
+ // Wrapper around validate.annotation that also checks the `body` field and throws errors if necessary.
21
+ async validateAnnotation(data, options) {
22
+ // TODO: Due to an issue with lax schemas in jskos-validate (see https://github.com/gbv/jskos-validate/issues/17), we need a workaround here.
23
+ const result = validate.annotation(_.omit(data, "body"), options)
24
+ if (!result || (data.body && !Array.isArray(data.body))) {
25
+ throw new InvalidBodyError()
26
+ }
27
+ // Check `body` property
28
+ if (data.body?.length) {
29
+ const mismatchTagConcepts = await Concept.find({ "inScheme.uri": this.config.mismatchTagVocabulary?.uri })
30
+ if (data.bodyValue !== "-1") {
31
+ throw new InvalidBodyError("Property `body` is currently only allowed with when `bodyValue` is set to \"-1\".")
32
+ }
33
+ for (const tag of data.body) {
34
+ if (tag.type !== "SpecificResource") {
35
+ throw new InvalidBodyError("Currently, the only allowed `type` of body values in annotations is \"SpecificResource\".")
36
+ }
37
+ if (tag.purpose !== "tagging") {
38
+ throw new InvalidBodyError("Currently, the only allowed `purpose` of body values in annotations is \"tagging\".")
39
+ }
40
+ if (!mismatchTagConcepts.find(concept => jskos.compare(concept, { uri: tag.value }))) {
41
+ throw new InvalidBodyError(`Either \`annotations.mismatchTagVocabulary\` is not configured or tag mismatch URI "${tag.value}" is not a valid tag.`)
42
+ }
43
+ }
44
+ }
45
+ return true
46
+ }
47
+
48
+ /**
49
+ * Returns a Promise with an array of annotations.
50
+ *
51
+ * Can filter by:
52
+ *
53
+ * id, creator, target, bodyValue, motivation.
54
+ *
55
+ * TODO: Add sorting.
56
+ */
57
+ async queryItems(query) {
58
+ let criteria = []
59
+ if (query.id) {
60
+ criteria.push({
61
+ $or: [
62
+ {
63
+ _id: query.id,
64
+ },
65
+ {
66
+ id: query.id,
67
+ },
68
+ ],
69
+ })
70
+ }
71
+ if (query.creator) {
72
+ const creators = query.creator.split("|")
73
+ criteria.push({
74
+ $or: _.flatten(creators.map(creator => [
75
+ jskos.isValidUri(creator) ? null : { "creator.name": new RegExp(_.escapeRegExp(creator), "i") },
76
+ jskos.isValidUri(creator) ? { "creator.id": creator } : null,
77
+ { creator },
78
+ ].filter(Boolean))),
79
+ })
80
+ }
81
+ if (query.target) {
82
+ criteria.push({
83
+ $or: [
84
+ { target: query.target },
85
+ { "target.id": query.target },
86
+ ],
87
+ })
88
+ }
89
+ if (query.bodyValue) {
90
+ criteria.push({
91
+ bodyValue: query.bodyValue,
92
+ })
93
+ }
94
+ if (query.motivation) {
95
+ criteria.push({
96
+ motivation: query.motivation,
97
+ })
98
+ }
99
+
100
+ const mongoQuery = criteria.length ? { $and: criteria } : {}
101
+ const { limit, offset } = this._getLimitAndOffset(query)
102
+ const annotations = await Annotation.find(mongoQuery).lean().skip(offset).limit(limit).exec()
103
+ annotations.totalCount = await this._count(Annotation, [{ $match: mongoQuery }])
104
+
105
+ return annotations
106
+ }
107
+
108
+ async prepareAndCheckItemForAction(item, action, { admin, user, bulk }) {
109
+ if (action !== "create") {
110
+ return item
111
+ }
112
+ // For type moderating, check if user is on the whitelist (except for admin=true).
113
+ if (!admin && item.motivation == "moderating") {
114
+ let uris = [user.uri].concat(Object.values(user.identities || {}).map(id => id.uri)).filter(uri => uri != null)
115
+ let whitelist = this.config.moderatingIdentities
116
+ if (whitelist && _.intersection(whitelist, uris).length == 0) {
117
+ // Disallow
118
+ throw new ForbiddenAccessError("Access forbidden, user is not allowed to create items of type \"moderating\".")
119
+ }
120
+ }
121
+ // Add created and modified dates.
122
+ let date = (new Date()).toISOString()
123
+ if (!bulk || !item.created) {
124
+ item.created = date
125
+ }
126
+ // Remove type property
127
+ delete item.type
128
+ // Validate item
129
+ await this.validateAnnotation(item)
130
+ // Add _id and URI
131
+ delete item._id
132
+ if (item.id) {
133
+ let id = item.id
134
+ // ID already exists, use if it's valid, otherwise remove
135
+ if (id.startsWith(this.baseUri) && isValidUuid(id.slice(this.baseUri.length, id.length))) {
136
+ item._id = id.slice(this.baseUri.length, id.length)
137
+ }
138
+ }
139
+ if (!item._id) {
140
+ item._id = uuid()
141
+ item.id = this.baseUri + item._id
142
+ }
143
+ // Change target to object and add mapping content identifier if possible
144
+ const target = _.get(item, "target.id", item.target)
145
+ if (!item.target?.state?.id) {
146
+ const mapping = await Mapping.findOne({ uri: target })
147
+ const contentId = mapping && (mapping.identifier || []).find(id => id.startsWith("urn:jskos:mapping:content:"))
148
+ item.target = contentId ? {
149
+ id: target,
150
+ state: {
151
+ id: contentId,
152
+ },
153
+ } : { id: target }
154
+ }
155
+
156
+ return item
157
+ }
158
+
159
+ async updateItem({ body, existing }) {
160
+ let annotation = body
161
+ if (!annotation) {
162
+ throw new InvalidBodyError()
163
+ }
164
+ // Add modified date.
165
+ annotation.modified = (new Date()).toISOString()
166
+ // Remove type property
167
+ _.unset(annotation, "type")
168
+ // Validate annotation
169
+ await this.validateAnnotation(annotation)
170
+
171
+ // Always preserve certain existing properties
172
+ annotation.created = existing.created
173
+
174
+ // Override _id and id properties
175
+ annotation.id = existing.id
176
+ annotation._id = existing._id
177
+
178
+ // Change target property to object if necessary
179
+ if (_.isString(annotation.target)) {
180
+ annotation.target = { id: annotation.target }
181
+ }
182
+
183
+ const result = await Annotation.replaceOne({ _id: existing._id }, annotation)
184
+ if (result.acknowledged && result.matchedCount) {
185
+ return annotation
186
+ } else {
187
+ throw new DatabaseAccessError()
188
+ }
189
+ }
190
+
191
+ async patchAnnotation({ body, existing }) {
192
+ let annotation = body
193
+ if (!annotation) {
194
+ throw new InvalidBodyError()
195
+ }
196
+
197
+ annotation.modified = (new Date()).toISOString()
198
+
199
+ for (let key of ["_id", "id", "type", "created"]) {
200
+ delete annotation[key]
201
+ }
202
+
203
+ _.assign(existing, annotation)
204
+
205
+ // Change target property to object if necessary
206
+ if (_.isString(annotation.target)) {
207
+ annotation.target = { id: annotation.target }
208
+ }
209
+
210
+ removeNullProperties(existing)
211
+
212
+ // Validate annotation
213
+ await this.validateAnnotation(existing)
214
+
215
+ const result = await Annotation.replaceOne({ _id: existing._id }, existing)
216
+ if (result.acknowledged) {
217
+ return existing
218
+ } else {
219
+ throw new DatabaseAccessError()
220
+ }
221
+ }
222
+
223
+ async createIndexes() {
224
+ const indexes = [
225
+ [{ id: 1 }, {}],
226
+ [{ identifier: 1 }, {}],
227
+ [{ target: 1 }, {}],
228
+ [{ "target.id": 1 }, {}],
229
+ [{ creator: 1 }, {}],
230
+ [{ "creator.id": 1 }, {}],
231
+ [{ "creator.name": 1 }, {}],
232
+ [{ motivation: 1 }, {}],
233
+ [{ bodyValue: 1 }, {}],
234
+ ]
235
+ await this._createIndexes(indexes)
236
+ }
237
+ }