diff --git a/integration/src/test/scala/org/knora/webapi/E2EZSpec.scala b/integration/src/test/scala/org/knora/webapi/E2EZSpec.scala index 89062b423d..dd28dcc529 100644 --- a/integration/src/test/scala/org/knora/webapi/E2EZSpec.scala +++ b/integration/src/test/scala/org/knora/webapi/E2EZSpec.scala @@ -71,6 +71,9 @@ abstract class E2EZSpec extends ZIOSpecDefault with TestStartupUtils { result <- ZIO.fromEither(response.fromJson[B]) } yield result + def sendPostRequestAsRoot(url: String, data: String): ZIO[env, String, Response] = + getRootToken.flatMap(token => sendPostRequest(url, data, Some(token))) + def sendPostRequest(url: String, data: String, token: Option[String] = None): ZIO[env, String, Response] = for { client <- ZIO.service[Client] diff --git a/integration/src/test/scala/org/knora/webapi/e2e/v2/ValuesRouteV2E2ESpec.scala b/integration/src/test/scala/org/knora/webapi/e2e/v2/ValuesRouteV2E2ESpec.scala index 8caac18595..5094536d9a 100644 --- a/integration/src/test/scala/org/knora/webapi/e2e/v2/ValuesRouteV2E2ESpec.scala +++ b/integration/src/test/scala/org/knora/webapi/e2e/v2/ValuesRouteV2E2ESpec.scala @@ -686,6 +686,7 @@ class ValuesRouteV2E2ESpec extends E2ESpec { private val customValueIri: IRI = s"http://rdfh.ch/0001/a-thing/values/$customValueUUID" "The values v2 endpoint" should { + "get the latest versions of values, given their UUIDs" in { // The UUIDs of values in TestDing. val testDingValues: Map[String, String] = Map( diff --git a/integration/src/test/scala/org/knora/webapi/it/v2/CopyrightAndLicensesSpec.scala b/integration/src/test/scala/org/knora/webapi/it/v2/CopyrightAndLicensesSpec.scala new file mode 100644 index 0000000000..329911acec --- /dev/null +++ b/integration/src/test/scala/org/knora/webapi/it/v2/CopyrightAndLicensesSpec.scala @@ -0,0 +1,154 @@ +/* + * Copyright © 2021 - 2024 Swiss National Data and Service Center for the Humanities and/or DaSCH Service Platform contributors. + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.knora.webapi.it.v2 + +import org.apache.jena.rdf.model.Model +import org.apache.jena.rdf.model.Property +import org.apache.jena.rdf.model.Resource +import org.apache.jena.vocabulary.RDF +import zio.* +import zio.test.* + +import java.net.URLEncoder +import scala.jdk.CollectionConverters.IteratorHasAsScala +import scala.language.implicitConversions + +import org.knora.webapi.E2EZSpec +import org.knora.webapi.messages.OntologyConstants +import org.knora.webapi.messages.OntologyConstants.KnoraApiV2Complex.HasCopyrightAttribution +import org.knora.webapi.messages.OntologyConstants.KnoraApiV2Complex.HasLicense +import org.knora.webapi.messages.OntologyConstants.KnoraApiV2Complex.StillImageFileValue +import org.knora.webapi.models.filemodels.FileType +import org.knora.webapi.models.filemodels.UploadFileRequest +import org.knora.webapi.slice.admin.domain.model.KnoraProject.CopyrightAttribution +import org.knora.webapi.slice.admin.domain.model.KnoraProject.License +import org.knora.webapi.slice.common.KnoraIris.ValueIri +import org.knora.webapi.slice.common.jena.JenaConversions.given +import org.knora.webapi.slice.common.jena.ModelOps +import org.knora.webapi.slice.common.jena.ModelOps.* +import org.knora.webapi.slice.common.jena.ResourceOps.* +import org.knora.webapi.slice.resourceinfo.domain.IriConverter + +object CopyrightAndLicensesSpec extends E2EZSpec { + + private val copyrightAttribution = CopyrightAttribution.unsafeFrom("2020, Example") + private val license = License.unsafeFrom("CC BY-SA 4.0") + + val e2eSpec: Spec[Scope & env, Any] = suite("Copyright Attribution and Licenses")( + test( + "when creating a resource with copyright attribution and license " + + "the creation response should contain the license and copyright attribution", + ) { + for { + createResourceResponseModel <- createImageWithCopyrightAndLicense + actualCreatedCopyright <- copyrightValue(createResourceResponseModel) + actualCreatedLicense <- licenseValue(createResourceResponseModel) + } yield assertTrue( + actualCreatedCopyright == copyrightAttribution.value, + actualCreatedLicense == license.value, + ) + }, + test( + "when creating a resource with copyright attribution and license " + + "the response when getting the created resource should contain the license and copyright attribution", + ) { + for { + createResourceResponseModel <- createImageWithCopyrightAndLicense + resourceId <- resourceId(createResourceResponseModel) + getResponseModel <- getResourceFromApi(resourceId) + actualCopyright <- copyrightValue(getResponseModel) + actualLicense <- licenseValue(getResponseModel) + } yield assertTrue( + actualCopyright == copyrightAttribution.value, + actualLicense == license.value, + ) + }, + test( + "when creating a resource with copyright attribution and license " + + "the response when getting the created value should contain the license and copyright attribution", + ) { + for { + createResourceResponseModel <- createImageWithCopyrightAndLicense + resourceId <- resourceId(createResourceResponseModel) + valueId <- valueId(createResourceResponseModel) + valueResponseModel <- getValueFromApi(valueId, resourceId) + actualCopyright <- copyrightValue(valueResponseModel) + actualLicense <- licenseValue(valueResponseModel) + } yield assertTrue( + actualCopyright == copyrightAttribution.value, + actualLicense == license.value, + ) + }, + ) + + private def createImageWithCopyrightAndLicense: ZIO[env, Throwable, Model] = { + val jsonLd = UploadFileRequest + .make( + FileType.StillImageFile(), + "internalFilename", + copyrightAttribution = Some(copyrightAttribution), + license = Some(license), + ) + .toJsonLd( + className = Some("ThingPicture"), + ontologyName = "anything", + ) + + for { + responseBody <- sendPostRequestAsRoot("/v2/resources", jsonLd) + .filterOrFail(_.status.isSuccess)(s"Failed to create resource") + .mapError(Exception(_)) + .flatMap(_.body.asString) + createResourceResponseModel <- ModelOps.fromJsonLd(responseBody).mapError(Exception(_)) + } yield createResourceResponseModel + } + + private def getResourceFromApi(resourceId: String) = for { + responseBody <- sendGetRequest(s"/v2/resources/${URLEncoder.encode(resourceId, "UTF-8")}") + .filterOrFail(_.status.isSuccess)(s"Failed to get resource $resourceId") + .flatMap(_.body.asString) + model <- ModelOps.fromJsonLd(responseBody).mapError(Exception(_)) + } yield model + + private def getValueFromApi(valueIri: ValueIri, resourceIri: String) = for { + responseBody <- sendGetRequest(s"/v2/values/${URLEncoder.encode(resourceIri, "UTF-8")}/${valueIri.valueId}") + .filterOrFail(_.status.isSuccess)(s"Failed to get resource $valueId") + .flatMap(_.body.asString) + model <- ModelOps.fromJsonLd(responseBody).mapError(Exception(_)) + } yield model + + private def resourceId(model: Model): Task[String] = + ZIO + .fromEither( + for { + root <- model.singleRootResource + id <- root.uri.toRight("No URI found for root resource") + } yield id, + ) + .mapError(Exception(_)) + + private def valueId(model: Model): ZIO[IriConverter, Throwable, ValueIri] = { + val subs = model + .listSubjectsWithProperty(RDF.`type`) + .asScala + .filter(_.getProperty(RDF.`type`).getObject.asResource().hasURI(StillImageFileValue)) + .toList + subs match + case s :: Nil => + ZIO + .fromEither(s.uri.toRight("No URI found for value")) + .mapError(Exception(_)) + .flatMap(str => ZIO.serviceWithZIO[IriConverter](_.asSmartIri(str))) + .flatMap(iri => ZIO.fromEither(ValueIri.from(iri)).mapError(Exception(_))) + case Nil => ZIO.fail(Exception("No value found")) + case _ => ZIO.fail(Exception("Multiple values found")) + } + + private def copyrightValue(model: Model) = singleStringValue(model, HasCopyrightAttribution) + private def licenseValue(model: Model) = singleStringValue(model, HasLicense) + private def singleStringValue(model: Model, property: Property) = + ZIO.fromEither(model.singleSubjectWithProperty(property).flatMap(_.objectString(property))).mapError(Exception(_)) +} diff --git a/integration/src/test/scala/org/knora/webapi/models/filemodels/FileModelUtil.scala b/integration/src/test/scala/org/knora/webapi/models/filemodels/FileModelUtil.scala index b2ed066a31..a7bec11c00 100644 --- a/integration/src/test/scala/org/knora/webapi/models/filemodels/FileModelUtil.scala +++ b/integration/src/test/scala/org/knora/webapi/models/filemodels/FileModelUtil.scala @@ -11,6 +11,8 @@ import org.knora.webapi.messages.OntologyConstants import org.knora.webapi.messages.SmartIri import org.knora.webapi.messages.StringFormatter import org.knora.webapi.messages.v2.responder.valuemessages.* +import org.knora.webapi.slice.admin.domain.model.KnoraProject.CopyrightAttribution +import org.knora.webapi.slice.admin.domain.model.KnoraProject.License import org.knora.webapi.slice.resources.IiifImageRequestUrl object FileModelUtil { @@ -84,6 +86,8 @@ object FileModelUtil { originalFilename: Option[String], originalMimeType: Option[String], comment: Option[String], + copyrightAttribution: Option[CopyrightAttribution], + license: Option[License], ): FileValueContentV2 = fileType match { case FileType.DocumentFile(pageCount, dimX, dimY) => @@ -94,6 +98,8 @@ object FileModelUtil { internalMimeType = internalMimeType.getOrElse("application/pdf"), originalFilename = originalFilename, originalMimeType = Some(originalMimeType.getOrElse("application/pdf")), + copyrightAttribution, + license, ), pageCount = pageCount, dimX = dimX, @@ -108,6 +114,8 @@ object FileModelUtil { internalMimeType = internalMimeType.getOrElse("image/jp2"), originalFilename = originalFilename, originalMimeType = originalMimeType, + copyrightAttribution, + license, ), dimX = dimX, dimY = dimY, @@ -121,6 +129,8 @@ object FileModelUtil { internalMimeType = internalMimeType.getOrElse("image/jp2"), originalFilename = originalFilename, originalMimeType = originalMimeType, + copyrightAttribution, + license, ), externalUrl = externalUrl, comment = comment, @@ -133,6 +143,8 @@ object FileModelUtil { internalMimeType = internalMimeType.get, originalFilename = originalFilename, originalMimeType = internalMimeType, + copyrightAttribution, + license, ), ) case FileType.TextFile => @@ -143,6 +155,8 @@ object FileModelUtil { internalMimeType = internalMimeType.get, originalFilename = originalFilename, originalMimeType = internalMimeType, + copyrightAttribution, + license, ), ) case FileType.AudioFile => @@ -153,6 +167,8 @@ object FileModelUtil { internalMimeType = internalMimeType.get, originalFilename = originalFilename, originalMimeType = internalMimeType, + copyrightAttribution, + license, ), ) case FileType.ArchiveFile => @@ -163,6 +179,8 @@ object FileModelUtil { internalMimeType = internalMimeType.getOrElse("application/zip"), originalFilename = originalFilename, originalMimeType = internalMimeType, + copyrightAttribution, + license, ), comment = comment, ) diff --git a/integration/src/test/scala/org/knora/webapi/models/filemodels/FileModels.scala b/integration/src/test/scala/org/knora/webapi/models/filemodels/FileModels.scala index 95a24683e4..60ac0e42bc 100644 --- a/integration/src/test/scala/org/knora/webapi/models/filemodels/FileModels.scala +++ b/integration/src/test/scala/org/knora/webapi/models/filemodels/FileModels.scala @@ -15,20 +15,24 @@ import org.knora.webapi.messages.v2.responder.resourcemessages.CreateResourceV2 import org.knora.webapi.messages.v2.responder.resourcemessages.CreateValueInNewResourceV2 import org.knora.webapi.sharedtestdata.SharedTestDataADM import org.knora.webapi.slice.admin.api.model.Project +import org.knora.webapi.slice.admin.domain.model.KnoraProject.CopyrightAttribution +import org.knora.webapi.slice.admin.domain.model.KnoraProject.License sealed abstract case class UploadFileRequest private ( fileType: FileType, internalFilename: String, label: String, resourceIRI: Option[String] = None, + copyrightAttribution: Option[CopyrightAttribution] = None, + license: Option[License] = None, ) { /** * Create a JSON-LD serialization of the request. This can be used for e2e and integration tests. * - * @param className the class name of the resource. Optional. + * @param shortcode the project's shortcode. Optional. * @param ontologyName the name of the ontology to be prefixed to the class name. Defaults to `"knora-api"` - * @param uuid the uuid of the project to which the resource should be added. Defaults to `"0001"` + * @param className the class name of the resource. Optional. * @param ontologyIRI IRI of the ontology, to which the prefix should resolve. Optional. * @return JSON-LD serialization of the request. */ @@ -55,6 +59,8 @@ sealed abstract case class UploadFileRequest private ( | "$fileValuePropertyName" : { | "@type" : "$fileValueType", | "knora-api:fileValueHasFilename" : "$internalFilename" + | ${copyrightAttribution.map(ca => s""","knora-api:hasCopyrightAttribution" : "${ca.value}"""").getOrElse("")} + | ${license.map(l => s""","knora-api:hasLicense" : "${l.value}"""").getOrElse("")} | }, | "knora-api:attachedToProject" : { | "@id" : "http://rdfh.ch/projects/$shortcode" @@ -104,6 +110,8 @@ sealed abstract case class UploadFileRequest private ( resourceClassIRI: Option[SmartIri] = None, valuePropertyIRI: Option[SmartIri] = None, project: Option[Project] = None, + copyrightAttribution: Option[CopyrightAttribution] = None, + license: Option[License] = None, ): CreateResourceV2 = { implicit val stringFormatter: StringFormatter = StringFormatter.getGeneralInstance @@ -122,6 +130,8 @@ sealed abstract case class UploadFileRequest private ( originalFilename = originalFilename, originalMimeType = originalMimeType, comment = comment, + copyrightAttribution, + license, ) val values = List( @@ -172,13 +182,10 @@ object UploadFileRequest { internalFilename: String, label: String = "test label", resourceIRI: Option[String] = None, + copyrightAttribution: Option[CopyrightAttribution] = None, + license: Option[License] = None, ): UploadFileRequest = - new UploadFileRequest( - fileType = fileType, - internalFilename = internalFilename, - label = label, - resourceIRI = resourceIRI, - ) {} + new UploadFileRequest(fileType, internalFilename, label, resourceIRI, copyrightAttribution, license) {} } sealed abstract case class ChangeFileRequest private ( diff --git a/integration/src/test/scala/org/knora/webapi/models/filemodels/FileModelsSpec.scala b/integration/src/test/scala/org/knora/webapi/models/filemodels/FileModelsSpec.scala index a3424eeda3..d64775f558 100644 --- a/integration/src/test/scala/org/knora/webapi/models/filemodels/FileModelsSpec.scala +++ b/integration/src/test/scala/org/knora/webapi/models/filemodels/FileModelsSpec.scala @@ -310,6 +310,8 @@ class FileModelsSpec extends CoreSpec { internalMimeType = "application/pdf", originalFilename = None, originalMimeType = Some("application/pdf"), + None, + None, ), pageCount = Some(1), dimX = Some(100), @@ -388,6 +390,8 @@ class FileModelsSpec extends CoreSpec { internalMimeType = internalMimetype.get, originalFilename = originalFilename, originalMimeType = originalMimeType, + None, + None, ), pageCount = pageCount, dimX = dimX, diff --git a/integration/src/test/scala/org/knora/webapi/responders/v2/ValuesResponderV2Spec.scala b/integration/src/test/scala/org/knora/webapi/responders/v2/ValuesResponderV2Spec.scala index ace062703e..555c976c28 100644 --- a/integration/src/test/scala/org/knora/webapi/responders/v2/ValuesResponderV2Spec.scala +++ b/integration/src/test/scala/org/knora/webapi/responders/v2/ValuesResponderV2Spec.scala @@ -407,6 +407,7 @@ class ValuesResponderV2Spec extends CoreSpec with ImplicitSender { } "The values responder" should { + "create an integer value" in { // Add the value. @@ -4320,6 +4321,8 @@ class ValuesResponderV2Spec extends CoreSpec with ImplicitSender { Some("test.tiff"), Some(mimeTypeTIFF), None, + None, + None, ), ), anythingUser1, @@ -4369,6 +4372,8 @@ class ValuesResponderV2Spec extends CoreSpec with ImplicitSender { originalFilename, originalMimeType, None, + None, + None, ), ), anythingUser1, @@ -4418,6 +4423,8 @@ class ValuesResponderV2Spec extends CoreSpec with ImplicitSender { internalMimeType = mimeTypeJP2, originalFilename = Some("test.tiff"), originalMimeType = Some(mimeTypeTIFF), + None, + None, ), dimX = 512, dimY = 256, @@ -4453,6 +4460,8 @@ class ValuesResponderV2Spec extends CoreSpec with ImplicitSender { internalMimeType = mimeTypeJP2, originalFilename = Some("test.tiff"), originalMimeType = Some(mimeTypeTIFF), + None, + None, ), dimX = 512, dimY = 256, 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 a93f3b63d5..29a7057052 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 @@ -48,6 +48,8 @@ import org.knora.webapi.messages.v2.responder.standoffmessages.GetXSLTransformat import org.knora.webapi.messages.v2.responder.standoffmessages.GetXSLTransformationResponseV2 import org.knora.webapi.messages.v2.responder.standoffmessages.MappingXMLtoStandoff import org.knora.webapi.messages.v2.responder.valuemessages.* +import org.knora.webapi.slice.admin.domain.model.KnoraProject.CopyrightAttribution +import org.knora.webapi.slice.admin.domain.model.KnoraProject.License import org.knora.webapi.slice.admin.domain.model.KnoraProject.ProjectIri import org.knora.webapi.slice.admin.domain.model.ListProperties.ListIri import org.knora.webapi.slice.admin.domain.model.Permission @@ -1084,6 +1086,10 @@ final case class ConstructResponseUtilV2Live( internalFilename = valueObject.requireStringObject(OntologyConstants.KnoraBase.InternalFilename.toSmartIri), originalFilename = valueObject.maybeStringObject(OntologyConstants.KnoraBase.OriginalFilename.toSmartIri), originalMimeType = valueObject.maybeStringObject(OntologyConstants.KnoraBase.OriginalMimeType.toSmartIri), + copyrightAttribution = valueObject + .maybeStringObject(OntologyConstants.KnoraBase.HasCopyrightAttribution.toSmartIri) + .map(CopyrightAttribution.unsafeFrom), + license = valueObject.maybeStringObject(OntologyConstants.KnoraBase.HasLicense.toSmartIri).map(License.unsafeFrom), ) valueType match { 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 96a489e46a..0cdb205ee9 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 @@ -44,8 +44,11 @@ import org.knora.webapi.routing.v2.AssetIngestState import org.knora.webapi.routing.v2.AssetIngestState.* import org.knora.webapi.slice.admin.api.model.MaintenanceRequests.AssetId import org.knora.webapi.slice.admin.api.model.Project +import org.knora.webapi.slice.admin.domain.model.KnoraProject.CopyrightAttribution +import org.knora.webapi.slice.admin.domain.model.KnoraProject.License import org.knora.webapi.slice.admin.domain.model.KnoraProject.Shortcode import org.knora.webapi.slice.admin.domain.model.Permission +import org.knora.webapi.slice.common.Value.StringValue import org.knora.webapi.slice.common.jena.JenaConversions.given import org.knora.webapi.slice.common.jena.ResourceOps.* import org.knora.webapi.slice.resourceinfo.domain.InternalIri @@ -2027,6 +2030,8 @@ case class FileValueV2( internalMimeType: String, originalFilename: Option[String], originalMimeType: Option[String], + copyrightAttribution: Option[CopyrightAttribution], + license: Option[License], ) /** @@ -2048,13 +2053,19 @@ sealed trait FileValueContentV2 extends ValueContentV2 { ) } - def toJsonLDObjectMapInComplexSchema(fileUrl: String): Map[IRI, JsonLDValue] = Map( - FileValueHasFilename -> JsonLDString(fileValue.internalFilename), - FileValueAsUrl -> JsonLDUtil.datatypeValueToJsonLDObject( - value = fileUrl, - datatype = OntologyConstants.Xsd.Uri.toSmartIri, - ), - ) + def toJsonLDObjectMapInComplexSchema(fileUrl: String): Map[IRI, JsonLDValue] = { + def mkJsonLdString: StringValue => JsonLDString = sv => JsonLDString(sv.value) + val knownValues: Map[IRI, JsonLDValue] = Map( + FileValueHasFilename -> JsonLDString(fileValue.internalFilename), + FileValueAsUrl -> JsonLDUtil.datatypeValueToJsonLDObject( + value = fileUrl, + datatype = OntologyConstants.Xsd.Uri.toSmartIri, + ), + ) + val copyrightOption = fileValue.copyrightAttribution.map(mkJsonLdString).map((HasCopyrightAttribution, _)) + val licenseOption = fileValue.license.map(mkJsonLdString).map((HasLicense, _)) + knownValues ++ copyrightOption ++ licenseOption + } } /** @@ -2139,9 +2150,18 @@ case class StillImageFileValueContentV2( */ object StillImageFileValueContentV2 { def from(r: Resource, fileInfo: FileInfo): Either[String, StillImageFileValueContentV2] = for { - comment <- objectCommentOption(r) - meta = fileInfo.metadata - fileValue = FileValueV2(fileInfo.filename, meta.internalMimeType, meta.originalFilename, meta.originalMimeType) + comment <- objectCommentOption(r) + meta = fileInfo.metadata + copyrightAttribution <- getCopyrightAttribution(r) + license <- getLicense(r) + fileValue = FileValueV2( + fileInfo.filename, + meta.internalMimeType, + meta.originalFilename, + meta.originalMimeType, + copyrightAttribution, + license, + ) } yield StillImageFileValueContentV2( ApiV2Complex, fileValue, @@ -2151,6 +2171,22 @@ object StillImageFileValueContentV2 { ) } +def getCopyrightAttribution(resource: Resource): Either[String, Option[CopyrightAttribution]] = for { + str <- resource.objectStringOption(HasCopyrightAttribution) + copyrightAttribution <- str match { + case Some(str) => CopyrightAttribution.from(str).map(Some(_)) + case None => Right(None) + } +} yield copyrightAttribution + +def getLicense(resource: Resource): Either[String, Option[License]] = for { + str <- resource.objectStringOption(HasLicense) + copyrightAttribution <- str match { + case Some(str) => License.from(str).map(Some(_)) + case None => Right(None) + } +} yield copyrightAttribution + /** * Represents the external image file metadata. * @@ -2228,10 +2264,19 @@ case class StillImageExternalFileValueContentV2( */ object StillImageExternalFileValueContentV2 { def from(r: Resource): Either[String, StillImageExternalFileValueContentV2] = for { - externalUrlStr <- r.objectString(StillImageFileValueHasExternalUrl) - iifUrl <- IiifImageRequestUrl.from(externalUrlStr) - comment <- objectCommentOption(r) - fileValue = FileValueV2("internalFilename", "internalMimeType", Some("originalFilename"), Some("originalMimeType")) + externalUrlStr <- r.objectString(StillImageFileValueHasExternalUrl) + iifUrl <- IiifImageRequestUrl.from(externalUrlStr) + comment <- objectCommentOption(r) + copyrightAttribution <- getCopyrightAttribution(r) + license <- getLicense(r) + fileValue = FileValueV2( + "internalFilename", + "internalMimeType", + Some("originalFilename"), + Some("originalMimeType"), + copyrightAttribution, + license, + ) } yield StillImageExternalFileValueContentV2(ApiV2Complex, fileValue, iifUrl, comment) } @@ -2369,9 +2414,18 @@ case class ArchiveFileValueContentV2( */ object DocumentFileValueContentV2 { def from(r: Resource, info: FileInfo): Either[String, DocumentFileValueContentV2] = for { - comment <- objectCommentOption(r) - meta = info.metadata - fileValue = FileValueV2(info.filename, meta.internalMimeType, meta.originalFilename, meta.originalMimeType) + comment <- objectCommentOption(r) + meta = info.metadata + copyrightAttribution <- getCopyrightAttribution(r) + license <- getLicense(r) + fileValue = FileValueV2( + info.filename, + meta.internalMimeType, + meta.originalFilename, + meta.originalMimeType, + copyrightAttribution, + license, + ) } yield DocumentFileValueContentV2(ApiV2Complex, fileValue, meta.numpages, meta.width, meta.height, comment) } @@ -2380,9 +2434,18 @@ object DocumentFileValueContentV2 { */ object ArchiveFileValueContentV2 { def from(r: Resource, info: FileInfo): Either[String, ArchiveFileValueContentV2] = for { - comment <- objectCommentOption(r) - meta = info.metadata - fileValue = FileValueV2(info.filename, meta.internalMimeType, meta.originalFilename, meta.originalMimeType) + comment <- objectCommentOption(r) + meta = info.metadata + copyrightAttribution <- getCopyrightAttribution(r) + license <- getLicense(r) + fileValue = FileValueV2( + info.filename, + meta.internalMimeType, + meta.originalFilename, + meta.originalMimeType, + copyrightAttribution, + license, + ) } yield ArchiveFileValueContentV2(ApiV2Complex, fileValue, comment) } @@ -2450,9 +2513,18 @@ case class TextFileValueContentV2( */ object TextFileValueContentV2 { def from(r: Resource, info: FileInfo): Either[String, TextFileValueContentV2] = for { - comment <- objectCommentOption(r) - meta = info.metadata - fileValue = FileValueV2(info.filename, meta.internalMimeType, meta.originalFilename, meta.originalMimeType) + comment <- objectCommentOption(r) + meta = info.metadata + copyrightAttribution <- getCopyrightAttribution(r) + license <- getLicense(r) + fileValue = FileValueV2( + info.filename, + meta.internalMimeType, + meta.originalFilename, + meta.originalMimeType, + copyrightAttribution, + license, + ) } yield TextFileValueContentV2(ApiV2Complex, fileValue, comment) } @@ -2520,11 +2592,20 @@ case class AudioFileValueContentV2( */ object AudioFileValueContentV2 { def from(r: Resource, info: FileInfo): Either[String, AudioFileValueContentV2] = for { - comment <- objectCommentOption(r) - meta = info.metadata + comment <- objectCommentOption(r) + meta = info.metadata + copyrightAttribution <- getCopyrightAttribution(r) + license <- getLicense(r) } yield AudioFileValueContentV2( ApiV2Complex, - FileValueV2(info.filename, meta.internalMimeType, meta.originalFilename, meta.originalMimeType), + FileValueV2( + info.filename, + meta.internalMimeType, + meta.originalFilename, + meta.originalMimeType, + copyrightAttribution, + license, + ), comment, ) } @@ -2595,11 +2676,20 @@ case class MovingImageFileValueContentV2( */ object MovingImageFileValueContentV2 { def from(r: Resource, info: FileInfo): Either[String, MovingImageFileValueContentV2] = for { - comment <- objectCommentOption(r) - meta = info.metadata + comment <- objectCommentOption(r) + meta = info.metadata + copyrightAttribution <- getCopyrightAttribution(r) + license <- getLicense(r) } yield MovingImageFileValueContentV2( ApiV2Complex, - FileValueV2(info.filename, meta.internalMimeType, meta.originalFilename, meta.originalMimeType), + FileValueV2( + info.filename, + meta.internalMimeType, + meta.originalFilename, + meta.originalMimeType, + copyrightAttribution, + license, + ), comment, ) } diff --git a/webapi/src/main/scala/org/knora/webapi/responders/v2/ValuesResponderV2.scala b/webapi/src/main/scala/org/knora/webapi/responders/v2/ValuesResponderV2.scala index e2895a263b..2911e3955f 100644 --- a/webapi/src/main/scala/org/knora/webapi/responders/v2/ValuesResponderV2.scala +++ b/webapi/src/main/scala/org/knora/webapi/responders/v2/ValuesResponderV2.scala @@ -404,7 +404,7 @@ final case class ValuesResponderV2( /** * Creates an ordinary value (i.e. not a link), using an existing transaction, assuming that pre-update checks have already been done. * - * @param resourceInfo information about the the resource in which to create the value. + * @param resourceInfo information about the resource in which to create the value. * @param propertyIri the property that should point to the value. * @param value an [[ValueContentV2]] describing the value. * @param maybeValueIri the optional custom IRI supplied for the value. diff --git a/webapi/src/main/scala/org/knora/webapi/responders/v2/resources/CreateResourceV2Handler.scala b/webapi/src/main/scala/org/knora/webapi/responders/v2/resources/CreateResourceV2Handler.scala index 66f509f084..5d9401c97e 100644 --- a/webapi/src/main/scala/org/knora/webapi/responders/v2/resources/CreateResourceV2Handler.scala +++ b/webapi/src/main/scala/org/knora/webapi/responders/v2/resources/CreateResourceV2Handler.scala @@ -501,6 +501,8 @@ final case class CreateResourceV2Handler( originalMimeType = fileValue.originalMimeType, dimX = dimX, dimY = dimY, + fileValue.copyrightAttribution, + fileValue.license, ), ) case StillImageExternalFileValueContentV2(_, fileValue, externalUrl, _) => @@ -511,6 +513,8 @@ final case class CreateResourceV2Handler( originalFilename = fileValue.originalFilename, originalMimeType = fileValue.originalMimeType, externalUrl = externalUrl.value.toString(), + fileValue.copyrightAttribution, + fileValue.license, ), ) case DocumentFileValueContentV2(_, fileValue, pageCount, dimX, dimY, _) => @@ -523,6 +527,8 @@ final case class CreateResourceV2Handler( dimX = dimX, dimY = dimY, pageCount = pageCount, + fileValue.copyrightAttribution, + fileValue.license, ), ) case ArchiveFileValueContentV2(_, fileValue, _) => @@ -532,6 +538,8 @@ final case class CreateResourceV2Handler( internalMimeType = fileValue.internalMimeType, originalFilename = fileValue.originalFilename, originalMimeType = fileValue.originalMimeType, + fileValue.copyrightAttribution, + fileValue.license, ), ) case TextFileValueContentV2(_, fileValue, _) => @@ -541,6 +549,8 @@ final case class CreateResourceV2Handler( internalMimeType = fileValue.internalMimeType, originalFilename = fileValue.originalFilename, originalMimeType = fileValue.originalMimeType, + fileValue.copyrightAttribution, + fileValue.license, ), ) case AudioFileValueContentV2(_, fileValue, _) => @@ -550,6 +560,8 @@ final case class CreateResourceV2Handler( internalMimeType = fileValue.internalMimeType, originalFilename = fileValue.originalFilename, originalMimeType = fileValue.originalMimeType, + fileValue.copyrightAttribution, + fileValue.license, ), ) case MovingImageFileValueContentV2(_, fileValue, _) => @@ -559,6 +571,8 @@ final case class CreateResourceV2Handler( internalMimeType = fileValue.internalMimeType, originalFilename = fileValue.originalFilename, originalMimeType = fileValue.originalMimeType, + fileValue.copyrightAttribution, + fileValue.license, ), ) case LinkValueContentV2( diff --git a/webapi/src/main/scala/org/knora/webapi/slice/common/jena/ModelOps.scala b/webapi/src/main/scala/org/knora/webapi/slice/common/jena/ModelOps.scala index ccc2b86aef..dded0c525e 100644 --- a/webapi/src/main/scala/org/knora/webapi/slice/common/jena/ModelOps.scala +++ b/webapi/src/main/scala/org/knora/webapi/slice/common/jena/ModelOps.scala @@ -8,7 +8,9 @@ package org.knora.webapi.slice.common.jena import org.apache.jena.rdf.model.* import org.apache.jena.riot.Lang import org.apache.jena.riot.RDFDataMgr +import zio.Console import zio.Scope +import zio.Task import zio.UIO import zio.ZIO @@ -20,7 +22,14 @@ object ModelOps { self => extension (model: Model) { def printTurtle: UIO[Unit] = - ZIO.attempt(RDFDataMgr.write(java.lang.System.out, model, Lang.TURTLE)).logError.ignore + asTurtle.flatMap(Console.printLine(_)).logError.ignore + + def asTurtle: Task[String] = + ZIO.attempt { + val out = new java.io.ByteArrayOutputStream() + RDFDataMgr.write(out, model, Lang.TURTLE) + out.toString(java.nio.charset.StandardCharsets.UTF_8) + } def resourceOption(uri: String): Option[Resource] = Option(model.getResource(uri)) def resource(uri: String): Either[String, Resource] = @@ -38,6 +47,15 @@ object ModelOps { self => case iris if iris.isEmpty => Left("No root resource found in model") case iris => Left(s"Multiple root resources found in model: ${iris.mkString(", ")}") } + + def singleSubjectWithProperty(property: Property): Either[String, Resource] = + val subjects = model.listSubjectsWithProperty(property).asScala.toList + subjects match { + case s :: Nil => Right(s) + case Nil => Left(s"No resource found with property ${property.getURI}") + case _ => Left(s"Multiple resources found with property ${property.getURI}") + } + } def fromJsonLd(str: String): ZIO[Scope, String, Model] = from(str, Lang.JSONLD) diff --git a/webapi/src/main/scala/org/knora/webapi/slice/resources/repo/model/ResourceCreateModels.scala b/webapi/src/main/scala/org/knora/webapi/slice/resources/repo/model/ResourceCreateModels.scala index 0889dc44dd..cbaddca6e5 100644 --- a/webapi/src/main/scala/org/knora/webapi/slice/resources/repo/model/ResourceCreateModels.scala +++ b/webapi/src/main/scala/org/knora/webapi/slice/resources/repo/model/ResourceCreateModels.scala @@ -10,6 +10,8 @@ import java.util.UUID import org.knora.webapi.messages.util.CalendarNameV2 import org.knora.webapi.messages.util.DatePrecisionV2 +import org.knora.webapi.slice.admin.domain.model.KnoraProject.CopyrightAttribution +import org.knora.webapi.slice.admin.domain.model.KnoraProject.License import org.knora.webapi.slice.resourceinfo.domain.InternalIri final case class ResourceReadyToCreate( @@ -42,6 +44,15 @@ enum FormattedTextValueType { case CustomMapping(mappingIri: InternalIri) } +sealed trait FileValueTypeSpecificInfo { + def internalFilename: String + def internalMimeType: String + def originalFilename: Option[String] + def originalMimeType: Option[String] + def copyrightAttribution: Option[CopyrightAttribution] + def license: Option[License] +} + enum TypeSpecificValueInfo { case LinkValueInfo(referredResourceIri: InternalIri) case UnformattedTextValueInfo(valueHasLanguage: Option[String]) @@ -72,14 +83,18 @@ enum TypeSpecificValueInfo { originalMimeType: Option[String], dimX: Int, dimY: Int, - ) + copyrightAttribution: Option[CopyrightAttribution], + license: Option[License], + ) extends TypeSpecificValueInfo with FileValueTypeSpecificInfo case StillImageExternalFileValueInfo( internalFilename: String, internalMimeType: String, originalFilename: Option[String], originalMimeType: Option[String], externalUrl: String, - ) + copyrightAttribution: Option[CopyrightAttribution], + license: Option[License], + ) extends TypeSpecificValueInfo with FileValueTypeSpecificInfo case DocumentFileValueInfo( internalFilename: String, internalMimeType: String, @@ -88,13 +103,17 @@ enum TypeSpecificValueInfo { dimX: Option[Int], dimY: Option[Int], pageCount: Option[Int], - ) + copyrightAttribution: Option[CopyrightAttribution], + license: Option[License], + ) extends TypeSpecificValueInfo with FileValueTypeSpecificInfo case OtherFileValueInfo( internalFilename: String, internalMimeType: String, originalFilename: Option[String], originalMimeType: Option[String], - ) + copyrightAttribution: Option[CopyrightAttribution], + license: Option[License], + ) extends TypeSpecificValueInfo with FileValueTypeSpecificInfo case HierarchicalListValueInfo(valueHasListNode: InternalIri) case IntervalValueInfo(valueHasIntervalStart: BigDecimal, valueHasIntervalEnd: BigDecimal) case TimeValueInfo(valueHasTimeStamp: Instant) diff --git a/webapi/src/main/scala/org/knora/webapi/slice/resources/repo/service/ResourcesRepoLive.scala b/webapi/src/main/scala/org/knora/webapi/slice/resources/repo/service/ResourcesRepoLive.scala index f19b099171..7a0dc88f14 100644 --- a/webapi/src/main/scala/org/knora/webapi/slice/resources/repo/service/ResourcesRepoLive.scala +++ b/webapi/src/main/scala/org/knora/webapi/slice/resources/repo/service/ResourcesRepoLive.scala @@ -27,6 +27,7 @@ import java.time.Instant import dsp.valueobjects.UuidUtil import org.knora.webapi.slice.common.repo.rdf.Vocabulary.KnoraBase as KB import org.knora.webapi.slice.resourceinfo.domain.InternalIri +import org.knora.webapi.slice.resources.repo.model.FileValueTypeSpecificInfo import org.knora.webapi.slice.resources.repo.model.FormattedTextValueType import org.knora.webapi.slice.resources.repo.model.ResourceReadyToCreate import org.knora.webapi.slice.resources.repo.model.StandoffAttribute @@ -194,14 +195,8 @@ object ResourcesRepoLive { List(iri(valueIri).has(KB.valueHasColor, literalOf(valueHasColor))) case GeomValueInfo(valueHasGeometry) => List(iri(valueIri).has(KB.valueHasGeometry, literalOf(valueHasGeometry))) - case v: StillImageFileValueInfo => - buildStillImageFileValuePattern(v, valueIri) - case v: StillImageExternalFileValueInfo => - buildStillImageExternalFileValuePattern(v, valueIri) - case v: DocumentFileValueInfo => - buildDocumentFileValuePattern(v, valueIri) - case v: OtherFileValueInfo => - buildOtherFileValuePattern(v, valueIri) + case v: FileValueTypeSpecificInfo => + List(buildFileValuePattern(v, valueIri)) case HierarchicalListValueInfo(valueHasListNode) => List(iri(valueIri).has(KB.valueHasListNode, iri(valueHasListNode.value))) case IntervalValueInfo(valueHasIntervalStart, valueHasIntervalEnd) => @@ -280,51 +275,26 @@ object ResourcesRepoLive { .andHas(KB.valueHasCalendar, literalOf(v.valueHasCalendar.toString())), ) - private def buildStillImageFileValuePattern(v: StillImageFileValueInfo, valueIri: String): List[TriplePattern] = - List( - iri(valueIri) - .has(KB.internalFilename, literalOf(v.internalFilename)) - .andHas(KB.internalMimeType, literalOf(v.internalMimeType)) - .andHas(KB.dimX, literalOf(v.dimX)) - .andHas(KB.dimY, literalOf(v.dimY)) - .andHasOptional(KB.originalFilename, v.originalFilename.map(literalOf)) - .andHasOptional(KB.originalMimeType, v.originalMimeType.map(literalOf)), - ) - - private def buildStillImageExternalFileValuePattern( - v: StillImageExternalFileValueInfo, - valueIri: String, - ): List[TriplePattern] = - List( - iri(valueIri) - .has(KB.internalFilename, literalOf(v.internalFilename)) - .andHas(KB.internalMimeType, literalOf(v.internalMimeType)) - .andHas(KB.externalUrl, literalOf(v.externalUrl)) - .andHasOptional(KB.originalFilename, v.originalFilename.map(literalOf)) - .andHasOptional(KB.originalMimeType, v.originalMimeType.map(literalOf)), - ) - - private def buildDocumentFileValuePattern(v: DocumentFileValueInfo, valueIri: String): List[TriplePattern] = - List( - iri(valueIri) - .has(KB.internalFilename, literalOf(v.internalFilename)) - .andHas(KB.internalMimeType, literalOf(v.internalMimeType)) - .andHasOptional(KB.originalFilename, v.originalFilename.map(literalOf)) - .andHasOptional(KB.originalMimeType, v.originalMimeType.map(literalOf)) - .andHasOptional(KB.dimX, v.dimX.map(i => literalOf(i))) - .andHasOptional(KB.dimY, v.dimY.map(i => literalOf(i))) - .andHasOptional(KB.pageCount, v.pageCount.map(i => literalOf(i))), - ) - - private def buildOtherFileValuePattern(v: OtherFileValueInfo, valueIri: String): List[TriplePattern] = - List( - iri(valueIri) - .has(KB.internalFilename, literalOf(v.internalFilename)) - .andHas(KB.internalMimeType, literalOf(v.internalMimeType)) - .andHasOptional(KB.originalFilename, v.originalFilename.map(literalOf)) - .andHasOptional(KB.originalMimeType, v.originalMimeType.map(literalOf)), - ) + private def buildFileValuePattern(v: FileValueTypeSpecificInfo, valueIri: String): TriplePattern = { + val result = iri(valueIri) + .has(KB.internalFilename, literalOf(v.internalFilename)) + .andHas(KB.internalMimeType, literalOf(v.internalMimeType)) + .andHasOptional(KB.originalFilename, v.originalFilename.map(literalOf)) + .andHasOptional(KB.originalMimeType, v.originalMimeType.map(literalOf)) + .andHasOptional(KB.hasCopyrightAttribution, v.copyrightAttribution.map(_.value).map(literalOf)) + .andHasOptional(KB.hasLicense, v.license.map(_.value).map(literalOf)) + v match { + case _: OtherFileValueInfo => result + case v: StillImageFileValueInfo => result.andHas(KB.dimX, literalOf(v.dimX)).andHas(KB.dimY, literalOf(v.dimY)) + case v: StillImageExternalFileValueInfo => result.andHas(KB.externalUrl, literalOf(v.externalUrl)) + case v: DocumentFileValueInfo => + result + .andHasOptional(KB.dimX, v.dimX.map(i => literalOf(i))) + .andHasOptional(KB.dimY, v.dimY.map(i => literalOf(i))) + .andHasOptional(KB.pageCount, v.pageCount.map(i => literalOf(i))) + } + } } } diff --git a/webapi/src/main/twirl/org/knora/webapi/messages/twirl/queries/sparql/v2/addValueVersion.scala.txt b/webapi/src/main/twirl/org/knora/webapi/messages/twirl/queries/sparql/v2/addValueVersion.scala.txt index 89ccdb0a92..b1ccfef3f9 100644 --- a/webapi/src/main/twirl/org/knora/webapi/messages/twirl/queries/sparql/v2/addValueVersion.scala.txt +++ b/webapi/src/main/twirl/org/knora/webapi/messages/twirl/queries/sparql/v2/addValueVersion.scala.txt @@ -234,6 +234,20 @@ DELETE { case None => {} } + @fileValueContentV2.fileValue.copyrightAttribution match { + case Some(copyrightAttribution) => { + <@newValueIri> knora-base:hasCopyrightAttribution """@copyrightAttribution.value""" . + } + case None => {} + } + + @fileValueContentV2.fileValue.license match { + case Some(license) => { + <@newValueIri> knora-base:hasLicense """@license.value""" . + } + case None => {} + } + @fileValueContentV2 match { case stillImageFileValue: StillImageFileValueContentV2 => { <@newValueIri> knora-base:dimX @stillImageFileValue.dimX ; diff --git a/webapi/src/main/twirl/org/knora/webapi/messages/twirl/queries/sparql/v2/createValue.scala.txt b/webapi/src/main/twirl/org/knora/webapi/messages/twirl/queries/sparql/v2/createValue.scala.txt index d0de49dcac..fa8a7b9486 100644 --- a/webapi/src/main/twirl/org/knora/webapi/messages/twirl/queries/sparql/v2/createValue.scala.txt +++ b/webapi/src/main/twirl/org/knora/webapi/messages/twirl/queries/sparql/v2/createValue.scala.txt @@ -228,6 +228,20 @@ DELETE { case None => {} } + @fileValueContentV2.fileValue.copyrightAttribution match { + case Some(copyrightAttribution) => { + <@newValueIri> knora-base:hasCopyrightAttribution """@copyrightAttribution.value""" . + } + case None => {} + } + + @fileValueContentV2.fileValue.license match { + case Some(license) => { + <@newValueIri> knora-base:hasLicense """@license.value""" . + } + case None => {} + } + @fileValueContentV2 match { case stillImageFileValue: StillImageFileValueContentV2 => { <@newValueIri> knora-base:dimX @stillImageFileValue.dimX ; diff --git a/webapi/src/test/scala/org/knora/webapi/slice/common/ApiComplexV2JsonLdRequestParserSpec.scala b/webapi/src/test/scala/org/knora/webapi/slice/common/ApiComplexV2JsonLdRequestParserSpec.scala index af0f7d98ed..57dc9bf502 100644 --- a/webapi/src/test/scala/org/knora/webapi/slice/common/ApiComplexV2JsonLdRequestParserSpec.scala +++ b/webapi/src/test/scala/org/knora/webapi/slice/common/ApiComplexV2JsonLdRequestParserSpec.scala @@ -82,6 +82,8 @@ object ApiComplexV2JsonLdRequestParserSpec extends ZIOSpecDefault { "internalMimeType", Some("originalFilename.orig"), Some("originalMimeType"), + None, + None, ) private val configureSipiServiceMock = for { @@ -484,6 +486,8 @@ object ApiComplexV2JsonLdRequestParserSpec extends ZIOSpecDefault { "internalMimeType", Some("originalFilename"), Some("originalMimeType"), + None, + None, ), IiifImageRequestUrl.unsafeFrom("http://www.example.org/prefix1/abcd1234/full/0/native.jpg"), None, @@ -712,9 +716,7 @@ object ApiComplexV2JsonLdRequestParserSpec extends ZIOSpecDefault { check(Gen.fromIterable(transformations)) { jsonLdTransform => for { sf <- ZIO.service[StringFormatter] - value <- service( - _.createValueV2FromJsonLd( - jsonLdTransform(""" + str = jsonLdTransform(""" { "@id": "http://rdfh.ch/0001/a-thing", "@type": "http://0.0.0.0:3333/ontology/0001/anything/v2#Thing", @@ -723,10 +725,8 @@ object ApiComplexV2JsonLdRequestParserSpec extends ZIOSpecDefault { "http://api.knora.org/ontology/knora-api/v2#valueAsString":"This is English", "http://api.knora.org/ontology/knora-api/v2#textValueHasLanguage":"en" } - }""".stripMargin), - AssetIngested, - ), - ) + }""".stripMargin) + value <- service(_.createValueV2FromJsonLd(str, AssetIngested)) } yield assertTrue( value == CreateValueV2( resourceIri = "http://rdfh.ch/0001/a-thing", diff --git a/webapi/src/test/scala/org/knora/webapi/slice/resources/repo/service/ResourcesRepoLiveSpec.scala b/webapi/src/test/scala/org/knora/webapi/slice/resources/repo/service/ResourcesRepoLiveSpec.scala index 24cf08993e..804eecf0ff 100644 --- a/webapi/src/test/scala/org/knora/webapi/slice/resources/repo/service/ResourcesRepoLiveSpec.scala +++ b/webapi/src/test/scala/org/knora/webapi/slice/resources/repo/service/ResourcesRepoLiveSpec.scala @@ -274,6 +274,8 @@ object TestData { originalMimeType = Some("image/png"), dimX = 100, dimY = 60, + None, + None, ), permissions = valuePermissions, creator = valueCreator, @@ -296,6 +298,8 @@ object TestData { originalFilename = None, originalMimeType = None, externalUrl = "http://example.com/foo.jpg", + None, + None, ), permissions = valuePermissions, creator = valueCreator, @@ -320,6 +324,8 @@ object TestData { dimX = Some(100), dimY = Some(60), pageCount = Some(10), + None, + None, ), permissions = valuePermissions, creator = valueCreator, @@ -341,6 +347,8 @@ object TestData { internalMimeType = "application/zip", originalFilename = Some("foo.zip"), originalMimeType = Some("application/zip"), + None, + None, ), permissions = valuePermissions, creator = valueCreator, @@ -881,10 +889,10 @@ object ResourcesRepoLiveSpec extends ZIOSpecDefault { | knora-base:valueCreationDate "$valueCreationDate"^^xsd:dateTime ; | knora-base:internalFilename "24159oO1pNg-ByLN1NLlMSJ.jp2" ; | knora-base:internalMimeType "image/jp2" ; - | knora-base:dimX 100 ; - | knora-base:dimY 60 ; | knora-base:originalFilename "foo.png" ; - | knora-base:originalMimeType "image/png" . + | knora-base:originalMimeType "image/png" ; + | knora-base:dimX 100 ; + | knora-base:dimY 60 . | } |} |""".stripMargin,