diff --git a/knora-ontologies/knora-base.ttl b/knora-ontologies/knora-base.ttl index 2ad4b5fc01..2d5c77c4d6 100644 --- a/knora-ontologies/knora-base.ttl +++ b/knora-ontologies/knora-base.ttl @@ -33,7 +33,7 @@ :attachedToProject knora-admin:SystemProject ; - :ontologyVersion "knora-base v10" . + :ontologyVersion "knora-base v11" . @@ -1674,7 +1674,7 @@ rdfs:subClassOf :FileValue , [ rdf:type owl:Restriction ; owl:onProperty :duration ; - owl:cardinality "1"^^xsd:nonNegativeInteger + owl:maxCardinality "1"^^xsd:nonNegativeInteger ] ; rdfs:comment "Represents an audio file"@en . @@ -2163,7 +2163,7 @@ ] , [ rdf:type owl:Restriction ; owl:onProperty :duration ; - owl:cardinality "1"^^xsd:nonNegativeInteger + owl:maxCardinality "1"^^xsd:nonNegativeInteger ] ; rdfs:comment "Represents a moving image file"@en . diff --git a/sipi/scripts/file_info.lua b/sipi/scripts/file_info.lua index 069590bc74..82c83e832b 100644 --- a/sipi/scripts/file_info.lua +++ b/sipi/scripts/file_info.lua @@ -23,6 +23,7 @@ require "util" TEXT = "text" IMAGE = "image" DOCUMENT = "document" +AUDIO = "audio" ------------------------------------------------------------------------------- -- Mimetype constants @@ -37,7 +38,9 @@ local TEXT_XML = "text/xml" local TEXT_PLAIN = "text/plain" local AUDIO_MP3 = "audio/mpeg" local AUDIO_MP4 = "audio/mp4" -local AUDIO_WAV = "audio/x-wav" +local AUDIO_WAV = "audio/wav" +local AUDIO_X_WAV = "audio/x-wav" +local AUDIO_VND_WAVE = "audio/vnd.wave" local APPLICATION_PDF = "application/pdf" local APPLICATION_DOC = "application/msword" local APPLICATION_DOCX = "application/vnd.openxmlformats-officedocument.wordprocessingml.document" @@ -61,7 +64,9 @@ local image_mime_types = { local audio_mime_types = { AUDIO_MP3, AUDIO_MP4, - AUDIO_WAV + AUDIO_WAV, + AUDIO_X_WAV, + AUDIO_VND_WAVE } local text_mime_types = { diff --git a/test_data/test_route/files/minimal.wav b/test_data/test_route/files/minimal.wav new file mode 100644 index 0000000000..8dbde9545c Binary files /dev/null and b/test_data/test_route/files/minimal.wav differ diff --git a/test_data/test_route/files/test.wav b/test_data/test_route/files/test.wav new file mode 100644 index 0000000000..679f9ac158 Binary files /dev/null and b/test_data/test_route/files/test.wav differ diff --git a/webapi/src/main/resources/application.conf b/webapi/src/main/resources/application.conf index 749d1dfb0d..151af9735e 100644 --- a/webapi/src/main/resources/application.conf +++ b/webapi/src/main/resources/application.conf @@ -398,8 +398,8 @@ app { "application/x-iso9660-image", ] text-mime-types = ["application/xml", "text/xml", "text/csv", "text/plain"] - movie-mime-types = [] - sound-mime-types = ["audio/mpeg", "audio/mp4", "audio/x-wav", "audio/vnd.wav"] + video-mime-types = [] + audio-mime-types = ["audio/mpeg", "audio/mp4", "audio/wav", "audio/x-wav", "audio/vnd.wave"] } ark { diff --git a/webapi/src/main/scala/org/knora/webapi/messages/store/sipimessages/SipiMessages.scala b/webapi/src/main/scala/org/knora/webapi/messages/store/sipimessages/SipiMessages.scala index 7d6a86c7d0..e7b820760b 100644 --- a/webapi/src/main/scala/org/knora/webapi/messages/store/sipimessages/SipiMessages.scala +++ b/webapi/src/main/scala/org/knora/webapi/messages/store/sipimessages/SipiMessages.scala @@ -51,14 +51,16 @@ case class GetFileMetadataRequest(fileUrl: String, requestingUser: UserADM) exte * @param internalMimeType the file's internal MIME type. Always defined (https://dasch.myjetbrains.com/youtrack/issue/DSP-711). * @param width the file's width in pixels, if applicable. * @param height the file's height in pixels, if applicable. - * @param pageCount the number of pages in the file, if applicable. + * @param pageCount the number of pages in the file, if applicable. + * @param duration the duration of the file in seconds, if applicable. */ case class GetFileMetadataResponse(originalFilename: Option[String], originalMimeType: Option[String], internalMimeType: String, width: Option[Int], height: Option[Int], - pageCount: Option[Int]) + pageCount: Option[Int], + duration: Option[BigDecimal]) /** * Asks Sipi to move a file from temporary to permanent storage. diff --git a/webapi/src/main/scala/org/knora/webapi/messages/util/ConstructResponseUtilV2.scala b/webapi/src/main/scala/org/knora/webapi/messages/util/ConstructResponseUtilV2.scala index 60a05d5224..8e3827130f 100644 --- a/webapi/src/main/scala/org/knora/webapi/messages/util/ConstructResponseUtilV2.scala +++ b/webapi/src/main/scala/org/knora/webapi/messages/util/ConstructResponseUtilV2.scala @@ -1087,6 +1087,19 @@ object ConstructResponseUtilV2 { fileValue = fileValue, comment = valueCommentOption )) + + case OntologyConstants.KnoraBase.AudioFileValue => + FastFuture.successful( + AudioFileValueContentV2( + ontologySchema = InternalSchema, + fileValue = fileValue, + duration = valueObject + .maybeStringObject(OntologyConstants.KnoraBase.Duration.toSmartIri) + .map(definedDuration => BigDecimal(definedDuration)), + comment = valueCommentOption + )) + + case _ => throw InconsistentRepositoryDataException(s"Unexpected file value type: $valueType") } } diff --git a/webapi/src/main/scala/org/knora/webapi/messages/util/ValueUtilV1.scala b/webapi/src/main/scala/org/knora/webapi/messages/util/ValueUtilV1.scala index fb2314c443..1727445b35 100644 --- a/webapi/src/main/scala/org/knora/webapi/messages/util/ValueUtilV1.scala +++ b/webapi/src/main/scala/org/knora/webapi/messages/util/ValueUtilV1.scala @@ -88,6 +88,8 @@ class ValueUtilV1(private val settings: KnoraSettingsImpl) { makeStillImageValue(valueProps, projectShortcode, responderManager, userProfile) case OntologyConstants.KnoraBase.TextFileValue => makeTextFileValue(valueProps, projectShortcode, responderManager, userProfile) + case OntologyConstants.KnoraBase.AudioFileValue => + makeAudioFileValue(valueProps, projectShortcode, responderManager, userProfile) case OntologyConstants.KnoraBase.DocumentFileValue => makeDocumentFileValue(valueProps, projectShortcode, responderManager, userProfile) case OntologyConstants.KnoraBase.LinkValue => makeLinkValue(valueProps, responderManager, userProfile) @@ -122,16 +124,20 @@ class ValueUtilV1(private val settings: KnoraSettingsImpl) { * Creates a URL for accessing a text file via Sipi. * * @param textFileValue the text file value representing the text file. - * @param external a flag denoting the type of URL that should be generated. * @return a Sipi URL. */ - def makeSipiTextFileGetUrlFromFilename(textFileValue: TextFileValueV1, external: Boolean = true): String = { + def makeSipiTextFileGetUrlFromFilename(textFileValue: TextFileValueV1): String = { + s"${settings.externalSipiBaseUrl}/${textFileValue.projectShortcode}/${textFileValue.internalFilename}" + } - if (external) { - s"${settings.externalSipiBaseUrl}/${textFileValue.projectShortcode}/${textFileValue.internalFilename}" - } else { - s"${settings.internalSipiBaseUrl}/${textFileValue.projectShortcode}/${textFileValue.internalFilename}" - } + /** + * Creates a URL for accessing an audio file via Sipi. + * + * @param audioFileValue the file value representing the audio file. + * @return a Sipi URL. + */ + def makeSipiAudioFileGetUrlFromFilename(audioFileValue: AudioFileValueV1): String = { + s"${settings.externalSipiIIIFGetUrl}/${audioFileValue.projectShortcode}/${audioFileValue.internalFilename}/file" } // A Map of MIME types to Knora API v1 binary format name. @@ -158,9 +164,11 @@ class ValueUtilV1(private val settings: KnoraSettingsImpl) { "text/csv" -> "CSV", "application/zip" -> "ZIP", "application/x-compressed-zip" -> "ZIP", - "audio/x-wav" -> "AUDIO", + "audio/mpeg" -> "AUDIO", "audio/mp4" -> "AUDIO", - "audio/mpeg" -> "AUDIO" + "audio/wav" -> "AUDIO", + "audio/x-wav" -> "AUDIO", + "audio/vnd.wave" -> "AUDIO" ), { key: String => s"Unknown MIME type: $key" } @@ -199,6 +207,14 @@ class ValueUtilV1(private val settings: KnoraSettingsImpl) { origname = textFileValue.originalFilename, path = makeSipiTextFileGetUrlFromFilename(textFileValue) ) + + case audioFileValue: AudioFileValueV1 => + LocationV1( + format_name = mimeType2V1Format(audioFileValue.internalMimeType), + origname = audioFileValue.originalFilename, + path = makeSipiAudioFileGetUrlFromFilename(audioFileValue) + ) + case otherType => throw NotImplementedException(s"Type not yet implemented: ${otherType.valueTypeIri}") } } @@ -364,12 +380,14 @@ class ValueUtilV1(private val settings: KnoraSettingsImpl) { case _: LinkV1 => basicObjectResponse - case _: StillImageFileValueV1 => basicObjectResponse // TODO: implement this. + case _: StillImageFileValueV1 => basicObjectResponse case _: TextFileValueV1 => basicObjectResponse case _: DocumentFileValueV1 => basicObjectResponse + case _: AudioFileValueV1 => basicObjectResponse + case _: HierarchicalListValueV1 => basicObjectResponse case _: ColorValueV1 => basicObjectResponse @@ -856,6 +874,31 @@ class ValueUtilV1(private val settings: KnoraSettingsImpl) { )) } + /** + * Converts a [[ValueProps]] into a [[AudioFileValueV1]]. + * + * @param valueProps a [[ValueProps]] representing the SPARQL query results to be converted. + * @return a [[DocumentFileValueV1]]. + */ + private def makeAudioFileValue( + valueProps: ValueProps, + projectShortcode: String, + responderManager: ActorRef, + userProfile: UserADM)(implicit timeout: Timeout, executionContext: ExecutionContext): Future[ApiValueV1] = { + val predicates = valueProps.literalData + + Future( + AudioFileValueV1( + internalMimeType = predicates(OntologyConstants.KnoraBase.InternalMimeType).literals.head, + internalFilename = predicates(OntologyConstants.KnoraBase.InternalFilename).literals.head, + originalFilename = predicates.get(OntologyConstants.KnoraBase.OriginalFilename).map(_.literals.head), + projectShortcode = projectShortcode, + duration = predicates + .get(OntologyConstants.KnoraBase.Duration) + .map(valueLiterals => BigDecimal(valueLiterals.literals.head)) + )) + } + /** * Converts a [[ValueProps]] into a [[LinkValueV1]]. * diff --git a/webapi/src/main/scala/org/knora/webapi/messages/v1/responder/valuemessages/ValueMessagesV1.scala b/webapi/src/main/scala/org/knora/webapi/messages/v1/responder/valuemessages/ValueMessagesV1.scala index a76241569c..ce76c7a403 100644 --- a/webapi/src/main/scala/org/knora/webapi/messages/v1/responder/valuemessages/ValueMessagesV1.scala +++ b/webapi/src/main/scala/org/knora/webapi/messages/v1/responder/valuemessages/ValueMessagesV1.scala @@ -1638,6 +1638,62 @@ case class DocumentFileValueV1(internalMimeType: String, } } +case class AudioFileValueV1(internalMimeType: String, + internalFilename: String, + originalFilename: Option[String], + originalMimeType: Option[String] = None, + projectShortcode: String, + duration: Option[BigDecimal] = None) + extends FileValueV1 { + + def valueTypeIri: IRI = OntologyConstants.KnoraBase.AudioFileValue + + def toJsValue: JsValue = ApiValueV1JsonProtocol.audioFileValueV1Format.write(this) + + override def toString: String = internalFilename + + /** + * Checks if a new moving image file value would duplicate an existing moving image file value. + * + * @param other another [[ValueV1]]. + * @return `true` if `other` is a duplicate of `this`. + */ + override def isDuplicateOfOtherValue(other: ApiValueV1): Boolean = { + other match { + case audioFileValueV1: AudioFileValueV1 => audioFileValueV1 == this + case otherValue => + throw InconsistentRepositoryDataException(s"Cannot compare a $valueTypeIri to a ${otherValue.valueTypeIri}") + } + } + + /** + * Checks if a new version of a moving image file value would be redundant given the current version of the value. + * + * @param currentVersion the current version of the value. + * @return `true` if this [[UpdateValueV1]] is redundant given `currentVersion`. + */ + override def isRedundant(currentVersion: ApiValueV1): Boolean = { + currentVersion match { + case audioFileValueV1: AudioFileValueV1 => audioFileValueV1 == this + case other => + throw InconsistentRepositoryDataException(s"Cannot compare a $valueTypeIri to a ${other.valueTypeIri}") + } + } + + override def toFileValueContentV2: FileValueContentV2 = { + AudioFileValueContentV2( + ontologySchema = InternalSchema, + fileValue = FileValueV2( + internalFilename = internalFilename, + internalMimeType = internalMimeType, + originalFilename = originalFilename, + originalMimeType = Some(internalMimeType) + ), + duration = duration + ) + } +} + case class MovingImageFileValueV1(internalMimeType: String, internalFilename: String, originalFilename: Option[String], @@ -1822,6 +1878,7 @@ object ApiValueV1JsonProtocol extends SprayJsonSupport with DefaultJsonProtocol implicit val stillImageFileValueV1Format: JsonFormat[StillImageFileValueV1] = jsonFormat7(StillImageFileValueV1) implicit val documentFileValueV1Format: JsonFormat[DocumentFileValueV1] = jsonFormat8(DocumentFileValueV1) implicit val textFileValueV1Format: JsonFormat[TextFileValueV1] = jsonFormat5(TextFileValueV1) + implicit val audioFileValueV1Format: JsonFormat[AudioFileValueV1] = jsonFormat6(AudioFileValueV1) implicit val movingImageFileValueV1Format: JsonFormat[MovingImageFileValueV1] = jsonFormat5(MovingImageFileValueV1) implicit val valueVersionV1Format: JsonFormat[ValueVersionV1] = jsonFormat3(ValueVersionV1) implicit val linkValueV1Format: JsonFormat[LinkValueV1] = jsonFormat4(LinkValueV1) diff --git a/webapi/src/main/scala/org/knora/webapi/messages/v2/responder/valuemessages/ValueMessagesV2.scala b/webapi/src/main/scala/org/knora/webapi/messages/v2/responder/valuemessages/ValueMessagesV2.scala index f9bac2f921..8455cec6d7 100644 --- a/webapi/src/main/scala/org/knora/webapi/messages/v2/responder/valuemessages/ValueMessagesV2.scala +++ b/webapi/src/main/scala/org/knora/webapi/messages/v2/responder/valuemessages/ValueMessagesV2.scala @@ -1337,6 +1337,17 @@ object ValueContentV2 extends ValueContentReaderV2[ValueContentV2] { log = log ) + case OntologyConstants.KnoraApiV2Complex.AudioFileValue => + AudioFileValueContentV2.fromJsonLDObject( + jsonLDObject = jsonLDObject, + requestingUser = requestingUser, + responderManager = responderManager, + storeManager = storeManager, + featureFactoryConfig = featureFactoryConfig, + settings = settings, + log = log + ) + case other => throw NotImplementedException(s"Parsing of JSON-LD value type not implemented: $other") } @@ -3453,6 +3464,105 @@ object TextFileValueContentV2 extends ValueContentReaderV2[TextFileValueContentV } } +/** + * Represents audio file metadata. + * + * @param fileValue the basic metadata about the file value. + * @param duration the duration of the audio file in seconds. + * @param comment a comment on this [[AudioFileValueContentV2]], if any. + */ +case class AudioFileValueContentV2(ontologySchema: OntologySchema, + fileValue: FileValueV2, + duration: Option[BigDecimal] = None, + comment: Option[String] = None) + extends FileValueContentV2 { + override def valueType: SmartIri = { + implicit val stringFormatter: StringFormatter = StringFormatter.getGeneralInstance + OntologyConstants.KnoraBase.AudioFileValue.toSmartIri.toOntologySchema(ontologySchema) + } + + override def valueHasString: String = fileValue.internalFilename + + override def toOntologySchema(targetSchema: OntologySchema): AudioFileValueContentV2 = + copy(ontologySchema = targetSchema) + + override def toJsonLDValue(targetSchema: ApiV2Schema, + projectADM: ProjectADM, + settings: KnoraSettingsImpl, + schemaOptions: Set[SchemaOption]): JsonLDValue = { + val fileUrl: String = s"${settings.externalSipiBaseUrl}/${projectADM.shortcode}/${fileValue.internalFilename}/file" + + targetSchema match { + case ApiV2Simple => toJsonLDValueInSimpleSchema(fileUrl) + + case ApiV2Complex => + JsonLDObject(toJsonLDObjectMapInComplexSchema(fileUrl)) + } + } + + override def unescape: ValueContentV2 = { + copy(comment = comment.map(commentStr => stringFormatter.fromSparqlEncodedString(commentStr))) + } + + override def wouldDuplicateOtherValue(that: ValueContentV2): Boolean = { + that match { + case thatAudioFile: AudioFileValueContentV2 => + fileValue == thatAudioFile.fileValue + + case _ => throw AssertionException(s"Can't compare a <$valueType> to a <${that.valueType}>") + } + } + + override def wouldDuplicateCurrentVersion(currentVersion: ValueContentV2): Boolean = { + currentVersion match { + case thatAudioFile: AudioFileValueContentV2 => + fileValue == thatAudioFile.fileValue && + comment == thatAudioFile.comment + + case _ => throw AssertionException(s"Can't compare a <$valueType> to a <${currentVersion.valueType}>") + } + } +} + +/** + * Constructs [[AudioFileValueContentV2]] objects based on JSON-LD input. + */ +object AudioFileValueContentV2 extends ValueContentReaderV2[AudioFileValueContentV2] { + override def fromJsonLDObject(jsonLDObject: JsonLDObject, + requestingUser: UserADM, + responderManager: ActorRef, + storeManager: ActorRef, + featureFactoryConfig: FeatureFactoryConfig, + settings: KnoraSettingsImpl, + log: LoggingAdapter)( + implicit timeout: Timeout, + executionContext: ExecutionContext): Future[AudioFileValueContentV2] = { + implicit val stringFormatter: StringFormatter = StringFormatter.getGeneralInstance + + for { + fileValueWithSipiMetadata <- FileValueWithSipiMetadata.fromJsonLDObject( + jsonLDObject = jsonLDObject, + requestingUser = requestingUser, + responderManager = responderManager, + storeManager = storeManager, + settings = settings, + log = log + ) + + _ = if (!settings.audioMimeTypes.contains(fileValueWithSipiMetadata.fileValue.internalMimeType)) { + throw BadRequestException( + s"File ${fileValueWithSipiMetadata.fileValue.internalFilename} has MIME type ${fileValueWithSipiMetadata.fileValue.internalMimeType}, which is not supported for audio files") + } + } yield + AudioFileValueContentV2( + ontologySchema = ApiV2Complex, + fileValue = fileValueWithSipiMetadata.fileValue, + duration = fileValueWithSipiMetadata.sipiFileMetadata.duration, + comment = getComment(jsonLDObject) + ) + } +} + /** * Represents a Knora link value. * diff --git a/webapi/src/main/scala/org/knora/webapi/package.scala b/webapi/src/main/scala/org/knora/webapi/package.scala index 0396ad51b2..2822a71c89 100644 --- a/webapi/src/main/scala/org/knora/webapi/package.scala +++ b/webapi/src/main/scala/org/knora/webapi/package.scala @@ -25,7 +25,7 @@ package object webapi { * The version of `knora-base` and of the other built-in ontologies that this version of Knora requires. * Must be the same as the object of `knora-base:ontologyVersion` in the `knora-base` ontology being used. */ - val KnoraBaseVersion: String = "knora-base v10" + val KnoraBaseVersion: String = "knora-base v11" /** * `IRI` is a synonym for `String`, used to improve code readability. diff --git a/webapi/src/main/scala/org/knora/webapi/routing/RouteUtilV1.scala b/webapi/src/main/scala/org/knora/webapi/routing/RouteUtilV1.scala index c5ca3ae6ee..3cb226b073 100644 --- a/webapi/src/main/scala/org/knora/webapi/routing/RouteUtilV1.scala +++ b/webapi/src/main/scala/org/knora/webapi/routing/RouteUtilV1.scala @@ -34,6 +34,7 @@ import org.knora.webapi.messages.store.sipimessages.GetFileMetadataResponse import org.knora.webapi.messages.util.standoff.StandoffTagUtilV2 import org.knora.webapi.messages.util.standoff.StandoffTagUtilV2.TextWithStandoffTagsV2 import org.knora.webapi.messages.v1.responder.valuemessages.{ + AudioFileValueV1, DocumentFileValueV1, FileValueV1, StillImageFileValueV1, @@ -279,6 +280,21 @@ object RouteUtilV1 { "application/gzip" ) + /** + * MIME types used in Sipi to store audio files. + */ + private val audioMimeTypes: Set[String] = Set( + "application/xml", + "text/xml", + "text/csv", + "text/plain", + "audio/mpeg", + "audio/mp4", + "audio/wav", + "audio/x-wav", + "audio/vnd.wave" + ) + /** * Converts file metadata from Sipi into a [[FileValueV1]]. * @@ -320,6 +336,15 @@ object RouteUtilV1 { dimX = fileMetadataResponse.width, dimY = fileMetadataResponse.height ) + } else if (audioMimeTypes.contains(fileMetadataResponse.internalMimeType)) { + AudioFileValueV1( + internalFilename = filename, + internalMimeType = fileMetadataResponse.internalMimeType, + originalFilename = fileMetadataResponse.originalFilename, + originalMimeType = fileMetadataResponse.originalMimeType, + projectShortcode = projectShortcode, + duration = fileMetadataResponse.duration + ) } else { throw BadRequestException(s"MIME type ${fileMetadataResponse.internalMimeType} not supported in Knora API v1") } diff --git a/webapi/src/main/scala/org/knora/webapi/settings/KnoraSettings.scala b/webapi/src/main/scala/org/knora/webapi/settings/KnoraSettings.scala index da9fed938a..1f20055863 100644 --- a/webapi/src/main/scala/org/knora/webapi/settings/KnoraSettings.scala +++ b/webapi/src/main/scala/org/knora/webapi/settings/KnoraSettings.scala @@ -125,6 +125,24 @@ class KnoraSettingsImpl(config: Config, log: LoggingAdapter) extends Extension { } .toSet + val audioMimeTypes: Set[String] = config + .getList("app.sipi.audio-mime-types") + .iterator + .asScala + .map { mType: ConfigValue => + mType.unwrapped.toString + } + .toSet + + val videoMimeTypes: Set[String] = config + .getList("app.sipi.video-mime-types") + .iterator + .asScala + .map { mType: ConfigValue => + mType.unwrapped.toString + } + .toSet + val internalSipiProtocol: String = config.getString("app.sipi.internal-protocol") val internalSipiHost: String = config.getString("app.sipi.internal-host") val internalSipiPort: Int = config.getInt("app.sipi.internal-port") diff --git a/webapi/src/main/scala/org/knora/webapi/store/iiif/SipiConnector.scala b/webapi/src/main/scala/org/knora/webapi/store/iiif/SipiConnector.scala index 62eadd17fb..65b67d6274 100644 --- a/webapi/src/main/scala/org/knora/webapi/store/iiif/SipiConnector.scala +++ b/webapi/src/main/scala/org/knora/webapi/store/iiif/SipiConnector.scala @@ -91,13 +91,15 @@ class SipiConnector extends Actor with ActorLogging { * @param width the file's width in pixels, if applicable. * @param height the file's height in pixels, if applicable. * @param numpages the number of pages in the file, if applicable. + * @param duration the duration of the file in seconds, if applicable. */ case class SipiKnoraJsonResponse(originalFilename: Option[String], originalMimeType: Option[String], internalMimeType: String, width: Option[Int], height: Option[Int], - numpages: Option[Int]) { + numpages: Option[Int], + duration: Option[BigDecimal]) { if (originalFilename.contains("")) { throw SipiException(s"Sipi returned an empty originalFilename") } @@ -108,7 +110,7 @@ class SipiConnector extends Actor with ActorLogging { } object SipiKnoraJsonResponseProtocol extends SprayJsonSupport with DefaultJsonProtocol { - implicit val sipiKnoraJsonResponseFormat: RootJsonFormat[SipiKnoraJsonResponse] = jsonFormat6(SipiKnoraJsonResponse) + implicit val sipiKnoraJsonResponseFormat: RootJsonFormat[SipiKnoraJsonResponse] = jsonFormat7(SipiKnoraJsonResponse) } /** @@ -133,7 +135,8 @@ class SipiConnector extends Actor with ActorLogging { internalMimeType = sipiResponse.internalMimeType, width = sipiResponse.width, height = sipiResponse.height, - pageCount = sipiResponse.numpages + pageCount = sipiResponse.numpages, + duration = sipiResponse.duration ) } diff --git a/webapi/src/main/scala/org/knora/webapi/store/triplestore/upgrade/RepositoryUpdatePlan.scala b/webapi/src/main/scala/org/knora/webapi/store/triplestore/upgrade/RepositoryUpdatePlan.scala index a174206d47..ded46d9f26 100644 --- a/webapi/src/main/scala/org/knora/webapi/store/triplestore/upgrade/RepositoryUpdatePlan.scala +++ b/webapi/src/main/scala/org/knora/webapi/store/triplestore/upgrade/RepositoryUpdatePlan.scala @@ -33,7 +33,8 @@ object RepositoryUpdatePlan { PluginForKnoraBaseVersion(versionNumber = 7, plugin = new NoopPlugin), // PR 1403 PluginForKnoraBaseVersion(versionNumber = 8, plugin = new UpgradePluginPR1615(featureFactoryConfig)), PluginForKnoraBaseVersion(versionNumber = 9, plugin = new UpgradePluginPR1746(featureFactoryConfig, log)), - PluginForKnoraBaseVersion(versionNumber = 10, plugin = new NoopPlugin) // PR 1808 + PluginForKnoraBaseVersion(versionNumber = 10, plugin = new NoopPlugin), // PR 1808 + PluginForKnoraBaseVersion(versionNumber = 11, plugin = new NoopPlugin) // PR 1813 ) /** diff --git a/webapi/src/main/twirl/org/knora/webapi/messages/twirl/queries/sparql/v1/addValueVersion.scala.txt b/webapi/src/main/twirl/org/knora/webapi/messages/twirl/queries/sparql/v1/addValueVersion.scala.txt index 0b71321e46..39e0eeebcc 100644 --- a/webapi/src/main/twirl/org/knora/webapi/messages/twirl/queries/sparql/v1/addValueVersion.scala.txt +++ b/webapi/src/main/twirl/org/knora/webapi/messages/twirl/queries/sparql/v1/addValueVersion.scala.txt @@ -265,6 +265,35 @@ DELETE { } } + case audioFileValue: AudioFileValueV1 => { + ?newValue knora-base:internalFilename """@audioFileValue.internalFilename""" ; + knora-base:internalMimeType """@audioFileValue.internalMimeType""" . + + @audioFileValue.originalFilename match { + case Some(definedOriginalFilename) => { + ?newValue knora-base:originalFilename """@definedOriginalFilename""" . + } + + case None => {} + } + + @audioFileValue.originalMimeType match { + case Some(definedOriginalMimeType) => { + ?newValue knora-base:originalMimeType """@definedOriginalMimeType""" . + } + + case None => {} + } + + @audioFileValue.duration match { + case Some(definedDuration) => { + ?newValue knora-base:duration """@definedDuration""" . + } + + case None => {} + } + } + case documentFileValue: DocumentFileValueV1 => { ?newValue knora-base:internalFilename """@documentFileValue.internalFilename""" . ?newValue knora-base:internalMimeType """@documentFileValue.internalMimeType""" . diff --git a/webapi/src/main/twirl/org/knora/webapi/messages/twirl/queries/sparql/v1/generateInsertStatementsForCreateValue.scala.txt b/webapi/src/main/twirl/org/knora/webapi/messages/twirl/queries/sparql/v1/generateInsertStatementsForCreateValue.scala.txt index e610b088c1..a4fe7a5be3 100644 --- a/webapi/src/main/twirl/org/knora/webapi/messages/twirl/queries/sparql/v1/generateInsertStatementsForCreateValue.scala.txt +++ b/webapi/src/main/twirl/org/knora/webapi/messages/twirl/queries/sparql/v1/generateInsertStatementsForCreateValue.scala.txt @@ -221,6 +221,35 @@ } } + case audioFileValue: AudioFileValueV1 => { + <@newValueIri> knora-base:internalFilename """@audioFileValue.internalFilename""" ; + knora-base:internalMimeType """@audioFileValue.internalMimeType""" . + + @audioFileValue.originalFilename match { + case Some(definedOriginalFilename) => { + <@newValueIri> knora-base:originalFilename """@definedOriginalFilename""" . + } + + case None => {} + } + + @audioFileValue.originalMimeType match { + case Some(definedOriginalMimeType) => { + <@newValueIri> knora-base:originalMimeType """@definedOriginalMimeType""" . + } + + case None => {} + } + + @audioFileValue.duration match { + case Some(definedDuration) => { + <@newValueIri> knora-base:duration """@definedDuration""" . + } + + case None => {} + } + } + case documentFileValue: DocumentFileValueV1 => { <@newValueIri> knora-base:internalFilename """@documentFileValue.internalFilename""" ; knora-base:internalMimeType """@documentFileValue.internalMimeType""" . diff --git a/webapi/src/main/twirl/org/knora/webapi/messages/twirl/queries/sparql/v2/generateInsertStatementsForValueContent.scala.txt b/webapi/src/main/twirl/org/knora/webapi/messages/twirl/queries/sparql/v2/generateInsertStatementsForValueContent.scala.txt index 0c9ae4cc50..296e5f6d3a 100644 --- a/webapi/src/main/twirl/org/knora/webapi/messages/twirl/queries/sparql/v2/generateInsertStatementsForValueContent.scala.txt +++ b/webapi/src/main/twirl/org/knora/webapi/messages/twirl/queries/sparql/v2/generateInsertStatementsForValueContent.scala.txt @@ -205,6 +205,17 @@ } } + case audioFileValue: AudioFileValueContentV2 => { + @audioFileValue.duration match { + case Some(definedDuration) => { + <@newValueIri> knora-base:duration @definedDuration . + } + + case None => {} + } + + } + case _ => {} } } diff --git a/webapi/src/test/scala/org/knora/webapi/it/v1/KnoraSipiIntegrationV1ITSpec.scala b/webapi/src/test/scala/org/knora/webapi/it/v1/KnoraSipiIntegrationV1ITSpec.scala index 513d069bf2..d4d68000e5 100644 --- a/webapi/src/test/scala/org/knora/webapi/it/v1/KnoraSipiIntegrationV1ITSpec.scala +++ b/webapi/src/test/scala/org/knora/webapi/it/v1/KnoraSipiIntegrationV1ITSpec.scala @@ -80,6 +80,7 @@ class KnoraSipiIntegrationV1ITSpec private val pdfResourceIri = new MutableTestIri private val zipResourceIri = new MutableTestIri + private val wavResourceIri = new MutableTestIri private val minimalPdfOriginalFilename = "minimal.pdf" private val pathToMinimalPdf = s"test_data/test_route/files/$minimalPdfOriginalFilename" @@ -97,6 +98,12 @@ class KnoraSipiIntegrationV1ITSpec private val testZipOriginalFilename = "test.zip" private val pathToTestZip = s"test_data/test_route/files/$testZipOriginalFilename" + private val minimalWavOriginalFilename = "minimal.wav" + private val pathToMinimalWav = s"test_data/test_route/files/$minimalWavOriginalFilename" + + private val testWavOriginalFilename = "test.wav" + private val pathToTestWav = s"test_data/test_route/files/$testWavOriginalFilename" + /** * Adds the IRI of a XSL transformation to the given mapping. * @@ -906,5 +913,95 @@ class KnoraSipiIntegrationV1ITSpec val sipiGetRequest = Get(zipUrl) ~> addCredentials(BasicHttpCredentials(userEmail, password)) checkResponseOK(sipiGetRequest) } + + "create a resource with a WAV file attached" in { + // Upload the WAV file to Sipi. + val zipUploadResponse: SipiUploadResponse = uploadToSipi( + loginToken = loginToken, + filesToUpload = Seq(FileToUpload(path = pathToMinimalWav, mimeType = MediaTypes.`audio/wav`)) + ) + + val uploadedWavFile: SipiUploadResponseEntry = zipUploadResponse.uploadedFiles.head + uploadedWavFile.originalFilename should ===(minimalWavOriginalFilename) + + // Create a resource for the WAV file. + val createAudioResourceParams = JsObject( + Map( + "restype_id" -> JsString("http://www.knora.org/ontology/knora-base#AudioRepresentation"), + "label" -> JsString("Wav file"), + "project_id" -> JsString("http://rdfh.ch/projects/0001"), + "properties" -> JsObject(), + "file" -> JsString(uploadedWavFile.internalFilename) + ) + ) + + // Send the JSON in a POST request to the Knora API server. + val createAudioResourceRequest: HttpRequest = Post( + baseApiUrl + "/v1/resources", + HttpEntity(ContentTypes.`application/json`, createAudioResourceParams.compactPrint)) ~> addCredentials( + BasicHttpCredentials(userEmail, password)) + + val createAudioResourceResponseJson: JsObject = getResponseJson(createAudioResourceRequest) + + // get the IRI of the audio file resource + val resourceIri: String = createAudioResourceResponseJson.fields.get("res_id") match { + case Some(JsString(res_id: String)) => res_id + case _ => throw InvalidApiJsonException("member 'res_id' was expected") + } + + wavResourceIri.set(resourceIri) + + // Request the audio file resource from the Knora API server. + val audioResourceRequest = Get(baseApiUrl + "/v1/resources/" + URLEncoder.encode(resourceIri, "UTF-8")) ~> addCredentials( + BasicHttpCredentials(userEmail, password)) + + val audioResourceResponse: JsObject = getResponseJson(audioResourceRequest) + val locdata = audioResourceResponse.fields("resinfo").asJsObject.fields("locdata").asJsObject + val zipUrl = + locdata.fields("path").asInstanceOf[JsString].value.replace("http://0.0.0.0:1024", baseInternalSipiUrl) + + // Request the file from Sipi. + val sipiGetRequest = Get(zipUrl) ~> addCredentials(BasicHttpCredentials(userEmail, password)) + checkResponseOK(sipiGetRequest) + } + + "change the WAV file attached to a resource" in { + // Upload the file to Sipi. + val sipiUploadResponse: SipiUploadResponse = uploadToSipi( + loginToken = loginToken, + filesToUpload = Seq(FileToUpload(path = pathToTestWav, mimeType = MediaTypes.`audio/wav`)) + ) + + val uploadedFile: SipiUploadResponseEntry = sipiUploadResponse.uploadedFiles.head + uploadedFile.originalFilename should ===(testWavOriginalFilename) + + // JSON describing the new file to Knora. + val knoraParams = JsObject( + Map( + "file" -> JsString(s"${uploadedFile.internalFilename}") + ) + ) + + // Send the JSON in a PUT request to the Knora API server. + val knoraPutRequest = Put( + baseApiUrl + "/v1/filevalue/" + URLEncoder.encode(wavResourceIri.get, "UTF-8"), + HttpEntity(ContentTypes.`application/json`, knoraParams.compactPrint)) ~> addCredentials( + BasicHttpCredentials(userEmail, password)) + + checkResponseOK(knoraPutRequest) + + // Request the document resource from the Knora API server. + val audioResourceRequest = Get(baseApiUrl + "/v1/resources/" + URLEncoder.encode(wavResourceIri.get, "UTF-8")) ~> addCredentials( + BasicHttpCredentials(userEmail, password)) + + val audioResourceResponse: JsObject = getResponseJson(audioResourceRequest) + val locdata = audioResourceResponse.fields("resinfo").asJsObject.fields("locdata").asJsObject + val wavUrl = + locdata.fields("path").asInstanceOf[JsString].value.replace("http://0.0.0.0:1024", baseInternalSipiUrl) + + // Request the file from Sipi. + val sipiGetRequest = Get(wavUrl) ~> addCredentials(BasicHttpCredentials(userEmail, password)) + checkResponseOK(sipiGetRequest) + } } } diff --git a/webapi/src/test/scala/org/knora/webapi/it/v2/KnoraSipiIntegrationV2ITSpec.scala b/webapi/src/test/scala/org/knora/webapi/it/v2/KnoraSipiIntegrationV2ITSpec.scala index 29829d5ddc..9bf94ce68e 100644 --- a/webapi/src/test/scala/org/knora/webapi/it/v2/KnoraSipiIntegrationV2ITSpec.scala +++ b/webapi/src/test/scala/org/knora/webapi/it/v2/KnoraSipiIntegrationV2ITSpec.scala @@ -69,6 +69,8 @@ class KnoraSipiIntegrationV2ITSpec private val csvValueIri = new MutableTestIri private val zipResourceIri = new MutableTestIri private val zipValueIri = new MutableTestIri + private val wavResourceIri = new MutableTestIri + private val wavValueIri = new MutableTestIri private val marblesOriginalFilename = "marbles.tif" private val pathToMarbles = s"test_data/test_route/images/$marblesOriginalFilename" @@ -110,6 +112,13 @@ class KnoraSipiIntegrationV2ITSpec private val testZipOriginalFilename = "test.zip" private val pathToTestZip = s"test_data/test_route/files/$testZipOriginalFilename" + private val minimalWavOriginalFilename = "minimal.wav" + private val pathToMinimalWav = s"test_data/test_route/files/$minimalWavOriginalFilename" + private val minimalWavDuration = BigDecimal("0.0") + + private val testWavOriginalFilename = "test.wav" + private val pathToTestWav = s"test_data/test_route/files/$testWavOriginalFilename" + /** * Represents the information that Knora returns about an image file value that was created. * @@ -138,11 +147,20 @@ class KnoraSipiIntegrationV2ITSpec /** * Represents the information that Knora returns about a text file value that was created. * - * @param internalFilename the files's internal filename. + * @param internalFilename the file's internal filename. * @param url the file's URL. */ case class SavedTextFile(internalFilename: String, url: String) + /** + * Represents the information that Knora returns about an audio file value that was created. + * + * @param internalFilename the file's internal filename. + * @param url the file's URL. + * @param duration the duration of the audio in seconds. + */ + case class SavedAudioFile(internalFilename: String, url: String, duration: Option[BigDecimal]) + /** * Given a JSON-LD document representing a resource, returns a JSON-LD array containing the values of the specified * property. @@ -265,6 +283,34 @@ class KnoraSipiIntegrationV2ITSpec ) } + /** + * Given a JSON-LD object representing a Knora text file value, returns a [[SavedTextFile]] containing the same information. + * + * @param savedValue a JSON-LD object representing a Knora document file value. + * @return a [[SavedTextFile]] containing the same information. + */ + private def savedValueToSavedAudioFile(savedValue: JsonLDObject): SavedAudioFile = { + val internalFilename = savedValue.requireString(OntologyConstants.KnoraApiV2Complex.FileValueHasFilename) + + val url: String = savedValue.requireDatatypeValueInObject( + key = OntologyConstants.KnoraApiV2Complex.FileValueAsUrl, + expectedDatatype = OntologyConstants.Xsd.Uri.toSmartIri, + validationFun = stringFormatter.toSparqlEncodedString + ) + + val duration: Option[BigDecimal] = savedValue.maybeDatatypeValueInObject( + key = OntologyConstants.KnoraApiV2Complex.AudioFileValueHasDuration, + expectedDatatype = OntologyConstants.Xsd.Decimal.toSmartIri, + validationFun = stringFormatter.validateBigDecimal + ) + + SavedAudioFile( + internalFilename = internalFilename, + url = url, + duration = duration + ) + } + "The Knora/Sipi integration" should { var loginToken: String = "" @@ -1035,5 +1081,129 @@ class KnoraSipiIntegrationV2ITSpec val sipiGetFileRequest = Get(savedDocument.url.replace("http://0.0.0.0:1024", baseInternalSipiUrl)) checkResponseOK(sipiGetFileRequest) } + + "create a resource with a WAV file" in { + // Upload the file to Sipi. + val sipiUploadResponse: SipiUploadResponse = uploadToSipi( + loginToken = loginToken, + filesToUpload = Seq(FileToUpload(path = pathToMinimalWav, mimeType = MediaTypes.`audio/wav`)) + ) + + val uploadedFile: SipiUploadResponseEntry = sipiUploadResponse.uploadedFiles.head + uploadedFile.originalFilename should ===(minimalWavOriginalFilename) + + // Ask Knora to create the resource. + + val jsonLdEntity = + s"""{ + | "@type" : "knora-api:AudioRepresentation", + | "knora-api:hasAudioFileValue" : { + | "@type" : "knora-api:AudioFileValue", + | "knora-api:fileValueHasFilename" : "${uploadedFile.internalFilename}" + | }, + | "knora-api:attachedToProject" : { + | "@id" : "http://rdfh.ch/projects/0001" + | }, + | "rdfs:label" : "test audio representation", + | "@context" : { + | "rdf" : "http://www.w3.org/1999/02/22-rdf-syntax-ns#", + | "knora-api" : "http://api.knora.org/ontology/knora-api/v2#", + | "rdfs" : "http://www.w3.org/2000/01/rdf-schema#", + | "xsd" : "http://www.w3.org/2001/XMLSchema#" + | } + |}""".stripMargin + + val request = Post(s"$baseApiUrl/v2/resources", HttpEntity(RdfMediaTypes.`application/ld+json`, jsonLdEntity)) ~> addCredentials( + BasicHttpCredentials(anythingUserEmail, password)) + val responseJsonDoc: JsonLDDocument = getResponseJsonLD(request) + wavResourceIri.set(responseJsonDoc.body.requireIDAsKnoraDataIri.toString) + + // Get the resource from Knora. + val knoraGetRequest = Get(s"$baseApiUrl/v2/resources/${URLEncoder.encode(wavResourceIri.get, "UTF-8")}") + val resource: JsonLDDocument = getResponseJsonLD(knoraGetRequest) + assert( + resource.requireTypeAsKnoraTypeIri.toString == "http://api.knora.org/ontology/knora-api/v2#AudioRepresentation") + + // Get the new file value from the resource. + + val savedValues: JsonLDArray = getValuesFromResource( + resource = resource, + propertyIriInResult = OntologyConstants.KnoraApiV2Complex.HasAudioFileValue.toSmartIri + ) + + val savedValue: JsonLDValue = if (savedValues.value.size == 1) { + savedValues.value.head + } else { + throw AssertionException(s"Expected one file value, got ${savedValues.value.size}") + } + + val savedValueObj: JsonLDObject = savedValue match { + case jsonLDObject: JsonLDObject => jsonLDObject + case other => throw AssertionException(s"Invalid value object: $other") + } + + wavValueIri.set(savedValueObj.requireIDAsKnoraDataIri.toString) + + val savedAudioFile: SavedAudioFile = savedValueToSavedAudioFile(savedValueObj) + assert(savedAudioFile.internalFilename == uploadedFile.internalFilename) + assert(savedAudioFile.duration.forall(_ == minimalWavDuration)) + + // Request the permanently stored file from Sipi. + val sipiGetFileRequest = Get(savedAudioFile.url.replace("http://0.0.0.0:1024", baseInternalSipiUrl)) + checkResponseOK(sipiGetFileRequest) + } + + "change a WAV file value" in { + // Upload the file to Sipi. + val sipiUploadResponse: SipiUploadResponse = uploadToSipi( + loginToken = loginToken, + filesToUpload = Seq(FileToUpload(path = pathToTestWav, mimeType = MediaTypes.`audio/wav`)) + ) + + val uploadedFile: SipiUploadResponseEntry = sipiUploadResponse.uploadedFiles.head + uploadedFile.originalFilename should ===(testWavOriginalFilename) + + // Ask Knora to update the value. + + val jsonLdEntity = + s"""{ + | "@id" : "${wavResourceIri.get}", + | "@type" : "knora-api:AudioRepresentation", + | "knora-api:hasAudioFileValue" : { + | "@type" : "knora-api:AudioFileValue", + | "@id" : "${wavValueIri.get}", + | "knora-api:fileValueHasFilename" : "${uploadedFile.internalFilename}" + | }, + | "@context" : { + | "rdf" : "http://www.w3.org/1999/02/22-rdf-syntax-ns#", + | "knora-api" : "http://api.knora.org/ontology/knora-api/v2#", + | "rdfs" : "http://www.w3.org/2000/01/rdf-schema#", + | "xsd" : "http://www.w3.org/2001/XMLSchema#" + | } + |}""".stripMargin + + val request = Put(s"$baseApiUrl/v2/values", HttpEntity(RdfMediaTypes.`application/ld+json`, jsonLdEntity)) ~> addCredentials( + BasicHttpCredentials(anythingUserEmail, password)) + val responseJsonDoc: JsonLDDocument = getResponseJsonLD(request) + wavValueIri.set(responseJsonDoc.body.requireIDAsKnoraDataIri.toString) + + // Get the resource from Knora. + val knoraGetRequest = Get(s"$baseApiUrl/v2/resources/${URLEncoder.encode(wavResourceIri.get, "UTF-8")}") + val resource = getResponseJsonLD(knoraGetRequest) + + // Get the new file value from the resource. + val savedValue: JsonLDObject = getValueFromResource( + resource = resource, + propertyIriInResult = OntologyConstants.KnoraApiV2Complex.HasAudioFileValue.toSmartIri, + expectedValueIri = wavValueIri.get + ) + + val savedAudioFile: SavedAudioFile = savedValueToSavedAudioFile(savedValue) + assert(savedAudioFile.internalFilename == uploadedFile.internalFilename) + + // Request the permanently stored file from Sipi. + val sipiGetFileRequest = Get(savedAudioFile.url.replace("http://0.0.0.0:1024", baseInternalSipiUrl)) + checkResponseOK(sipiGetFileRequest) + } } } diff --git a/webapi/src/test/scala/org/knora/webapi/store/iiif/MockSipiConnector.scala b/webapi/src/test/scala/org/knora/webapi/store/iiif/MockSipiConnector.scala index 77e34427a9..3315defd95 100644 --- a/webapi/src/test/scala/org/knora/webapi/store/iiif/MockSipiConnector.scala +++ b/webapi/src/test/scala/org/knora/webapi/store/iiif/MockSipiConnector.scala @@ -70,7 +70,8 @@ class MockSipiConnector extends Actor with ActorLogging { internalMimeType = "image/jp2", width = Some(512), height = Some(256), - pageCount = None + pageCount = None, + duration = None ) }