Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add reports for originals and topleft #57

Merged
merged 11 commits into from
Aug 8, 2023
8 changes: 2 additions & 6 deletions src/main/scala/swiss/dasch/api/ApiProblem.scala
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,7 @@ import zio.schema.{ DeriveSchema, Schema }

sealed trait ApiProblem

case class ProjectNotFound(shortcode: String) extends ApiProblem

object ProjectNotFound {
def make(shortcode: ProjectShortcode): ProjectNotFound = ProjectNotFound(shortcode.toString)
}
case class ProjectNotFound(shortcode: ProjectShortcode) extends ApiProblem

case class IllegalArguments(errors: List[IllegalArgument]) extends ApiProblem

Expand Down Expand Up @@ -65,5 +61,5 @@ object ApiProblem {
invalidHeader("Content-Type", actual.toString, s"expected '$expected'")

// other
def projectNotFound(shortcode: ProjectShortcode): ProjectNotFound = ProjectNotFound.make(shortcode)
def projectNotFound(shortcode: ProjectShortcode): ProjectNotFound = ProjectNotFound(shortcode)
}
6 changes: 2 additions & 4 deletions src/main/scala/swiss/dasch/api/ListProjectsEndpoint.scala
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,8 @@ import zio.http.{ App, Status }
import zio.json.{ DeriveJsonEncoder, JsonEncoder }
import zio.schema.{ DeriveSchema, Schema }
object ListProjectsEndpoint {
final case class ProjectResponse(id: String)
final case class ProjectResponse(id: ProjectShortcode)
object ProjectResponse {
def make(shortcode: ProjectShortcode): ProjectResponse = ProjectResponse(shortcode.toString)

implicit val schema: Schema[ProjectResponse] = DeriveSchema.gen[ProjectResponse]
implicit val jsonEncoder: JsonEncoder[ProjectResponse] = DeriveJsonEncoder.gen[ProjectResponse]
}
Expand All @@ -34,7 +32,7 @@ object ListProjectsEndpoint {
.listAllProjects()
.mapBoth(
ApiProblem.internalError,
shortcodes => (EndTotal("items", 0, shortcodes.size, shortcodes.size), shortcodes.map(ProjectResponse.make)),
shortcodes => (EndTotal("items", 0, shortcodes.size, shortcodes.size), shortcodes.map(ProjectResponse.apply)),
)
)
.toApp
Expand Down
65 changes: 23 additions & 42 deletions src/main/scala/swiss/dasch/api/MaintenanceEndpoint.scala
Original file line number Diff line number Diff line change
Expand Up @@ -6,58 +6,45 @@
package swiss.dasch.api

import swiss.dasch.api.ApiPathCodecSegments.shortcodePathVar
import swiss.dasch.domain.{ MaintenanceActions, ProjectService }
import zio.*
import zio.http.*
import zio.http.codec.*
import zio.http.codec.HttpCodec.*
import zio.http.endpoint.*
import zio.json.{ DeriveJsonEncoder, JsonEncoder }
import zio.nio.file
import zio.schema.{ DeriveSchema, Schema }

object MaintenanceEndpoint {

final case class MappingEntry(internalFilename: String, originalFilename: String)

object MappingEntry {
implicit val encoder: JsonEncoder[MappingEntry] = DeriveJsonEncoder.gen[MappingEntry]
implicit val schema: Schema[MappingEntry] = DeriveSchema.gen[MappingEntry]
}

private val applyTopLeftCorrectionEndpoint = Endpoint
.post("maintenance" / "apply-top-left-correction" / shortcodePathVar)
private val maintenance = "maintenance"

val applyTopLeftCorrectionEndpoint = Endpoint
.post(maintenance / "apply-top-left-correction" / shortcodePathVar)
.out[String](Status.Accepted)
.outErrors(
HttpCodec.error[ProjectNotFound](Status.NotFound),
HttpCodec.error[IllegalArguments](Status.BadRequest),
HttpCodec.error[InternalProblem](Status.InternalServerError),
)

private val applyTopLeftCorrectionRoute =
applyTopLeftCorrectionEndpoint.implement(shortcodeStr =>
for {
projectPath <- getProjectPath(shortcodeStr)
_ <- ZIO.logInfo(s"Creating originals for $projectPath")
_ <- MaintenanceActions
.applyTopLeftCorrections(projectPath)
.tap(count => ZIO.logInfo(s"Corrected $count top left images for $projectPath"))
.logError
.forkDaemon
} yield "work in progress"
val needsTopLeftCorrectionEndpoint = Endpoint
.get(maintenance / "needs-top-left-correction")
.out[String](Status.Accepted)
.outErrors(
HttpCodec.error[ProjectNotFound](Status.NotFound),
HttpCodec.error[IllegalArguments](Status.BadRequest),
HttpCodec.error[InternalProblem](Status.InternalServerError),
)

private def getProjectPath(shortcodeStr: String): ZIO[ProjectService, ApiProblem, file.Path] =
ApiStringConverters
.fromPathVarToProjectShortcode(shortcodeStr)
.flatMap(code =>
ProjectService.findProject(code).some.mapError {
case Some(e) => ApiProblem.internalError(e)
case _ => ApiProblem.projectNotFound(code)
}
)

private val createOriginalEndpoint = Endpoint
.post("maintenance" / "create-originals" / shortcodePathVar)
val createOriginalsEndpoint = Endpoint
.post(maintenance / "create-originals" / shortcodePathVar)
.inCodec(ContentCodec.content[Chunk[MappingEntry]](MediaType.application.json))
.out[String](Status.Accepted)
.outErrors(
Expand All @@ -66,19 +53,13 @@ object MaintenanceEndpoint {
HttpCodec.error[InternalProblem](Status.InternalServerError),
)

private val createOriginalRoute =
createOriginalEndpoint.implement {
case (shortCodeStr: String, mapping: Chunk[MappingEntry]) =>
for {
projectPath <- getProjectPath(shortCodeStr)
_ <- ZIO.logInfo(s"Creating originals for $projectPath")
_ <- MaintenanceActions
.createOriginals(projectPath, mapping.map(e => e.internalFilename -> e.originalFilename).toMap)
.tap(count => ZIO.logInfo(s"Created $count originals for $projectPath"))
.logError
.forkDaemon
} yield "work in progress"
}

val app = (createOriginalRoute ++ applyTopLeftCorrectionRoute).toApp
val needsOriginalsEndpoint = Endpoint
.get(maintenance / "needs-originals")
.query(queryBool("imagesOnly").optional)
.out[String](Status.Accepted)
.outErrors(
HttpCodec.error[ProjectNotFound](Status.NotFound),
HttpCodec.error[IllegalArguments](Status.BadRequest),
HttpCodec.error[InternalProblem](Status.InternalServerError),
)
}
137 changes: 137 additions & 0 deletions src/main/scala/swiss/dasch/api/MaintenanceEndpointRoutes.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
/*
* Copyright © 2021 - 2023 Swiss National Data and Service Center for the Humanities and/or DaSCH Service Platform contributors.
* SPDX-License-Identifier: Apache-2.0
*/

package swiss.dasch.api

import swiss.dasch.api.MaintenanceEndpoint.*
import swiss.dasch.domain.*
import zio.json.JsonEncoder
import zio.nio.file
import zio.nio.file.Files
import zio.{ Chunk, IO, ZIO }

import java.io.IOException

object MaintenanceEndpointRoutes {

private def getProjectPath(shortcodeStr: String): ZIO[ProjectService, ApiProblem, file.Path] =
ApiStringConverters
.fromPathVarToProjectShortcode(shortcodeStr)
seakayone marked this conversation as resolved.
Show resolved Hide resolved
.flatMap(code =>
ProjectService.findProject(code).some.mapError {
case Some(e) => ApiProblem.internalError(e)
case _ => ApiProblem.projectNotFound(code)
}
)

private def saveReport[A](
tmpDir: file.Path,
name: String,
report: A,
)(implicit encoder: JsonEncoder[A]
): ZIO[StorageService, Throwable, Unit] =
Files.createDirectories(tmpDir / "reports") *>
Files.deleteIfExists(tmpDir / "reports" / s"$name.json") *>
Files.createFile(tmpDir / "reports" / s"$name.json") *>
StorageService.saveJsonFile(tmpDir / "reports" / s"$name.json", report)

private val needsOriginalsRoute = needsOriginalsEndpoint.implement(imagesOnlyMaybe =>
{
val imagesOnly = imagesOnlyMaybe.getOrElse(true)
val reportName = if (imagesOnly) "needsOriginals_images_only" else "needsOriginals"
for {
_ <- ZIO.logInfo(s"Checking for originals")
assetDir <- StorageService.getAssetDirectory()
tmpDir <- StorageService.getTempDirectory()
projectShortcodes <- ProjectService.listAllProjects()
_ <- ZIO
.foreach(projectShortcodes)(shortcode =>
Files
.walk(assetDir / shortcode.toString)
.mapZIOPar(8)(originalNotPresent(imagesOnly))
.filter(identity)
.as(shortcode)
.runHead
)
.map(_.flatten)
.flatMap(saveReport(tmpDir, reportName, _))
.zipLeft(ZIO.logInfo(s"Created $reportName.json"))
.logError
.forkDaemon
} yield "work in progress"
}.logError.mapError(ApiProblem.internalError)
)

private def originalNotPresent(imagesOnly: Boolean)(path: file.Path): IO[IOException, Boolean] = {
val assetId = AssetId.makeFromPath(path).map(_.toString).getOrElse("unknown-asset-id")
(ZIO.succeed(imagesOnly).negate || FileFilters.isImage(path)) &&
Files
.list(path.parent.orNull)
.map(_.filename.toString)
.filter(name => name.endsWith(".orig") && name.startsWith(assetId))
.runHead
.map(_.isEmpty)
}
seakayone marked this conversation as resolved.
Show resolved Hide resolved

private val createOriginalsRoute =
createOriginalsEndpoint.implement {
case (shortCodeStr: String, mapping: Chunk[MappingEntry]) =>
for {
projectPath <- getProjectPath(shortCodeStr)
_ <- ZIO.logInfo(s"Creating originals for $projectPath")
_ <- MaintenanceActions
.createOriginals(projectPath, mapping.map(e => e.internalFilename -> e.originalFilename).toMap)
.tap(count => ZIO.logInfo(s"Created $count originals for $projectPath"))
.logError
.forkDaemon
} yield "work in progress"
}

private val needsTopLeftCorrectionRoute =
needsTopLeftCorrectionEndpoint.implement(_ =>
(
for {
_ <- ZIO.logInfo(s"Checking for top left correction")
assetDir <- StorageService.getAssetDirectory()
tmpDir <- StorageService.getTempDirectory()
imageService <- ZIO.service[ImageService]
projectShortcodes <- ProjectService.listAllProjects()
_ <-
ZIO
.foreach(projectShortcodes)(shortcode =>
Files
.walk(assetDir / shortcode.toString)
.mapZIOPar(8)(imageService.needsTopLeftCorrection)
.filter(identity)
.runHead
.map(_.map(_ => shortcode))
)
.map(_.flatten)
.flatMap(saveReport(tmpDir, "needsTopLeftCorrection", _))
.zipLeft(ZIO.logInfo(s"Created needsTopLeftCorrection.json"))
.logError
.forkDaemon
} yield "work in progress"
).logError.mapError(ApiProblem.internalError)
)

private val applyTopLeftCorrectionRoute =
applyTopLeftCorrectionEndpoint.implement(shortcodeStr =>
for {
projectPath <- getProjectPath(shortcodeStr)
_ <- ZIO.logInfo(s"Creating originals for $projectPath")
_ <- MaintenanceActions
.applyTopLeftCorrections(projectPath)
.tap(count => ZIO.logInfo(s"Corrected $count top left images for $projectPath"))
.logError
.forkDaemon
} yield "work in progress"
)

val app = (needsOriginalsRoute ++
createOriginalsRoute ++
needsTopLeftCorrectionRoute ++
applyTopLeftCorrectionRoute).toApp
}
5 changes: 4 additions & 1 deletion src/main/scala/swiss/dasch/domain/Asset.scala
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,10 @@ object AssetId {

def makeFromPath(file: Path): Option[AssetId] = {
val filename = file.filename.toString
AssetId.make(filename.substring(0, filename.lastIndexOf("."))).toOption
filename.contains(".") match {
case true => AssetId.make(filename.substring(0, filename.indexOf("."))).toOption
case false => None
}
seakayone marked this conversation as resolved.
Show resolved Hide resolved
}
}

Expand Down
22 changes: 22 additions & 0 deletions src/main/scala/swiss/dasch/domain/Exif.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
/*
* Copyright © 2021 - 2023 Swiss National Data and Service Center for the Humanities and/or DaSCH Service Platform contributors.
* SPDX-License-Identifier: Apache-2.0
*/

package swiss.dasch.domain

// see also https://exiftool.org/TagNames/EXIF.html
object Exif {
object Image {
val Orientation = "Exif.Image.Orientation"

sealed trait OrientationValue { def value: Char }
object OrientationValue {
// = Horizontal(normal)
case object Horizontal extends OrientationValue { val value = '1' }

// = Rotate 270 CW
case object Rotate270CW extends OrientationValue { val value = '8' }
}
}
}
2 changes: 1 addition & 1 deletion src/main/scala/swiss/dasch/domain/FileFilters.scala
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ object FileFilters {

val isJpeg2000: FileFilter = hasFileExtension(Jpx.allExtensions)

val isImage: FileFilter = hasFileExtension(SipiImageFormat.allExtension)
val isImage: FileFilter = hasFileExtension(SipiImageFormat.allExtensions)

val isNonHiddenRegularFile: FileFilter = (path: Path) => Files.isRegularFile(path) && Files.isHidden(path).negate

Expand Down
23 changes: 6 additions & 17 deletions src/main/scala/swiss/dasch/domain/ImageService.scala
Original file line number Diff line number Diff line change
Expand Up @@ -24,26 +24,15 @@ trait ImageService {
* the path to the corrected image or None if no correction was needed
*/
def applyTopLeftCorrection(image: Path): Task[Option[Path]]

def needsTopLeftCorrection(image: Path): IO[IOException, Boolean]
}

object ImageService {
def applyTopLeftCorrection(image: Path): ZIO[ImageService, Throwable, Option[Path]] =
ZIO.serviceWithZIO[ImageService](_.applyTopLeftCorrection(image))
}

// see also https://exiftool.org/TagNames/EXIF.html
object Exif {
object Image {
val Orientation = "Exif.Image.Orientation"

sealed trait OrientationValue { def value: Char }
object OrientationValue {
// = Horizontal(normal)
case object Horizontal extends OrientationValue { val value = '1' }

// = Rotate 270 CW
case object Rotate270CW extends OrientationValue { val value = '8' }
}
}
def needsTopLeftCorrection(image: Path): ZIO[ImageService, IOException, Boolean] =
ZIO.serviceWithZIO[ImageService](_.needsTopLeftCorrection(image))
}

final case class ImageServiceLive(sipiClient: SipiClient, assetInfos: AssetInfoService) extends ImageService {
Expand All @@ -56,7 +45,7 @@ final case class ImageServiceLive(sipiClient: SipiClient, assetInfos: AssetInfoS
assetInfos.updateAssetInfoForDerivative(image).as(image)
)

private def needsTopLeftCorrection(image: Path): IO[IOException, Boolean] =
override def needsTopLeftCorrection(image: Path): IO[IOException, Boolean] =
FileFilters.isImage(image) &&
sipiClient
.queryImageFile(image)
Expand Down
12 changes: 10 additions & 2 deletions src/main/scala/swiss/dasch/domain/ProjectService.scala
Original file line number Diff line number Diff line change
Expand Up @@ -10,17 +10,25 @@ import eu.timepit.refined.refineV
import eu.timepit.refined.string.MatchesRegex
import org.apache.commons.io.FileUtils
import zio.*
import zio.json.JsonCodec
import zio.nio.file.Files.{ isDirectory, newDirectoryStream }
import zio.nio.file.{ Files, Path }
import zio.schema.Schema
import zio.stream.ZStream

import java.io.IOException

opaque type ProjectShortcode = String Refined MatchesRegex["""^\p{XDigit}{4,4}$"""]
type IiifPrefix = ProjectShortcode

object ProjectShortcode {
def make(shortcode: String): Either[String, ProjectShortcode] = refineV(shortcode.toUpperCase)

def make(shortcodeStr: String): Either[String, ProjectShortcode] = refineV(shortcodeStr.toUpperCase)
seakayone marked this conversation as resolved.
Show resolved Hide resolved

extension (c: ProjectShortcode) { def value: String = c.toString }

given schema: Schema[ProjectShortcode] = Schema[String].transformOrFail(ProjectShortcode.make, id => Right(id.value))

given codec: JsonCodec[ProjectShortcode] = JsonCodec[String].transformOrFail(ProjectShortcode.make, _.value)
}

trait ProjectService {
Expand Down
Loading