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.
- package/.dockerignore +20 -0
- package/.editorconfig +9 -0
- package/.github/workflows/docker.yml +59 -0
- package/.github/workflows/gh-pages.yml +23 -0
- package/.github/workflows/gh-release.yml +19 -0
- package/.github/workflows/test.yml +39 -0
- package/.husky/pre-commit +1 -0
- package/CHANGELOG.md +18 -0
- package/LICENSE +21 -0
- package/README.md +2710 -0
- package/bin/extra.js +81 -0
- package/bin/import.js +438 -0
- package/bin/mongodb.js +21 -0
- package/bin/reset.js +257 -0
- package/bin/upgrade.js +34 -0
- package/config/config.default.json +88 -0
- package/config/config.schema.json +877 -0
- package/config/config.test.json +107 -0
- package/config/index.js +77 -0
- package/config/setup.js +212 -0
- package/depdendencies.png +0 -0
- package/docker/.env +1 -0
- package/docker/Dockerfile +20 -0
- package/docker/README.md +175 -0
- package/docker/docker-compose.yml +29 -0
- package/docker/docker-entrypoint.sh +8 -0
- package/docker/mongo-initdb.d/mongo_setup.sh +22 -0
- package/ecosystem.example.json +7 -0
- package/errors/index.js +94 -0
- package/eslint.config.js +17 -0
- package/index.js +10 -0
- package/models/annotations.js +13 -0
- package/models/concepts.js +12 -0
- package/models/concordances.js +12 -0
- package/models/index.js +33 -0
- package/models/mappings.js +20 -0
- package/models/meta.js +21 -0
- package/models/registries.js +12 -0
- package/models/schemes.js +12 -0
- package/package.json +91 -0
- package/routes/annotations.js +83 -0
- package/routes/common.js +86 -0
- package/routes/concepts.js +64 -0
- package/routes/concordances.js +86 -0
- package/routes/data.js +19 -0
- package/routes/mappings.js +108 -0
- package/routes/registries.js +24 -0
- package/routes/schemes.js +72 -0
- package/routes/validate.js +37 -0
- package/server.js +190 -0
- package/services/abstract.js +328 -0
- package/services/annotations.js +237 -0
- package/services/concepts.js +459 -0
- package/services/concordances.js +264 -0
- package/services/data.js +30 -0
- package/services/index.js +34 -0
- package/services/mappings.js +978 -0
- package/services/registries.js +319 -0
- package/services/schemes.js +318 -0
- package/services/validate.js +39 -0
- package/status.schema.json +145 -0
- package/test/abstract-service.js +36 -0
- package/test/annotations/annotation.json +13 -0
- package/test/api.js +2481 -0
- package/test/chai.js +14 -0
- package/test/changes.js +179 -0
- package/test/concepts/conceptNoFileEnding +4 -0
- package/test/concepts/concepts-ddc-6-60-61-62.json +123 -0
- package/test/concordances/concordances.ndjson +2 -0
- package/test/config.js +26 -0
- package/test/configs/complex-config.json +90 -0
- package/test/configs/empty-object.json +1 -0
- package/test/configs/fail-array.json +1 -0
- package/test/configs/fail-empty.json +0 -0
- package/test/configs/fail-mapping-only-props1.json +5 -0
- package/test/configs/fail-mapping-only-props2.json +5 -0
- package/test/configs/fail-mapping-only-props3.json +5 -0
- package/test/configs/fail-mapping-only-props4.json +5 -0
- package/test/configs/fail-nonexisting-prop.json +3 -0
- package/test/configs/fail-port-string.json +3 -0
- package/test/configs/fail-registry-types.json +16 -0
- package/test/configs/registry-types.json +16 -0
- package/test/data-write.js +784 -0
- package/test/eslint.js +22 -0
- package/test/import-reset.js +287 -0
- package/test/infer-mappings.js +340 -0
- package/test/ipcheck.js +287 -0
- package/test/mappings/README.md +1 -0
- package/test/mappings/ddc-gnd-1.mapping.json +33 -0
- package/test/mappings/ddc-gnd-2.mapping.json +67 -0
- package/test/mappings/mapping-ddc-gnd-noScheme.json +145 -0
- package/test/mappings/mapping-ddc-gnd.json +175 -0
- package/test/mappings/mappings-ddc.json +214 -0
- package/test/registries/registries.ndjson +2 -0
- package/test/services.js +557 -0
- package/test/terminologies/terminologies.json +94 -0
- package/test/test-utils.js +182 -0
- package/test/utils.js +425 -0
- package/test/validate.js +226 -0
- package/utils/adjust.js +206 -0
- package/utils/auth.js +154 -0
- package/utils/changes.js +88 -0
- package/utils/db.js +106 -0
- package/utils/ipcheck.js +76 -0
- package/utils/middleware.js +636 -0
- package/utils/searchHelper.js +153 -0
- package/utils/status.js +77 -0
- package/utils/users.js +7 -0
- package/utils/utils.js +114 -0
- package/utils/uuid.js +6 -0
- package/utils/version.js +324 -0
- 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
|
+
}
|