KafkaTopology.kt 19.4 KB
Newer Older
Jonas Waeber's avatar
Jonas Waeber committed
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/*
 * Media Linker
 * Copyright (C) 2020 Memoriav
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU Affero General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU Affero General Public License for more details.
 *
 * You should have received a copy of the GNU Affero General Public License
 * along with this program.  If not, see <https://www.gnu.org/licenses/>.
 */

package org.memobase

import org.apache.jena.rdf.model.Model
import org.apache.jena.rdf.model.ModelFactory
import org.apache.jena.rdf.model.Resource
24
import org.apache.jena.rdf.model.ResourceFactory
25
import org.apache.jena.riot.RiotException
Jonas Waeber's avatar
Jonas Waeber committed
26
27
import org.apache.kafka.streams.StreamsBuilder
import org.apache.kafka.streams.kstream.Predicate
Sebastian Schüpbach's avatar
Sebastian Schüpbach committed
28
import org.apache.logging.log4j.LogManager
Jonas Waeber's avatar
Jonas Waeber committed
29
import org.memobase.rdf.EBUCORE
30
import org.memobase.rdf.RDF
Jonas Waeber's avatar
Jonas Waeber committed
31
32
33
34
35
import org.memobase.rdf.RICO
import org.memobase.reports.ReportMessages
import org.memobase.reports.ReportStatus
import org.memobase.settings.SettingsLoader
import org.memobase.sftp.SftpClient
36
37
import settings.HeaderExtractionTransformSupplier
import settings.HeaderMetadata
Sebastian Schüpbach's avatar
Sebastian Schüpbach committed
38
39
40
41
import java.io.StringReader
import java.io.StringWriter
import java.net.MalformedURLException
import java.net.URL
Jonas Waeber's avatar
Jonas Waeber committed
42
43

class KafkaTopology(private val settings: SettingsLoader) {
44
    private val appSettings = settings.appSettings
Sebastian Schüpbach's avatar
Sebastian Schüpbach committed
45
    private val log = LogManager.getLogger("KafkaTopology")
46

Jonas Waeber's avatar
Jonas Waeber committed
47
    private val sftpClient = SftpClient(settings.sftpSettings)
48
    private val previewImageHandler = RemoteResourceHandler(sftpClient)
49
    private val sftpBasePath = appSettings.getProperty(Constant.sftpBasePathPropertyName)
50
    private val fileExtensions = appSettings.getProperty(Constant.extensionsPropertyName).split(",")
51
    private val reportingTopic = settings.processReportTopic
Jonas Waeber's avatar
Jonas Waeber committed
52

53
    fun prepare(): StreamsBuilder {
Jonas Waeber's avatar
Jonas Waeber committed
54
55
        val builder = StreamsBuilder()

Jonas Waeber's avatar
Jonas Waeber committed
56
        val stream = builder.stream<String, String>(settings.inputTopic)
Jonas Waeber's avatar
Jonas Waeber committed
57

58
        val model = stream
59
            .transformValues(HeaderExtractionTransformSupplier<String>())
60
            .mapValues { value -> createModel(value) }
61
62
63
64
65
66
67
            .branch(
                Predicate { _, value -> value != null },
                Predicate { _, _ -> true }
            )

        model[1]
            .mapValues { key, _ ->
Sebastian Schüpbach's avatar
Sebastian Schüpbach committed
68
                log.warn("Invalid input data. Check mapper service processing.")
69
70
                Report(
                    key,
Sebastian Schüpbach's avatar
Sebastian Schüpbach committed
71
                    ReportStatus.fatal,
72
                    generalFailureMessage = "Invalid input data. Check mapper service processing."
Sebastian Schüpbach's avatar
Sebastian Schüpbach committed
73
                ).toJson()
74
75
76
            }
            .to(reportingTopic)

77
        val requiredFieldsAvailable = model[0]
78
            .mapValues { value -> extractSubjects(value!!) }
79
80
81
82
83
            .mapValues { key, value ->
                if (getDigitalObjectResource(value.second) == null) {
                    createRecord(
                        value,
                        key,
Sebastian Schüpbach's avatar
Sebastian Schüpbach committed
84
85
                        ReportStatus.warning,
                        generalFailureMessage = "No digital object resource present in model."
86
87
88
89
90
91
92
                    )
                } else {
                    createRecord(value, key, ReportStatus.success)
                }
            }
            .mapValues { key, value ->
                val recordResource = getRecordResource(value.second)
Sebastian Schüpbach's avatar
Sebastian Schüpbach committed
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
                when {
                    recordResource == null -> {
                        updateRecord(
                            value,
                            ReportStatus.fatal,
                            generalMessage = "No record resource present in model."
                        )
                    }
                    getOriginalIdentifier(recordResource) == null -> {
                        updateRecord(
                            value,
                            ReportStatus.fatal,
                            generalMessage = ReportMessages.noOriginalIdentifier(key)
                        )
                    }
                    else -> {
                        value
                    }
111
                }
Matthias's avatar
Matthias committed
112
            }
113
            .branch(
Sebastian Schüpbach's avatar
Sebastian Schüpbach committed
114
115
                Predicate { _, value -> value.third.status == ReportStatus.fatal },
                Predicate { _, value -> value.third.status == ReportStatus.warning },
116
                Predicate { _, _ -> true }
117
            )
Jonas Waeber's avatar
Jonas Waeber committed
118

119
        requiredFieldsAvailable[0]
Sebastian Schüpbach's avatar
Sebastian Schüpbach committed
120
121
122
123
124
125
126
            .mapValues { _, value ->
                log.warn("Record contains faulty data: ${value.third.digitalObjectMessage}. Abort processing of message")
                value.third.toJson()
            }
            .to(reportingTopic)

        requiredFieldsAvailable[1]
Sebastian Schüpbach's avatar
Sebastian Schüpbach committed
127
            .mapValues { _, value ->
128
                log.warn("Record contains faulty data: ${value.third.digitalObjectMessage}")
Sebastian Schüpbach's avatar
Sebastian Schüpbach committed
129
130
131
                value.third.toJson()
            }
            .to(reportingTopic)
132

Sebastian Schüpbach's avatar
Sebastian Schüpbach committed
133
        requiredFieldsAvailable[1]
Sebastian Schüpbach's avatar
Sebastian Schüpbach committed
134
135
136
            .mapValues { value -> serializeModel(value.first.first) }
            .to(settings.outputTopic)

Sebastian Schüpbach's avatar
Sebastian Schüpbach committed
137
        val hasLocatorBranch = requiredFieldsAvailable[2]
138
139
140
141
142
143
144
145
146
147
148
149
            .mapValues { readOnlyKey, value ->
                addThumbnailSftpLocatorToModel(
                    readOnlyKey,
                    value
                )
            }
            .branch(
                Predicate { _, value -> hasDigitalObjectWithoutLocator(value.second) }, // Indicates a local media file
                Predicate { _, _ -> true } // Indicates a remote media file; check for youtube / vimeo thumbnail fetching
            )

        val updateDigitalObjects = hasLocatorBranch[0]
Sebastian Schüpbach's avatar
Sebastian Schüpbach committed
150
151
            .mapValues { readOnlyKey, value ->
                val enrichedModel = addMediaSftpLocatorToModel(readOnlyKey, value)
Sebastian Schüpbach's avatar
Sebastian Schüpbach committed
152
                if (enrichedModel.third.status == ReportStatus.warning) {
153
                    log.warn("A problem enriching the digital object occurred: ${enrichedModel.third.digitalObjectMessage}")
Sebastian Schüpbach's avatar
Sebastian Schüpbach committed
154
155
                }
                enrichedModel
156
            }
Sebastian Schüpbach's avatar
Sebastian Schüpbach committed
157
158
159

        updateDigitalObjects
            .mapValues { value -> serializeModel(value.first.first) }
160
            .to(settings.outputTopic)
Jonas Waeber's avatar
Jonas Waeber committed
161
162

        updateDigitalObjects
163
            .mapValues { value -> value.third.toJson() }
164
            .to(reportingTopic)
Jonas Waeber's avatar
Jonas Waeber committed
165

166
        hasLocatorBranch[1]
167
            .mapValues { value -> fetchThumbnailForYoutubeOrVimeoFile(value) }
Sebastian Schüpbach's avatar
Sebastian Schüpbach committed
168
            .mapValues { value -> serializeModel(value.first.first) }
169
            .to(settings.outputTopic)
Jonas Waeber's avatar
Jonas Waeber committed
170

171
        hasLocatorBranch[1]
172
            .mapValues { _, value -> value.third.toJson() }
173
            .to(reportingTopic)
Jonas Waeber's avatar
Jonas Waeber committed
174

175
        return builder
Jonas Waeber's avatar
Jonas Waeber committed
176
177
    }

178
179
    private fun fetchThumbnailForYoutubeOrVimeoFile(value: Triple<Pair<Model, HeaderMetadata>, List<Resource>, Report>): Triple<Pair<Model, HeaderMetadata>, List<Resource>, Report> {
        if (noThumbnailAttached(value.second)) {
Sebastian Schüpbach's avatar
Sebastian Schüpbach committed
180
181
182
183
            val recordResource = getRecordResource(value.second)
            val digitalObjectResource = getDigitalObjectResource(value.second)
            if (recordResource != null && digitalObjectResource != null) {
                val locator = digitalObjectResource.getProperty(EBUCORE.locator).string
184
                when {
Sebastian Schüpbach's avatar
Sebastian Schüpbach committed
185
186
                    isNoValidUrl(locator) -> {
                        log.warn("No valid locator url found for ${value.third.id}")
Sebastian Schüpbach's avatar
Sebastian Schüpbach committed
187
                        return updateRecord(value, ReportStatus.warning, thumbnailMessage = "no valid locator url")
Sebastian Schüpbach's avatar
Sebastian Schüpbach committed
188
                    }
189
                    RemoteResourceHandler.isVimeoUrl(locator) -> {
Sebastian Schüpbach's avatar
Sebastian Schüpbach committed
190
                        log.info("Trying to download thumbnail file on vimeo for ${value.third.id}")
Sebastian Schüpbach's avatar
Sebastian Schüpbach committed
191
192
193
194
                        val thumbnailHandler = this.previewImageHandler.getFromVimeo(locator)
                        if (thumbnailHandler == null) {
                            log.warn("Download for ${value.third.id} failed!")
                            return updateRecord(
195
                                value,
Sebastian Schüpbach's avatar
Sebastian Schüpbach committed
196
                                ReportStatus.warning,
197
                                thumbnailMessage = "couldn't fetch vimeo thumbnail"
Sebastian Schüpbach's avatar
Sebastian Schüpbach committed
198
                            )
Sebastian Schüpbach's avatar
Sebastian Schüpbach committed
199
200
                        }
                        thumbnailHandler
201
                    }
202
                    RemoteResourceHandler.isYoutubeUrl(locator) -> {
Sebastian Schüpbach's avatar
Sebastian Schüpbach committed
203
                        log.info("Trying to download thumbnail file on youtube for ${value.third.id}")
Sebastian Schüpbach's avatar
Sebastian Schüpbach committed
204
205
206
207
                        val thumbnailHandler = this.previewImageHandler.getFromYoutube(locator)
                        if (thumbnailHandler == null) {
                            log.warn("Download for ${value.third.id} failed!")
                            return updateRecord(
208
                                value,
Sebastian Schüpbach's avatar
Sebastian Schüpbach committed
209
210
                                ReportStatus.warning,
                                thumbnailMessage = "couldn't fetch youtube thumbnail"
Sebastian Schüpbach's avatar
Sebastian Schüpbach committed
211
                            )
Sebastian Schüpbach's avatar
Sebastian Schüpbach committed
212
213
                        }
                        thumbnailHandler
214
215
                    }
                    else -> {
Sebastian Schüpbach's avatar
Sebastian Schüpbach committed
216
                        log.debug("Won't fetch thumbnail file for ${value.third.id} because no youtube/vimeo resource")
217
218
                        return updateRecord(
                            value,
Sebastian Schüpbach's avatar
Sebastian Schüpbach committed
219
                            ReportStatus.success,
220
                            thumbnailMessage = "no additional thumbnails fetched"
Sebastian Schüpbach's avatar
Sebastian Schüpbach committed
221
                        )
222
                    }
Sebastian Schüpbach's avatar
Sebastian Schüpbach committed
223
224
225
226
227
228
229
230
231
                }.let { h ->
                    val enrichedValue = addDimensionsToDigitalObject(value, h.first)
                    val filePath = h.second
                    return if (filePath != null) {
                        addLocalThumbnail(enrichedValue, recordResource, digitalObjectResource, filePath)
                    } else {
                        log.warn("No thumbnail url available for ${value.third.id}")
                        enrichedValue
                    }
232
233
234
                }
            }
        }
Sebastian Schüpbach's avatar
Sebastian Schüpbach committed
235
        return value
236
237
    }

238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
    private fun addLocalThumbnail(
        value: Triple<Pair<Model, HeaderMetadata>, List<Resource>, Report>,
        recordResource: Resource,
        digitalObjectResource: Resource,
        pathToLocalFile: String
    ): Triple<Pair<Model, HeaderMetadata>, List<Resource>, Report> {
        val destPath = "$sftpBasePath/${value.first.second.recordSetId}/${Constant.thumbnailFolderName}/${
            recordResource.uri.split(
                "/"
            ).last()
        }.jpg"
        val pathOnSftpServer = previewImageHandler.moveFileToSFTP(pathToLocalFile, destPath)
        if (pathOnSftpServer != null) {
            log.info("Move downloaded thumbnail file to $destPath for ${value.third.id}")
            createThumbnailResource(
                value.first.first,
                recordResource,
                digitalObjectResource,
                pathOnSftpServer
            )
            return updateRecord(
                value,
                value.third.status,
                thumbnailMessage = "youtube / vimeo thumbnail fetched"
            )
        } else {
            log.warn("Couldn't move downloaded thumbnail file to $destPath for ${value.third.id}")
            return updateRecord(
                value,
Sebastian Schüpbach's avatar
Sebastian Schüpbach committed
267
                ReportStatus.warning,
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
                thumbnailMessage = "upload of youtube / vimeo thumbnail to sFTP server failed"
            )
        }
    }

    private fun addDimensionsToDigitalObject(
        value: Triple<Pair<Model, HeaderMetadata>, List<Resource>, Report>,
        oembedObject: OembedResponse
    ): Triple<Pair<Model, HeaderMetadata>, List<Resource>, Report> {
        val digitalObjectResource = getDigitalObjectResource(value.second)!!
        // TODO
        if (oembedObject.width != null) {
            val width = ResourceFactory.createPlainLiteral(oembedObject.width.toString())
            digitalObjectResource.addLiteral(EBUCORE.width, width)
        }
        if (oembedObject.height != null) {
            val height = ResourceFactory.createPlainLiteral(oembedObject.height.toString())
            digitalObjectResource.addLiteral(EBUCORE.height, height)
        }
        value.first.first.createLiteral(digitalObjectResource.toString(), true)
        return value
    }

291
292
293
294
    private fun noThumbnailAttached(resources: List<Resource>): Boolean {
        return resources.none { it.hasProperty(RICO.type, Constant.thumbnailRicoType) }
    }

295
296
    private fun extractSubjects(input: Pair<Model, HeaderMetadata>): Pair<Pair<Model, HeaderMetadata>, List<Resource>> {
        return Pair(input, input.first.listSubjects().toList())
Jonas Waeber's avatar
Jonas Waeber committed
297
298
    }

Sebastian Schüpbach's avatar
Sebastian Schüpbach committed
299
    private fun hasDigitalObjectWithoutLocator(res: List<Resource>): Boolean {
300
301
302
        return res.any { it.hasProperty(RICO.type, Constant.digitalObject) && !it.hasProperty(EBUCORE.locator) }
    }

Sebastian Schüpbach's avatar
Sebastian Schüpbach committed
303
    private fun getOriginalIdentifier(record: Resource): String? {
304
        return record.listProperties(RICO.identifiedBy).toList().map { statement -> statement.`object`.asResource() }
Sebastian Schüpbach's avatar
Sebastian Schüpbach committed
305
            .firstOrNull { resource ->
306
307
308
309
                resource.hasProperty(RDF.type, RICO.Identifier) && resource.hasProperty(
                    RICO.type,
                    Constant.identifierType
                )
Sebastian Schüpbach's avatar
Sebastian Schüpbach committed
310
            }?.getProperty(RICO.identifier)?.string
Jonas Waeber's avatar
Jonas Waeber committed
311
312
    }

313
    private fun createModel(data: Pair<String, HeaderMetadata>): Pair<Model, HeaderMetadata>? {
Jonas Waeber's avatar
Jonas Waeber committed
314
        val model = ModelFactory.createDefaultModel()
315
316
317
318
319
        try {
            model.read(StringReader(data.first), "", Constant.rdfParserLang)
        } catch (ex: RiotException) {
            return null
        }
320
        return Pair(model, data.second)
Jonas Waeber's avatar
Jonas Waeber committed
321
322
    }

323
324
325
326
327
    private fun createThumbnailResource(
        data: Model,
        record: Resource,
        digitalObject: Resource,
        locator: String
328
329
330
    ): String {
        val uri = "${digitalObject.uri}/derived"
        val thumbnail = data.createResource(uri)
331
332
333
334
335
336
337
338
        val literal = ResourceFactory.createPlainLiteral(locator)
        thumbnail.addProperty(RDF.type, RICO.Instantiation)
        thumbnail.addProperty(RICO.type, Constant.thumbnailRicoType)
        thumbnail.addProperty(EBUCORE.locator, literal)
        digitalObject.addProperty(RICO.hasDerivedInstantiation, thumbnail)
        thumbnail.addProperty(RICO.isDerivedFromInstantiation, digitalObject)
        record.addProperty(RICO.hasInstantiation, thumbnail)
        thumbnail.addProperty(RICO.instantiates, record)
339
        return uri
340
341
    }

Sebastian Schüpbach's avatar
Sebastian Schüpbach committed
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
    private fun addThumbnailSftpLocatorToModel(
        key: String,
        data: Triple<Pair<Model, HeaderMetadata>, List<Resource>, Report>
    ): Triple<Pair<Model, HeaderMetadata>, List<Resource>, Report> {
        return addSftpLocatorToModel(key, data, Constant.thumbnailFolderName)
    }

    private fun addMediaSftpLocatorToModel(
        key: String,
        data: Triple<Pair<Model, HeaderMetadata>, List<Resource>, Report>
    ): Triple<Pair<Model, HeaderMetadata>, List<Resource>, Report> {
        return addSftpLocatorToModel(key, data, Constant.mediaFolderName)
    }

    private fun addSftpLocatorToModel(
Matthias's avatar
Matthias committed
357
358
        key: String,
        data: Triple<Pair<Model, HeaderMetadata>, List<Resource>, Report>,
359
        type: String
Matthias's avatar
Matthias committed
360
    ): Triple<Pair<Model, HeaderMetadata>, List<Resource>, Report> {
361
362
363
        val recordResource = getRecordResource(data.second)!!
        val digitalObjectResource = getDigitalObjectResource(data.second)!!
        val originalIdentifierValue = getOriginalIdentifier(recordResource)!!
Sebastian Schüpbach's avatar
Sebastian Schüpbach committed
364
        val link = getLinkToResourceOnSFTPServer(data.first.second.recordSetId, type, originalIdentifierValue)
365
366
367
            ?: return if (type == Constant.thumbnailFolderName) {
                updateRecord(data, ReportStatus.success, thumbnailMessage = "no local thumbnails available")
            } else {
Sebastian Schüpbach's avatar
Sebastian Schüpbach committed
368
                updateRecord(data, ReportStatus.warning, digitalObjectMessage = ReportMessages.reportFailure(key, type))
369
370
            }
        return if (type == Constant.mediaFolderName) {
Sebastian Schüpbach's avatar
Sebastian Schüpbach committed
371
            addLocatorToDigitalObjectResource(data.first.first, link, digitalObjectResource)
372
373
            updateRecord(
                data,
Sebastian Schüpbach's avatar
Sebastian Schüpbach committed
374
                ReportStatus.success,
375
                digitalObjectMessage = ReportMessages.reportSuccess(digitalObjectResource.uri, link, type)
Sebastian Schüpbach's avatar
Sebastian Schüpbach committed
376
            )
377
378
379
380
381
382
383
384
        } else {
            val uri = createThumbnailResource(data.first.first, recordResource, digitalObjectResource, link)
            updateRecord(
                data,
                ReportStatus.success,
                digitalObjectMessage = ReportMessages.reportSuccess(uri, link, type)
            )
        }
Sebastian Schüpbach's avatar
Sebastian Schüpbach committed
385
386
387
388
389
390
391
392
393
394
395
    }

    private fun getLinkToResourceOnSFTPServer(
        recordSetId: String,
        type: String,
        originalIdentifierValue: String
    ): String? {
        for (extension in fileExtensions) {
            val filePath = "$sftpBasePath/$recordSetId/$type/$originalIdentifierValue.$extension"
            if (sftpClient.exists(filePath)) {
                return "${Constant.sftpPathPrefix}$filePath"
Jonas Waeber's avatar
Jonas Waeber committed
396
            }
Jonas Waeber's avatar
Jonas Waeber committed
397
        }
Sebastian Schüpbach's avatar
Sebastian Schüpbach committed
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
        return null
    }

    private fun getRecordResource(resources: List<Resource>): Resource? {
        return resources.firstOrNull { it.hasProperty(RDF.type, RICO.Record) }
    }

    private fun getDigitalObjectResource(resources: List<Resource>): Resource? {
        return resources.firstOrNull { it.hasProperty(RICO.type, Constant.digitalObject) }
    }

    private fun isNoValidUrl(locator: String): Boolean {
        return try {
            URL(locator)
            false
        } catch (ex: MalformedURLException) {
            true
        }
    }

    private fun addLocatorToDigitalObjectResource(model: Model, sftpLink: String, digitalObjectResource: Resource) {
        val literal = ResourceFactory.createPlainLiteral(sftpLink)
        digitalObjectResource.addLiteral(EBUCORE.locator, literal)
        model.createLiteral(digitalObjectResource.toString(), true)
    }

    private fun serializeModel(model: Model): String {
        val out = StringWriter()
        model.write(out, Constant.rdfParserLang)
        return out.toString().trim()
Jonas Waeber's avatar
Jonas Waeber committed
428
    }
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449

    private fun updateRecord(
        value: Triple<Pair<Model, HeaderMetadata>, List<Resource>, Report>,
        status: String,
        generalMessage: String = "",
        digitalObjectMessage: String = "",
        thumbnailMessage: String = ""
    ): Triple<Pair<Model, HeaderMetadata>, List<Resource>, Report> {
        val amendedReport = value.third.copy(
            status = status,
            generalFailureMessage = if (generalMessage != "") generalMessage else value.third.generalFailureMessage,
            digitalObjectMessage = if (digitalObjectMessage != "") digitalObjectMessage else value.third.digitalObjectMessage,
            thumbnailMessage = if (thumbnailMessage != "") thumbnailMessage else value.third.thumbnailMessage
        )
        return value.copy(third = amendedReport)
    }

    private fun createRecord(
        value: Pair<Pair<Model, HeaderMetadata>, List<Resource>>,
        messageId: String,
        status: String,
Sebastian Schüpbach's avatar
Sebastian Schüpbach committed
450
        generalFailureMessage: String = "",
451
452
453
454
455
456
        digitalObjectMessage: String = "",
        thumbnailMessage: String = ""
    ): Triple<Pair<Model, HeaderMetadata>, List<Resource>, Report> {
        val report = Report(
            messageId,
            status,
Sebastian Schüpbach's avatar
Sebastian Schüpbach committed
457
            generalFailureMessage = generalFailureMessage,
458
459
460
461
462
            digitalObjectMessage = digitalObjectMessage,
            thumbnailMessage = thumbnailMessage
        )
        return updateRecord(Triple(value.first, value.second, report), status = status)
    }
Sebastian Schüpbach's avatar
Sebastian Schüpbach committed
463
}