diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a09aa8f7dd..cc5bb8e379 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,7 +15,7 @@ jobs: pull-requests: write services: elasticsearch: - image: docker.elastic.co/elasticsearch/elasticsearch:7.16.2 + image: docker.elastic.co/elasticsearch/elasticsearch:7.17.29 # Wait for elasticsearch to report healthy before continuing. # see https://github.com/actions/example-services/blob/master/.github/workflows/postgres-service.yml#L28 options: -e "discovery.type=single-node" --expose 9200 --health-cmd "curl localhost:9200/_cluster/health" --health-interval 10s --health-timeout 5s --health-retries 10 diff --git a/build.sbt b/build.sbt index fb0fb38af1..d5949211f7 100644 --- a/build.sbt +++ b/build.sbt @@ -63,7 +63,7 @@ Global / concurrentRestrictions := Seq( ) val awsSdkVersion = "1.12.470" -val awsSdkV2Version = "2.31.12" +val awsSdkV2Version = "2.32.33" val elastic4sVersion = "8.18.2" val okHttpVersion = "3.12.1" @@ -107,6 +107,9 @@ lazy val commonLib = project("common-lib").settings( "org.scanamo" %% "scanamo" % "2.0.0", // declare explicit dependency on desired version of aws sdk v2 dynamo "software.amazon.awssdk" % "dynamodb" % awsSdkV2Version, + // declare explicit dependency on desired version of aws sdk v2 bedrock runtime + "software.amazon.awssdk" % "bedrockruntime" % awsSdkV2Version, + "software.amazon.awssdk" % "s3vectors" % awsSdkV2Version, ws, "org.testcontainers" % "elasticsearch" % "1.19.2" % Test ), diff --git a/common-lib/src/main/scala/com/gu/mediaservice/lib/aws/Bedrock.scala b/common-lib/src/main/scala/com/gu/mediaservice/lib/aws/Bedrock.scala new file mode 100644 index 0000000000..f1d816466b --- /dev/null +++ b/common-lib/src/main/scala/com/gu/mediaservice/lib/aws/Bedrock.scala @@ -0,0 +1,95 @@ +package com.gu.mediaservice.lib.aws + +import software.amazon.awssdk.services.bedrockruntime.model._ +import software.amazon.awssdk.services.bedrockruntime._ +import com.gu.mediaservice.lib.config.CommonConfig +import play.api.libs.json.Json +import software.amazon.awssdk.core.SdkBytes + +import java.net.URI +import com.gu.mediaservice.lib.logging.LogMarker +import play.api.libs.json.OFormat.oFormatFromReadsAndOWrites +import play.api.libs.json._ + +object Bedrock { + private case class BedrockRequest( + input_type: String, + embedding_types: List[String], + images: List[String] + ) + private implicit val bedrockRequestFormat: OFormat[BedrockRequest] = Json.format[BedrockRequest] +} + +import scala.concurrent.{ExecutionContext, Future} + +class Bedrock(config: CommonConfig) + extends AwsClientV2BuilderUtils { + + // TODO: figure out what the more usual pattern for turning off localstack behaviour is + override def awsLocalEndpointUri: Option[URI] = None + + override def isDev: Boolean = config.isDev + + val client: BedrockRuntimeClient = { + withAWSCredentialsV2(BedrockRuntimeClient.builder()) + .build() + } + + private def createRequestBody(base64EncodedImage: String, fileType: CohereCompatibleMimeType): InvokeModelRequest = { + val images = fileType match { + case CohereJpeg => List(s"data:image/jpg;base64,$base64EncodedImage") + case CoherePng => List(s"data:image/png;base64,$base64EncodedImage") + } + + val body = Bedrock.BedrockRequest( + input_type = "image", + embedding_types = List("float"), + images = images + ) + val jsonBody = Json.toJson(body).toString() + + val request: InvokeModelRequest = { + InvokeModelRequest + .builder() + .accept("*/*") + .body(SdkBytes.fromUtf8String(jsonBody)) + .contentType("application/json") + .modelId("cohere.embed-english-v3") + .build() + } + request + } + + private def sendBedrockEmbeddingRequest(base64EncodedImage: String, fileType: CohereCompatibleMimeType)( + implicit logMarker: LogMarker + ): InvokeModelResponse = { + try { + val response = client.invokeModel(createRequestBody(base64EncodedImage, fileType)) + logger.info( + logMarker, + s"Bedrock API call to create image embedding completed with status: ${response.sdkHttpResponse().statusCode()}" + ) + response + } + catch { + case e: Exception => + logger.error(logMarker, "Exception during Bedrock API call to create image embedding", e) + throw e + } + } + + def createImageEmbedding(base64EncodedImage: String, fileType: CohereCompatibleMimeType)(implicit ec: ExecutionContext, logMarker: LogMarker): Future[List[Float]] = { + val bedrockFuture = Future { sendBedrockEmbeddingRequest(base64EncodedImage, fileType) } + bedrockFuture.map { response => + val responseBody = response.body().asUtf8String() + val json = Json.parse(responseBody) + // Extract the embedding array (first element since it's an array of arrays) + val embedding = (json \ "embeddings" \ "float")(0).as[List[Float]] + logger.info( + logMarker, + s"Successfully extracted image embedding. Vector size: ${embedding.size}" + ) + embedding + } + } +} diff --git a/common-lib/src/main/scala/com/gu/mediaservice/lib/aws/Embedder.scala b/common-lib/src/main/scala/com/gu/mediaservice/lib/aws/Embedder.scala new file mode 100644 index 0000000000..1a00db5ed0 --- /dev/null +++ b/common-lib/src/main/scala/com/gu/mediaservice/lib/aws/Embedder.scala @@ -0,0 +1,44 @@ +package com.gu.mediaservice.lib.aws +import com.gu.mediaservice.lib.logging.{GridLogging, LogMarker} +import com.gu.mediaservice.model.{Jpeg, MimeType, Png, Tiff} +import software.amazon.awssdk.services.s3vectors.model.PutVectorsResponse + +import java.nio.file.{Files, Path} +import java.util.Base64 +import scala.concurrent.{ExecutionContext, Future} + +sealed trait CohereCompatibleMimeType +case object CohereJpeg extends CohereCompatibleMimeType +case object CoherePng extends CohereCompatibleMimeType + +class Embedder(s3vectors: S3Vectors, bedrock: Bedrock) extends GridLogging { + // https://docs.aws.amazon.com/bedrock/latest/userguide/model-parameters-embed-v3.html#:~:text=The%20image%20must%20be%20in%20either%20image/jpeg%20or%20image/png%20format%20and%20has%20a%20maximum%20size%20of%205MB + def meetsCohereRequirements(fileType: MimeType, imageFilePath: Path)(implicit logMarker: LogMarker): Either[String, CohereCompatibleMimeType]= { + val fileSize = Files.size(imageFilePath) + val fiveMB = 5_000_000 + + fileType match { + case _ if fileSize > fiveMB => Left(s"Image file is >5MB. File size: $fileSize") + case Jpeg => Right(CohereJpeg) + case Png => Right(CoherePng) + case Tiff => Left("Image file type is not supported. File type: Tiff") + } + } + + def createEmbeddingAndStore(fileType: MimeType, imageFilePath: Path, imageId: String)(implicit ec: ExecutionContext, logMarker: LogMarker + ): Future[Option[PutVectorsResponse]] = { + meetsCohereRequirements(fileType, imageFilePath)(logMarker) match { + case Left(error) => { + logger.info(logMarker, s"Skipping image embedding for $imageId as it does not meet the requirements: $error") + Future.successful(None) + } + case Right(imageType) => { + val base64EncodedString: String = Base64.getEncoder().encodeToString(Files.readAllBytes(imageFilePath)) + val embeddingFuture = bedrock.createImageEmbedding(base64EncodedString, imageType) + embeddingFuture.map { embedding => + Some(s3vectors.storeEmbeddingInS3VectorStore(embedding, imageId)) + } + } + } + } +} diff --git a/common-lib/src/main/scala/com/gu/mediaservice/lib/aws/S3Vectors.scala b/common-lib/src/main/scala/com/gu/mediaservice/lib/aws/S3Vectors.scala new file mode 100644 index 0000000000..4ea06a03cd --- /dev/null +++ b/common-lib/src/main/scala/com/gu/mediaservice/lib/aws/S3Vectors.scala @@ -0,0 +1,69 @@ +package com.gu.mediaservice.lib.aws +import com.gu.mediaservice.lib.config.CommonConfig +import com.gu.mediaservice.lib.logging.LogMarker + +import software.amazon.awssdk.regions.Region +import software.amazon.awssdk.services.s3vectors._ +import software.amazon.awssdk.services.s3vectors.model.{PutInputVector, PutVectorsRequest, PutVectorsResponse, VectorData} + +import java.net.URI +import scala.concurrent.{ExecutionContext, Future} +import scala.jdk.CollectionConverters._ + +class S3Vectors(config: CommonConfig) + extends AwsClientV2BuilderUtils { + + // TODO: figure out what the more usual pattern for turning off localstack behaviour is + override def awsLocalEndpointUri: Option[URI] = None + + override def isDev: Boolean = config.isDev + + // The S3 Vector Store is not yet available in eu-west-1, so we are using eu-central-1 because it's closest to us. + override def awsRegionV2: Region = Region.EU_CENTRAL_1 + + val client: S3VectorsClient = { + withAWSCredentialsV2(S3VectorsClient.builder()) + .build() + } + + private def createRequestBody(embedding: List[Float], imageId: String): PutVectorsRequest = { + val vectorData: VectorData = VectorData + .builder() + .float32(embedding.map(float2Float).asJava) + .build() + + val inputVector: PutInputVector = PutInputVector + .builder() + .data(vectorData) + .key(imageId) + .build() + + val request: PutVectorsRequest = PutVectorsRequest + .builder() + .indexName("cohere-embed-english-v3") + .vectorBucketName(s"image-embeddings-${config.stage.toLowerCase}") + .vectors(inputVector) + .build() + + request + } + + def storeEmbeddingInS3VectorStore(bedrockEmbedding: List[Float], imageId: String)(implicit logMarker: LogMarker + ): PutVectorsResponse = { + try { + val request = createRequestBody(bedrockEmbedding, imageId) + val response = client.putVectors(request) + logger.info( + logMarker, + s"S3 Vector Store API call to store image embedding completed with status: ${response.sdkHttpResponse().statusCode()}" + ) + response + } + catch { + case e: Exception => + logger.error(logMarker, s"Exception during S3 Vector Store API call to store image embedding for $imageId: ", e) + throw e + } + } + +} diff --git a/common-lib/src/main/scala/com/gu/mediaservice/lib/cleanup/PhotographerRenamer.scala b/common-lib/src/main/scala/com/gu/mediaservice/lib/cleanup/PhotographerRenamer.scala index 76244a4c34..c55b9cf1b1 100644 --- a/common-lib/src/main/scala/com/gu/mediaservice/lib/cleanup/PhotographerRenamer.scala +++ b/common-lib/src/main/scala/com/gu/mediaservice/lib/cleanup/PhotographerRenamer.scala @@ -234,6 +234,7 @@ object PhotographerRenamer extends MetadataCleaner { "Huseyin Demirci" -> "Hüseyin Demirci", "Huseyin Yildiz" -> "Hüseyin Yıldız", "Ian Macnicol" -> "Ian MacNicol", + "Igor Pavicevic" -> "Igor Pavićević", "Inti Ocon" -> "Inti Ocón", "Ints Kalnins" -> "Ints Kalniņš", "Irek Dorozanski" -> "Irek Dorożański", @@ -274,6 +275,7 @@ object PhotographerRenamer extends MetadataCleaner { "Jerome Prebois" -> "Jérôme Prébois", "Jerome Prevost" -> "Jérôme Prévost", "Jerome Sessini" -> "Jérôme Sessini", + "Jerzy Muszynski" -> "Jerzy Muszyński", "Jesus Bustamante" -> "Jesús Bustamante", "Jesus Diges" -> "Jesús Diges", "Jesus Merida" -> "Jesús Mérida", @@ -338,6 +340,7 @@ object PhotographerRenamer extends MetadataCleaner { "Klebher Vasquez" -> "Klebher Vásquez", "Koca Sulejmanovic" -> "Koca Sulejmanović", "Krisztian Elek" -> "Krisztián Elek", + "Krzysztof Cwik" -> "Krzysztof Ćwik", "Krzysztof Swiderski" -> "Krzysztof Świderski", "Kuba Stezycki" -> "Kuba Stężycki", "Laszlo Balogh" -> "László Balogh", @@ -378,6 +381,7 @@ object PhotographerRenamer extends MetadataCleaner { "Manu Fernandez" -> "Manu Fernández", "Manuel Vazquez" -> "Manuel Vázquez", "Manuel Velazquez" -> "Manuel Velázquez", + "Marek Antoni Iwanczuk" -> "Marek Antoni Iwańczuk", "Marc Mccormack" -> "Marc McCormack", "Marcelo Del Pozo" -> "Marcelo del Pozo", "Marcelo Hernandez" -> "Marcelo Hernández", @@ -419,6 +423,7 @@ object PhotographerRenamer extends MetadataCleaner { "Milos Bicanski" -> "Miloš Bičanski", "Milos Vujovic" -> "Miloš Vujović", "Miro Kuzmanovic" -> "Miro Kuzmanović", + "Mitar Mitrovic" -> "Mitar Mitrović", "Moises Castillo" -> "Moisés Castillo", "Morne de Klerk" -> "Morné de Klerk", "Murat Ozgur Guvendik" -> "Murat Özgür Güvendik", @@ -482,6 +487,7 @@ object PhotographerRenamer extends MetadataCleaner { "Radoslaw Jozwiak" -> "Radosław Jóźwiak", "Rafal Gaglewski" -> "Rafał Gąglewski", "Rafal Guz" -> "Rafał Guz", + "Raimonda Kulikauskiene" -> "Raimonda Kulikauskienė", "Ramon Buxo Martinez" -> "Ramon Buxó Martínez", "Ramon Costa" -> "Ramón Costa", "Ramon de la Rocha" -> "Ramón de la Rocha", diff --git a/common-lib/src/main/scala/com/gu/mediaservice/lib/cleanup/RedundantTokenRemover.scala b/common-lib/src/main/scala/com/gu/mediaservice/lib/cleanup/RedundantTokenRemover.scala index 3c74625f90..d61b2b5b00 100644 --- a/common-lib/src/main/scala/com/gu/mediaservice/lib/cleanup/RedundantTokenRemover.scala +++ b/common-lib/src/main/scala/com/gu/mediaservice/lib/cleanup/RedundantTokenRemover.scala @@ -21,6 +21,8 @@ object RedundantTokenRemover extends MetadataCleaner { "Stringer", "Stringer .", "STR", + "STR New", + "-STR", "supplied", "Supplied", "SUPPLIED", diff --git a/dev/script/generate-config/service-config.js b/dev/script/generate-config/service-config.js index d969844af3..3c522da91a 100644 --- a/dev/script/generate-config/service-config.js +++ b/dev/script/generate-config/service-config.js @@ -74,6 +74,7 @@ function getImageLoaderConfig(config) { |metrics.request.enabled=false |transcoded.mime.types="image/tiff" |upload.quarantine.enabled=false + |s3.vectors.shouldEmbed=false |`; } diff --git a/dev/script/get-s3-vector-store-records.sh b/dev/script/get-s3-vector-store-records.sh new file mode 100755 index 0000000000..ff63cb3b40 --- /dev/null +++ b/dev/script/get-s3-vector-store-records.sh @@ -0,0 +1,21 @@ +#!/bin/bash + +# Check if environment argument is provided +if [ $# -ne 1 ]; then + echo "Usage: $0 " + echo "Environment options: dev, test, prod" + exit 1 +fi + +# Validate environment argument +env=$1 +if [[ ! "$env" =~ ^(dev|test|prod)$ ]]; then + echo "Invalid environment. Please use dev, test, or prod" + exit 1 +fi + +aws s3vectors list-vectors \ + --vector-bucket-name "image-embeddings-$env" \ + --index-name cohere-embed-english-v3 \ + --profile media-service \ + --region eu-central-1 \ No newline at end of file diff --git a/image-loader/app/ImageLoaderComponents.scala b/image-loader/app/ImageLoaderComponents.scala index ebe3ef69ee..b49ce974d8 100644 --- a/image-loader/app/ImageLoaderComponents.scala +++ b/image-loader/app/ImageLoaderComponents.scala @@ -1,5 +1,5 @@ import com.gu.mediaservice.GridClient -import com.gu.mediaservice.lib.aws.SimpleSqsMessageConsumer +import com.gu.mediaservice.lib.aws.{Bedrock, S3Vectors, SimpleSqsMessageConsumer, Embedder} import com.gu.mediaservice.lib.config.Services import com.gu.mediaservice.lib.imaging.ImageOperations import com.gu.mediaservice.lib.logging.GridLogging @@ -27,8 +27,11 @@ class ImageLoaderComponents(context: Context) extends GridComponents(context, ne val imageOperations = new ImageOperations(context.environment.rootPath.getAbsolutePath) val notifications = new Notifications(config) val downloader = new Downloader()(ec,wsClient) - val uploader = new Uploader(store, config, imageOperations, notifications, imageProcessor) - val projector = Projector(config, imageOperations, imageProcessor, auth) + + val maybeEmbedder: Option[Embedder] = if (config.shouldEmbed) Some(new Embedder(new S3Vectors(config), new Bedrock(config))) else None + + val uploader = new Uploader(store, config, imageOperations, notifications, maybeEmbedder, imageProcessor) + val projector = Projector(config, imageOperations, imageProcessor, auth, maybeEmbedder) val quarantineUploader: Option[QuarantineUploader] = (config.uploadToQuarantineEnabled, config.quarantineBucket) match { case (true, Some(bucketName)) =>{ val quarantineStore = new QuarantineStore(config) diff --git a/image-loader/app/lib/ImageLoaderConfig.scala b/image-loader/app/lib/ImageLoaderConfig.scala index 8c85037fe9..61964f9456 100644 --- a/image-loader/app/lib/ImageLoaderConfig.scala +++ b/image-loader/app/lib/ImageLoaderConfig.scala @@ -33,6 +33,8 @@ class ImageLoaderConfig(resources: GridConfigResources) extends CommonConfig(res val uploadStatusTable: String = string("dynamo.table.upload.status") val uploadStatusExpiry: FiniteDuration = configuration.get[FiniteDuration]("uploadStatus.recordExpiry") + val shouldEmbed: Boolean = boolean("s3.vectors.shouldEmbed") + /** * Load in the chain of image processors from config. This can be a list of * companion objects, class names, both with and without config. diff --git a/image-loader/app/model/Projector.scala b/image-loader/app/model/Projector.scala index c776d664f3..eb6a4ac43a 100644 --- a/image-loader/app/model/Projector.scala +++ b/image-loader/app/model/Projector.scala @@ -1,15 +1,13 @@ package model import java.io.{File, FileOutputStream} -import java.util.UUID import com.amazonaws.services.s3.AmazonS3 import com.gu.mediaservice.{GridClient, ImageDataMerger} import com.gu.mediaservice.lib.auth.Authentication import com.amazonaws.services.s3.model.{GetObjectRequest, ObjectMetadata, S3Object => AwsS3Object} import com.gu.mediaservice.lib.ImageIngestOperations.{fileKeyFromId, optimisedPngKeyFromId} import com.gu.mediaservice.lib.{ImageIngestOperations, ImageStorageProps, StorableOptimisedImage, StorableOriginalImage, StorableThumbImage} -import com.gu.mediaservice.lib.aws.S3Ops -import com.gu.mediaservice.lib.aws.S3Object +import com.gu.mediaservice.lib.aws.{Embedder, S3Ops} import com.gu.mediaservice.lib.cleanup.ImageProcessor import com.gu.mediaservice.lib.imaging.ImageOperations import com.gu.mediaservice.lib.logging.{GridLogging, LogMarker, Stopwatch} @@ -20,9 +18,10 @@ import lib.{DigestedFile, ImageLoaderConfig} import model.upload.UploadRequest import org.apache.tika.io.IOUtils import org.joda.time.{DateTime, DateTimeZone} -import play.api.Logger import play.api.libs.ws.WSRequest +import software.amazon.awssdk.services.s3vectors.model.PutVectorsResponse +import java.nio.file.Path import scala.jdk.CollectionConverters._ import scala.concurrent.duration.Duration import scala.concurrent.{Await, ExecutionContext, Future} @@ -31,8 +30,8 @@ object Projector { import Uploader.toImageUploadOpsCfg - def apply(config: ImageLoaderConfig, imageOps: ImageOperations, processor: ImageProcessor, auth: Authentication)(implicit ec: ExecutionContext): Projector - = new Projector(toImageUploadOpsCfg(config), S3Ops.buildS3Client(config), imageOps, processor, auth) + def apply(config: ImageLoaderConfig, imageOps: ImageOperations, processor: ImageProcessor, auth: Authentication, maybeEmbedder: Option[Embedder])(implicit ec: ExecutionContext): Projector + = new Projector(toImageUploadOpsCfg(config), S3Ops.buildS3Client(config), imageOps, processor, auth, maybeEmbedder) } case class S3FileExtractedMetadata( @@ -85,9 +84,10 @@ class Projector(config: ImageUploadOpsCfg, s3: AmazonS3, imageOps: ImageOperations, processor: ImageProcessor, - auth: Authentication) extends GridLogging { + auth: Authentication, + maybeEmbedder: Option[Embedder]) extends GridLogging { - private val imageUploadProjectionOps = new ImageUploadProjectionOps(config, imageOps, processor, s3) + private val imageUploadProjectionOps = new ImageUploadProjectionOps(config, imageOps, processor, s3, maybeEmbedder) def projectS3ImageById(imageId: String, tempFile: File, gridClient: GridClient, onBehalfOfFn: WSRequest => WSRequest) (implicit ec: ExecutionContext, logMarker: LogMarker): Future[Option[Image]] = { @@ -159,7 +159,8 @@ class Projector(config: ImageUploadOpsCfg, class ImageUploadProjectionOps(config: ImageUploadOpsCfg, imageOps: ImageOperations, processor: ImageProcessor, - s3: AmazonS3 + s3: AmazonS3, + maybeEmbedder: Option[Embedder], ) extends GridLogging { import Uploader.{fromUploadRequestShared, toMetaMap} @@ -175,11 +176,20 @@ class ImageUploadProjectionOps(config: ImageUploadOpsCfg, projectOptimisedPNGFileAsS3Model, tryFetchThumbFile = fetchThumbFile, tryFetchOptimisedFile = fetchOptimisedFile, + createEmbeddingAndStore = createEmbeddingAndStore, ) fromUploadRequestShared(uploadRequest, dependenciesWithProjectionsOnly, processor) } + private def createEmbeddingAndStore(fileType: MimeType, imageFilePath: Path, imageId: String)(implicit ec: ExecutionContext, logMarker: LogMarker): Future[Option[PutVectorsResponse]] = { + maybeEmbedder match { + case Some(embedder) => + embedder.createEmbeddingAndStore(fileType, imageFilePath, imageId) + case None => Future.successful(None) + } + } + private def projectOriginalFileAsS3Model(storableOriginalImage: StorableOriginalImage) = Future.successful(storableOriginalImage.toProjectedS3Object(config.originalFileBucket)) diff --git a/image-loader/app/model/Uploader.scala b/image-loader/app/model/Uploader.scala index 38e1574435..92fbf901ca 100644 --- a/image-loader/app/model/Uploader.scala +++ b/image-loader/app/model/Uploader.scala @@ -4,15 +4,10 @@ import com.gu.mediaservice.{GridClient, ImageDataMerger} import com.gu.mediaservice.lib.Files.createTempFile import java.io.File -import java.net.URLEncoder -import java.nio.charset.StandardCharsets -import java.nio.file.{Files, Paths} -import java.util.UUID +import java.nio.file.{Files, Path} import com.gu.mediaservice.lib.argo.ArgoHelpers -import com.gu.mediaservice.lib.auth.Authentication -import com.gu.mediaservice.lib.auth.Authentication.Principal import com.gu.mediaservice.lib.{BrowserViewableImage, ImageStorageProps, StorableOptimisedImage, StorableOriginalImage, StorableThumbImage} -import com.gu.mediaservice.lib.aws.{S3Object, UpdateMessage} +import com.gu.mediaservice.lib.aws.{Embedder, S3Object, S3Vectors, UpdateMessage} import com.gu.mediaservice.lib.cleanup.ImageProcessor import com.gu.mediaservice.lib.formatting._ import com.gu.mediaservice.lib.imaging.ImageOperations @@ -30,6 +25,7 @@ import model.upload.{OptimiseOps, OptimiseWithPngQuant, UploadRequest} import org.joda.time.DateTime import play.api.libs.json.{JsObject, Json} import play.api.libs.ws.WSRequest +import software.amazon.awssdk.services.s3vectors.model.PutVectorsResponse import scala.collection.compat._ import scala.concurrent.{ExecutionContext, Future} @@ -81,6 +77,7 @@ case class ImageUploadOpsDependencies( storeOrProjectOptimisedImage: StorableOptimisedImage => Future[S3Object], tryFetchThumbFile: (String, File) => Future[Option[(File, MimeType)]] = (_, _) => Future.successful(None), tryFetchOptimisedFile: (String, File) => Future[Option[(File, MimeType)]] = (_, _) => Future.successful(None), + createEmbeddingAndStore: (MimeType, Path, String) => Future[Option[PutVectorsResponse]] ) case class UploadStatusUri (uri: String) extends AnyVal { @@ -116,6 +113,7 @@ object Uploader extends GridLogging { storeOrProjectOriginalFile, storeOrProjectThumbFile, storeOrProjectOptimisedImage, + createEmbeddingAndStore, OptimiseWithPngQuant, uploadRequest, deps, @@ -127,6 +125,7 @@ object Uploader extends GridLogging { private[model] def uploadAndStoreImage(storeOrProjectOriginalFile: StorableOriginalImage => Future[S3Object], storeOrProjectThumbFile: StorableThumbImage => Future[S3Object], storeOrProjectOptimisedFile: StorableOptimisedImage => Future[S3Object], + createEmbeddingAndStore: (MimeType, Path, String) => Future[Option[PutVectorsResponse]], optimiseOps: OptimiseOps, uploadRequest: UploadRequest, deps: ImageUploadOpsDependencies, @@ -156,6 +155,12 @@ object Uploader extends GridLogging { val sourceStoreFuture = storeOrProjectOriginalFile(storableOriginalImage) val eventualBrowserViewableImage = createBrowserViewableFileFuture(uploadRequest, tempDirForRequest, deps) + // We are fetching the embedding and storing it in the S3Vectors bucket + // This should not block the image upload process on failure + createEmbeddingAndStore(originalMimeType, uploadRequest.tempFile.toPath, uploadRequest.imageId).failed.foreach { failure => + logger.error(logMarker, s"Failed to create and store image embedding for ${uploadRequest.imageId}", failure) + } + val eventualImage = for { browserViewableImage <- eventualBrowserViewableImage s3Source <- sourceStoreFuture @@ -327,19 +332,28 @@ class Uploader(val store: ImageLoaderStore, val config: ImageLoaderConfig, val imageOps: ImageOperations, val notifications: Notifications, + val maybeEmbedder: Option[Embedder], imageProcessor: ImageProcessor) (implicit val ec: ExecutionContext) extends MessageSubjects with ArgoHelpers { def fromUploadRequest(uploadRequest: UploadRequest) (implicit logMarker: LogMarker): Future[ImageUpload] = { val sideEffectDependencies = ImageUploadOpsDependencies(toImageUploadOpsCfg(config), imageOps, - storeSource, storeThumbnail, storeOptimisedImage) + storeSource, storeThumbnail, storeOptimisedImage, createEmbeddingAndStore = createEmbeddingAndStore) Stopwatch.async("finalImage") { val finalImage = fromUploadRequestShared(uploadRequest, sideEffectDependencies, imageProcessor) finalImage.map(img => ImageUpload(uploadRequest, img)) } } + private def createEmbeddingAndStore(fileType: MimeType, imageFilePath: Path, imageId: String)(implicit logMarker: LogMarker): Future[Option[PutVectorsResponse]] = { + maybeEmbedder match { + case Some(embedder) => + embedder.createEmbeddingAndStore(fileType, imageFilePath, imageId) + case None => Future.successful(None) + } + } + private def storeSource(storableOriginalImage: StorableOriginalImage) (implicit logMarker: LogMarker) = store.store(storableOriginalImage) diff --git a/image-loader/test/scala/model/ImageUploadTest.scala b/image-loader/test/scala/model/ImageUploadTest.scala index 35859abf4b..ae6034ec7d 100644 --- a/image-loader/test/scala/model/ImageUploadTest.scala +++ b/image-loader/test/scala/model/ImageUploadTest.scala @@ -17,8 +17,10 @@ import org.scalatest.Assertion import org.scalatest.funsuite.AsyncFunSuite import org.scalatest.matchers.should.Matchers import org.scalatestplus.mockito.MockitoSugar +import software.amazon.awssdk.services.s3vectors.model.PutVectorsResponse import test.lib.ResourceHelpers +import java.nio.file.Path import scala.concurrent.{ExecutionContext, Future} import scala.util.{Failure, Success} @@ -56,16 +58,22 @@ class ImageUploadTest extends AsyncFunSuite with Matchers with MockitoSugar { S3Object("madeupname", "madeupkey", a.file, Some(a.mimeType), None, a.meta, None) ) + val mockPutVectorsResponse = mock[PutVectorsResponse] + def mockVectorStore = (fileType: MimeType, imagePath: Path, imageId: String) => + Future.successful(Some(mockPutVectorsResponse)) + def storeOrProjectOriginalFile: StorableOriginalImage => Future[S3Object] = mockStore def storeOrProjectThumbFile: StorableThumbImage => Future[S3Object] = mockStore def storeOrProjectOptimisedPNG: StorableOptimisedImage => Future[S3Object] = mockStore + def createEmbeddingAndStore: (MimeType, Path, String) => Future[Option[PutVectorsResponse]] = mockVectorStore val mockDependencies = ImageUploadOpsDependencies( mockConfig, imageOps, storeOrProjectOriginalFile, storeOrProjectThumbFile, - storeOrProjectOptimisedPNG + storeOrProjectOptimisedPNG, + createEmbeddingAndStore = createEmbeddingAndStore ) val tempFile = ResourceHelpers.fileAt(fileName) @@ -85,11 +93,12 @@ class ImageUploadTest extends AsyncFunSuite with Matchers with MockitoSugar { mockDependencies.storeOrProjectOriginalFile, mockDependencies.storeOrProjectThumbFile, mockDependencies.storeOrProjectOptimisedImage, + createEmbeddingAndStore, OptimiseWithPngQuant, uploadRequest, mockDependencies, FileMetadata(), - ImageProcessor.identity + ImageProcessor.identity, ) // Assertions; Failure will auto-fail diff --git a/image-loader/test/scala/model/ProjectorTest.scala b/image-loader/test/scala/model/ProjectorTest.scala index 3779c6ff90..b0473127ee 100644 --- a/image-loader/test/scala/model/ProjectorTest.scala +++ b/image-loader/test/scala/model/ProjectorTest.scala @@ -7,6 +7,7 @@ import com.amazonaws.services.s3.AmazonS3 import com.amazonaws.services.s3.model.ObjectMetadata import com.gu.mediaservice.GridClient import com.gu.mediaservice.lib.auth.Authentication +import com.gu.mediaservice.lib.aws.{Embedder, S3Vectors} import com.gu.mediaservice.lib.cleanup.ImageProcessor import com.gu.mediaservice.lib.imaging.ImageOperations import com.gu.mediaservice.lib.logging.{LogMarker, MarkerMap} @@ -14,6 +15,7 @@ import com.gu.mediaservice.model._ import com.gu.mediaservice.model.leases.LeasesByMedia import lib.DigestedFile import org.joda.time.{DateTime, DateTimeZone} +import org.mockito.ArgumentMatchers.any import org.mockito.Mockito.{times, verify, when} import org.scalatest.concurrent.ScalaFutures import org.scalatest.freespec.AnyFreeSpec @@ -21,11 +23,13 @@ import org.scalatest.matchers.should.Matchers import org.scalatest.time.{Millis, Span} import org.scalatestplus.mockito.MockitoSugar import play.api.libs.json.{JsArray, JsString} +import software.amazon.awssdk.services.s3vectors.model.PutVectorsResponse import test.lib.ResourceHelpers +import java.nio.file.Path import scala.concurrent.ExecutionContext.Implicits.global import scala.jdk.CollectionConverters._ -import scala.concurrent.Future +import scala.concurrent.{ExecutionContext, Future} class ProjectorTest extends AnyFreeSpec with Matchers with ScalaFutures with MockitoSugar { @@ -39,9 +43,11 @@ class ProjectorTest extends AnyFreeSpec with Matchers with ScalaFutures with Moc private val config = ImageUploadOpsCfg(new File("/tmp"), 256, 85d, Nil, "img-bucket", "thumb-bucket") + private val maybeEmbedder = None + private val s3 = mock[AmazonS3] private val auth = mock[Authentication] - private val projector = new Projector(config, s3, imageOperations, ImageProcessor.identity, auth) + private val projector = new Projector(config, s3, imageOperations, ImageProcessor.identity, auth, maybeEmbedder) // FIXME temporary ignored as test is not executable in CI/CD machine // because graphic lib files like srgb.icc, cmyk.icc are in root directory instead of resources diff --git a/kahuna/package-lock.json b/kahuna/package-lock.json index 33fd57b829..9a6893d467 100644 --- a/kahuna/package-lock.json +++ b/kahuna/package-lock.json @@ -5,11 +5,11 @@ "packages": { "": { "dependencies": { - "@babel/polyfill": "^7.8.7", - "@guardian/cql": "^1.8.1", - "@guardian/user-telemetry-client": "^1.1.0", - "@sentry/browser": "^6.11.0", - "@sentry/integrations": "^6.11.0", + "@babel/polyfill": "7.8.7", + "@guardian/cql": "1.8.2", + "@guardian/user-telemetry-client": "1.1.0", + "@sentry/browser": "6.11.0", + "@sentry/integrations": "6.11.0", "angular": "1.8.3", "angular-animate": "1.8.3", "angular-cookies": "1.8.3", @@ -2103,72 +2103,11 @@ "node": ">=10.0.0" } }, - "node_modules/@eslint-community/eslint-utils": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz", - "integrity": "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw==", - "license": "MIT", - "dependencies": { - "eslint-visitor-keys": "^3.4.3" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - }, - "peerDependencies": { - "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" - } - }, - "node_modules/@eslint-community/regexpp": { - "version": "4.12.1", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", - "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", - "license": "MIT", - "engines": { - "node": "^12.0.0 || ^14.0.0 || >=16.0.0" - } - }, - "node_modules/@eslint/config-array": { - "version": "0.21.0", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.0.tgz", - "integrity": "sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ==", - "license": "Apache-2.0", - "dependencies": { - "@eslint/object-schema": "^2.1.6", - "debug": "^4.3.1", - "minimatch": "^3.1.2" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/config-helpers": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.3.1.tgz", - "integrity": "sha512-xR93k9WhrDYpXHORXpxVL5oHj3Era7wo6k/Wd8/IsQNnZUTzkGS29lyn3nAT05v6ltUuTFVCCYDEGfy2Or/sPA==", - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/core": { - "version": "0.15.2", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.2.tgz", - "integrity": "sha512-78Md3/Rrxh83gCxoUc0EiciuOHsIITzLy53m3d9UyiW8y9Dj2D29FeETqyKA+BRK76tnTp6RXWb3pCay8Oyomg==", - "license": "Apache-2.0", - "dependencies": { - "@types/json-schema": "^7.0.15" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, "node_modules/@eslint/eslintrc": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-1.0.5.tgz", "integrity": "sha512-BLxsnmK3KyPunz5wmCCpqy0YelEoxxGmH73Is+Z74oOTMtExcjkr3dDR6quwrjh1YspA8DH9gnX1o069KiS9AQ==", + "dev": true, "dependencies": { "ajv": "^6.12.4", "debug": "^4.3.2", @@ -2187,12 +2126,14 @@ "node_modules/@eslint/eslintrc/node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==" + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true }, "node_modules/@eslint/eslintrc/node_modules/globals": { "version": "13.12.0", "resolved": "https://registry.npmjs.org/globals/-/globals-13.12.0.tgz", "integrity": "sha512-uS8X6lSKN2JumVoXrbUz+uG4BYG+eiawqm3qFcT7ammfbUHeCBoJMlHcec/S3krSk73/AE/f0szYFmgAA3kYZg==", + "dev": true, "dependencies": { "type-fest": "^0.20.2" }, @@ -2207,6 +2148,7 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dev": true, "dependencies": { "argparse": "^2.0.1" }, @@ -2214,40 +2156,6 @@ "js-yaml": "bin/js-yaml.js" } }, - "node_modules/@eslint/js": { - "version": "9.33.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.33.0.tgz", - "integrity": "sha512-5K1/mKhWaMfreBGJTwval43JJmkip0RmM+3+IuqupeSKNC/Th2Kc7ucaq5ovTSra/OOKB9c58CGSz3QMVbWt0A==", - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://eslint.org/donate" - } - }, - "node_modules/@eslint/object-schema": { - "version": "2.1.6", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.6.tgz", - "integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==", - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/plugin-kit": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.5.tgz", - "integrity": "sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w==", - "license": "Apache-2.0", - "dependencies": { - "@eslint/core": "^0.15.2", - "levn": "^0.4.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, "node_modules/@floating-ui/core": { "version": "1.7.3", "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz", @@ -2274,17 +2182,12 @@ "license": "MIT" }, "node_modules/@guardian/cql": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/@guardian/cql/-/cql-1.8.1.tgz", - "integrity": "sha512-eb4eJiK0o98C5RLVXAT1IA/hBdanHya1/GoyW3dh5bJL61iPKxrc+x9x9CB+wql/xeOfF4ZC4DisH/dr2g1vrg==", + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/@guardian/cql/-/cql-1.8.2.tgz", + "integrity": "sha512-SnWJ7zmUZgOHt2+MSQcN/md1XlGU8x4XUmIG6ZOZtcg96LOvTEu858VEjaL9bV7A325WxJISkjGK5S2yrkdmEQ==", "dependencies": { "@floating-ui/dom": "^1.6.11", - "eslint": "^9.14.0", - "eslint-config-prettier": "^9.1.0", - "eslint-plugin-prettier": "^5.2.1", - "happy-dom": "^15.11.7", "preact": "^10.25.4", - "prettier": "^3.5.3", "prosemirror-commands": "^1.6.0", "prosemirror-history": "^1.4.1", "prosemirror-keymap": "^1.2.2", @@ -2295,338 +2198,6 @@ "prosemirror-view": "^1.34.3" } }, - "node_modules/@guardian/cql/node_modules/@eslint/eslintrc": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz", - "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==", - "license": "MIT", - "dependencies": { - "ajv": "^6.12.4", - "debug": "^4.3.2", - "espree": "^10.0.1", - "globals": "^14.0.0", - "ignore": "^5.2.0", - "import-fresh": "^3.2.1", - "js-yaml": "^4.1.0", - "minimatch": "^3.1.2", - "strip-json-comments": "^3.1.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@guardian/cql/node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "license": "MIT" - }, - "node_modules/@guardian/cql/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/@guardian/cql/node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "license": "Python-2.0" - }, - "node_modules/@guardian/cql/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/@guardian/cql/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/@guardian/cql/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "license": "MIT" - }, - "node_modules/@guardian/cql/node_modules/escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@guardian/cql/node_modules/eslint": { - "version": "9.33.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.33.0.tgz", - "integrity": "sha512-TS9bTNIryDzStCpJN93aC5VRSW3uTx9sClUn4B87pwiCaJh220otoI0X8mJKr+VcPtniMdN8GKjlwgWGUv5ZKA==", - "license": "MIT", - "dependencies": { - "@eslint-community/eslint-utils": "^4.2.0", - "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.21.0", - "@eslint/config-helpers": "^0.3.1", - "@eslint/core": "^0.15.2", - "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "9.33.0", - "@eslint/plugin-kit": "^0.3.5", - "@humanfs/node": "^0.16.6", - "@humanwhocodes/module-importer": "^1.0.1", - "@humanwhocodes/retry": "^0.4.2", - "@types/estree": "^1.0.6", - "@types/json-schema": "^7.0.15", - "ajv": "^6.12.4", - "chalk": "^4.0.0", - "cross-spawn": "^7.0.6", - "debug": "^4.3.2", - "escape-string-regexp": "^4.0.0", - "eslint-scope": "^8.4.0", - "eslint-visitor-keys": "^4.2.1", - "espree": "^10.4.0", - "esquery": "^1.5.0", - "esutils": "^2.0.2", - "fast-deep-equal": "^3.1.3", - "file-entry-cache": "^8.0.0", - "find-up": "^5.0.0", - "glob-parent": "^6.0.2", - "ignore": "^5.2.0", - "imurmurhash": "^0.1.4", - "is-glob": "^4.0.0", - "json-stable-stringify-without-jsonify": "^1.0.1", - "lodash.merge": "^4.6.2", - "minimatch": "^3.1.2", - "natural-compare": "^1.4.0", - "optionator": "^0.9.3" - }, - "bin": { - "eslint": "bin/eslint.js" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://eslint.org/donate" - }, - "peerDependencies": { - "jiti": "*" - }, - "peerDependenciesMeta": { - "jiti": { - "optional": true - } - } - }, - "node_modules/@guardian/cql/node_modules/eslint-scope": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", - "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", - "license": "BSD-2-Clause", - "dependencies": { - "esrecurse": "^4.3.0", - "estraverse": "^5.2.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@guardian/cql/node_modules/eslint-visitor-keys": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", - "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@guardian/cql/node_modules/espree": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", - "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", - "license": "BSD-2-Clause", - "dependencies": { - "acorn": "^8.15.0", - "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^4.2.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@guardian/cql/node_modules/estraverse": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", - "license": "BSD-2-Clause", - "engines": { - "node": ">=4.0" - } - }, - "node_modules/@guardian/cql/node_modules/file-entry-cache": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", - "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", - "license": "MIT", - "dependencies": { - "flat-cache": "^4.0.0" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@guardian/cql/node_modules/find-up": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", - "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", - "license": "MIT", - "dependencies": { - "locate-path": "^6.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@guardian/cql/node_modules/flat-cache": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", - "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", - "license": "MIT", - "dependencies": { - "flatted": "^3.2.9", - "keyv": "^4.5.4" - }, - "engines": { - "node": ">=16" - } - }, - "node_modules/@guardian/cql/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/@guardian/cql/node_modules/ignore": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", - "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/@guardian/cql/node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "license": "MIT", - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/@guardian/cql/node_modules/locate-path": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", - "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", - "license": "MIT", - "dependencies": { - "p-locate": "^5.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@guardian/cql/node_modules/p-locate": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", - "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", - "license": "MIT", - "dependencies": { - "p-limit": "^3.0.2" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@guardian/cql/node_modules/path-exists": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/@guardian/cql/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/@guardian/user-telemetry-client": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@guardian/user-telemetry-client/-/user-telemetry-client-1.1.0.tgz", @@ -2635,45 +2206,11 @@ "lodash": "^4.17.20" } }, - "node_modules/@humanfs/core": { - "version": "0.19.1", - "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", - "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", - "license": "Apache-2.0", - "engines": { - "node": ">=18.18.0" - } - }, - "node_modules/@humanfs/node": { - "version": "0.16.6", - "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.6.tgz", - "integrity": "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw==", - "license": "Apache-2.0", - "dependencies": { - "@humanfs/core": "^0.19.1", - "@humanwhocodes/retry": "^0.3.0" - }, - "engines": { - "node": ">=18.18.0" - } - }, - "node_modules/@humanfs/node/node_modules/@humanwhocodes/retry": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.3.1.tgz", - "integrity": "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==", - "license": "Apache-2.0", - "engines": { - "node": ">=18.18" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, "node_modules/@humanwhocodes/config-array": { "version": "0.9.2", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.9.2.tgz", "integrity": "sha512-UXOuFCGcwciWckOpmfKDq/GyhlTf9pN/BzG//x8p8zTOFEcGuA68ANXheFS0AGvy3qgZqLBUkMs7hqzqCKOVwA==", + "dev": true, "dependencies": { "@humanwhocodes/object-schema": "^1.2.1", "debug": "^4.1.1", @@ -2683,36 +2220,11 @@ "node": ">=10.10.0" } }, - "node_modules/@humanwhocodes/module-importer": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", - "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", - "license": "Apache-2.0", - "engines": { - "node": ">=12.22" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, "node_modules/@humanwhocodes/object-schema": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-1.2.1.tgz", - "integrity": "sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==" - }, - "node_modules/@humanwhocodes/retry": { - "version": "0.4.3", - "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", - "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", - "license": "Apache-2.0", - "engines": { - "node": ">=18.18" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } + "integrity": "sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==", + "dev": true }, "node_modules/@isaacs/cliui": { "version": "8.0.2", @@ -3773,6 +3285,7 @@ "version": "0.2.7", "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.7.tgz", "integrity": "sha512-YLT9Zo3oNPJoBjBc4q8G2mjU4tqIbf5CEOORbUUr48dCD9q3umJ3IPlVqOqDakPfd2HuwccBaqlGhN4Gmr5OWg==", + "dev": true, "license": "MIT", "engines": { "node": "^12.20.0 || ^14.18.0 || >=16.0.0" @@ -3962,7 +3475,7 @@ "version": "8.21.2", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.21.2.tgz", "integrity": "sha512-EMpxUyystd3uZVByZap1DACsMXvb82ypQnGn89e1Y0a+LYu3JJscUd/gqhRsVFDkaD2MIiWo0MT8EfXr3DGRKw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@types/estree": "*", @@ -3983,7 +3496,7 @@ "version": "0.0.51", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-0.0.51.tgz", "integrity": "sha512-CuPgU6f3eT/XgKKPqKd/gLZV1Xmvf1a2R5POBOGQa6uv82xpls89HU5zKeVoyR8XzHd1RGNOlQlvUe3CFkjWNQ==", - "devOptional": true + "dev": true }, "node_modules/@types/istanbul-lib-coverage": { "version": "2.0.6", @@ -4026,6 +3539,7 @@ "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, "license": "MIT" }, "node_modules/@types/lodash": { @@ -4572,6 +4086,7 @@ "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, "bin": { "acorn": "bin/acorn" }, @@ -4592,6 +4107,7 @@ "version": "5.3.2", "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } @@ -4609,6 +4125,7 @@ "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.1", @@ -4736,6 +4253,7 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.1.tgz", "integrity": "sha512-JoX0apGbHaUJBNl6yF+p6JAFYZ666/hhCGKN5t9QFjbJQKUU/g8MNbFDbvfrgKXvI1QpZplPOnwIo99lX/AAmA==", + "dev": true, "engines": { "node": ">=6" } @@ -4772,6 +4290,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, "engines": { "node": ">=8" } @@ -5426,7 +4945,8 @@ "node_modules/balanced-match": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz", - "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=" + "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=", + "dev": true }, "node_modules/base": { "version": "0.11.2", @@ -5521,6 +5041,7 @@ "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", @@ -5648,6 +5169,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, "engines": { "node": ">=6" } @@ -5974,7 +5496,8 @@ "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=" + "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=", + "dev": true }, "node_modules/convert-source-map": { "version": "2.0.0", @@ -6073,6 +5596,7 @@ "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", @@ -6086,6 +5610,7 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -6250,6 +5775,7 @@ "version": "4.4.1", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -6305,7 +5831,8 @@ "node_modules/deep-is": { "version": "0.1.3", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.3.tgz", - "integrity": "sha1-s2nW+128E+7PUk+RsHD+7cNXzzQ=" + "integrity": "sha1-s2nW+128E+7PUk+RsHD+7cNXzzQ=", + "dev": true }, "node_modules/deepmerge": { "version": "4.3.1", @@ -6414,6 +5941,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", + "dev": true, "license": "MIT", "dependencies": { "esutils": "^2.0.2" @@ -6605,6 +6133,7 @@ "version": "2.3.6", "resolved": "https://registry.npmjs.org/enquirer/-/enquirer-2.3.6.tgz", "integrity": "sha512-yjNnPr315/FjS4zIsUxYguYUPP2e1NK4d7E7ZOLiyYCcbFBiTMyID+2wvm2w6+pZ/odMA7cRkjhsPbltwBOrLg==", + "dev": true, "dependencies": { "ansi-colors": "^4.1.1" }, @@ -6685,6 +6214,7 @@ "version": "8.6.0", "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.6.0.tgz", "integrity": "sha512-UvxdOJ7mXFlw7iuHZA4jmzPaUqIw54mZrv+XPYKNbKdLR0et4rf60lIZUU9kiNtnzzMzGWxMV+tQ7uG7JG8DPw==", + "dev": true, "license": "MIT", "dependencies": { "@eslint/eslintrc": "^1.0.5", @@ -6736,48 +6266,6 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/eslint-config-prettier": { - "version": "9.1.2", - "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-9.1.2.tgz", - "integrity": "sha512-iI1f+D2ViGn+uvv5HuHVUamg8ll4tN+JRHGc6IJi4TP9Kl976C57fzPXgseXNs8v0iA8aSJpHsTWjDb9QJamGQ==", - "license": "MIT", - "bin": { - "eslint-config-prettier": "bin/cli.js" - }, - "peerDependencies": { - "eslint": ">=7.0.0" - } - }, - "node_modules/eslint-plugin-prettier": { - "version": "5.5.4", - "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.4.tgz", - "integrity": "sha512-swNtI95SToIz05YINMA6Ox5R057IMAmWZ26GqPxusAp1TZzj+IdY9tXNWWD3vkF/wEqydCONcwjTFpxybBqZsg==", - "license": "MIT", - "dependencies": { - "prettier-linter-helpers": "^1.0.0", - "synckit": "^0.11.7" - }, - "engines": { - "node": "^14.18.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint-plugin-prettier" - }, - "peerDependencies": { - "@types/eslint": ">=8.0.0", - "eslint": ">=8.0.0", - "eslint-config-prettier": ">= 7.0.0 <10.0.0 || >=10.1.0", - "prettier": ">=3.0.0" - }, - "peerDependenciesMeta": { - "@types/eslint": { - "optional": true - }, - "eslint-config-prettier": { - "optional": true - } - } - }, "node_modules/eslint-plugin-react": { "version": "7.28.0", "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.28.0.tgz", @@ -6855,6 +6343,7 @@ "version": "7.1.0", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.1.0.tgz", "integrity": "sha512-aWwkhnS0qAXqNOgKOK0dJ2nvzEbhEvpy8OlJ9kZ0FeZnA6zpjv1/Vei+puGFFX7zkPCkHHXb7IDX3A+7yPrRWg==", + "dev": true, "license": "MIT", "dependencies": { "esrecurse": "^4.3.0", @@ -6868,6 +6357,7 @@ "version": "5.3.0", "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, "engines": { "node": ">=4.0" } @@ -6876,6 +6366,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/eslint-utils/-/eslint-utils-3.0.0.tgz", "integrity": "sha512-uuQC43IGctw68pJA1RgbQS8/NP7rch6Cwd4j3ZBtgo4/8Flj4eGE7ZYSZRN3iq5pVUv6GPdW5Z1RFleo84uLDA==", + "dev": true, "license": "MIT", "dependencies": { "eslint-visitor-keys": "^2.0.0" @@ -6894,6 +6385,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-2.1.0.tgz", "integrity": "sha512-0rSmRBzXgDzIsD6mGdJgevzgezI534Cer5L/vyMX0kHzT/jiB43jRhd9YUlMGYLQy2zprNmoT8qasCGtY+QaKw==", + "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -6903,6 +6395,7 @@ "version": "3.4.3", "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, "license": "Apache-2.0", "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" @@ -6915,6 +6408,7 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, "license": "BSD-3-Clause", "dependencies": { "color-convert": "^2.0.1" @@ -6929,12 +6423,14 @@ "node_modules/eslint/node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==" + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true }, "node_modules/eslint/node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -6950,6 +6446,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, "license": "BSD-3-Clause", "dependencies": { "color-name": "~1.1.4" @@ -6961,12 +6458,14 @@ "node_modules/eslint/node_modules/color-name": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true }, "node_modules/eslint/node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -6979,6 +6478,7 @@ "version": "13.12.0", "resolved": "https://registry.npmjs.org/globals/-/globals-13.12.0.tgz", "integrity": "sha512-uS8X6lSKN2JumVoXrbUz+uG4BYG+eiawqm3qFcT7ammfbUHeCBoJMlHcec/S3krSk73/AE/f0szYFmgAA3kYZg==", + "dev": true, "license": "MIT", "dependencies": { "type-fest": "^0.20.2" @@ -6994,6 +6494,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, "engines": { "node": ">=8" } @@ -7002,6 +6503,7 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dev": true, "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -7014,6 +6516,7 @@ "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, "license": "BSD-3-Clause", "dependencies": { "prelude-ls": "^1.2.1", @@ -7027,6 +6530,7 @@ "version": "7.6.2", "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.2.tgz", "integrity": "sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w==", + "dev": true, "bin": { "semver": "bin/semver.js" }, @@ -7038,6 +6542,7 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, "license": "MIT", "dependencies": { "has-flag": "^4.0.0" @@ -7050,6 +6555,7 @@ "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, "license": "MIT", "dependencies": { "prelude-ls": "^1.2.1" @@ -7062,6 +6568,7 @@ "version": "9.3.0", "resolved": "https://registry.npmjs.org/espree/-/espree-9.3.0.tgz", "integrity": "sha512-d/5nCsb0JcqsSEeQzFZ8DH1RmxPcglRWh24EFTlUEmCKoehXGdpsx0RkHDubqUI8LSAIKMQp4r9SzQ3n+sm4HQ==", + "dev": true, "dependencies": { "acorn": "^8.7.0", "acorn-jsx": "^5.3.1", @@ -7088,6 +6595,7 @@ "version": "1.6.0", "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "dev": true, "license": "BSD-3-Clause", "dependencies": { "estraverse": "^5.1.0" @@ -7100,6 +6608,7 @@ "version": "5.3.0", "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, "license": "MIT", "engines": { "node": ">=4.0" @@ -7109,6 +6618,7 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, "license": "MIT", "dependencies": { "estraverse": "^5.2.0" @@ -7121,6 +6631,7 @@ "version": "5.3.0", "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, "engines": { "node": ">=4.0" } @@ -7138,6 +6649,7 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, "engines": { "node": ">=0.10.0" } @@ -7441,14 +6953,9 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, "license": "MIT" }, - "node_modules/fast-diff": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz", - "integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==", - "license": "Apache-2.0" - }, "node_modules/fast-glob": { "version": "3.2.10", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.10.tgz", @@ -7481,12 +6988,14 @@ "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==" + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true }, "node_modules/fast-levenshtein": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", - "integrity": "sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc=" + "integrity": "sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc=", + "dev": true }, "node_modules/fast-xml-parser": { "version": "4.4.0", @@ -7540,6 +7049,7 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", + "dev": true, "license": "MIT", "dependencies": { "flat-cache": "^3.0.4" @@ -7833,6 +7343,7 @@ "version": "3.0.4", "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.0.4.tgz", "integrity": "sha512-dm9s5Pw7Jc0GvMYbshN6zchCA9RgQlzzEZX3vylR9IqFfS8XciblUXOKfW6SiuJ0e13eDYZoZV5wdrev7P3Nwg==", + "dev": true, "license": "MIT", "dependencies": { "flatted": "^3.1.0", @@ -7846,6 +7357,7 @@ "version": "3.0.2", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "dev": true, "dependencies": { "glob": "^7.1.3" }, @@ -7860,6 +7372,7 @@ "version": "3.3.3", "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "dev": true, "license": "ISC" }, "node_modules/for-in": { @@ -8076,7 +7589,8 @@ "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=" + "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=", + "dev": true }, "node_modules/fsevents": { "version": "2.3.3", @@ -8106,7 +7620,8 @@ "node_modules/functional-red-black-tree": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz", - "integrity": "sha1-GwqzvVU7Kg1jmdKcDj6gslIHgyc=" + "integrity": "sha1-GwqzvVU7Kg1jmdKcDj6gslIHgyc=", + "dev": true }, "node_modules/gensync": { "version": "1.0.0-beta.2", @@ -8187,6 +7702,7 @@ "version": "7.1.6", "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz", "integrity": "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==", + "dev": true, "license": "MIT", "dependencies": { "fs.realpath": "^1.0.0", @@ -8207,6 +7723,7 @@ "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, "license": "MIT", "dependencies": { "is-glob": "^4.0.3" @@ -8219,6 +7736,7 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, "license": "MIT", "dependencies": { "is-extglob": "^2.1.1" @@ -8276,18 +7794,6 @@ "which": "bin/which" } }, - "node_modules/globals": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", - "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/globby": { "version": "11.1.0", "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", @@ -8323,32 +7829,6 @@ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "dev": true }, - "node_modules/happy-dom": { - "version": "15.11.7", - "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-15.11.7.tgz", - "integrity": "sha512-KyrFvnl+J9US63TEzwoiJOQzZBJY7KgBushJA8X61DMbNsH+2ONkDuLDnCnwUiPTF42tLoEmrPyoqbenVA5zrg==", - "license": "MIT", - "dependencies": { - "entities": "^4.5.0", - "webidl-conversions": "^7.0.0", - "whatwg-mimetype": "^3.0.0" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/happy-dom/node_modules/entities": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", - "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.12" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, "node_modules/has": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", @@ -8753,6 +8233,7 @@ "version": "4.0.6", "resolved": "https://registry.npmjs.org/ignore/-/ignore-4.0.6.tgz", "integrity": "sha512-cyFDKrqc/YdcWFniJhzI42+AzS+gNwmUzOSFcRCQYwySuBBBy/KjuxWLZ/FHEH6Moq1NizMOBWyTcv8O4OZIMg==", + "dev": true, "engines": { "node": ">= 4" } @@ -8942,6 +8423,7 @@ "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, "license": "MIT", "dependencies": { "parent-module": "^1.0.0", @@ -8958,6 +8440,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, "license": "MIT", "engines": { "node": ">=4" @@ -8987,6 +8470,7 @@ "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", "integrity": "sha1-khi5srkoojixPcT7a21XbyMUU+o=", + "dev": true, "engines": { "node": ">=0.8.19" } @@ -8995,6 +8479,7 @@ "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=", + "dev": true, "license": "MIT", "dependencies": { "once": "^1.3.0", @@ -9221,6 +8706,7 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "integrity": "sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -9249,6 +8735,7 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.1.tgz", "integrity": "sha512-5G0tKtBTFImOqDnLB2hG6Bp2qcKEFduo4tZu9MT/H6NQv/ghhy30o55ufafxJ/LdH79LLs2Kfrn85TLKyA7BUg==", + "dev": true, "license": "MIT", "dependencies": { "is-extglob": "^2.1.1" @@ -9429,7 +8916,8 @@ "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=" + "integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=", + "dev": true }, "node_modules/isobject": { "version": "3.0.1", @@ -11679,12 +11167,6 @@ "node": ">=6" } }, - "node_modules/json-buffer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", - "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", - "license": "MIT" - }, "node_modules/json-parse-even-better-errors": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", @@ -11703,12 +11185,14 @@ "node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==" + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", - "integrity": "sha1-nbe1lJatPzz+8wp1FC0tkwrXJlE=" + "integrity": "sha1-nbe1lJatPzz+8wp1FC0tkwrXJlE=", + "dev": true }, "node_modules/json5": { "version": "2.2.3", @@ -11816,15 +11300,6 @@ "node": ">=8" } }, - "node_modules/keyv": { - "version": "4.5.4", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", - "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", - "license": "MIT", - "dependencies": { - "json-buffer": "3.0.1" - } - }, "node_modules/kind-of": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", @@ -11860,6 +11335,7 @@ "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, "license": "MIT", "dependencies": { "prelude-ls": "^1.2.1", @@ -11982,7 +11458,8 @@ "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", - "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==" + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true }, "node_modules/loose-envify": { "version": "1.4.0", @@ -12177,6 +11654,7 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, "dependencies": { "brace-expansion": "^1.1.7" }, @@ -12239,6 +11717,7 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, "license": "MIT" }, "node_modules/nanoid": { @@ -12300,7 +11779,8 @@ "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", - "integrity": "sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc=" + "integrity": "sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc=", + "dev": true }, "node_modules/neo-async": { "version": "2.6.2", @@ -12988,6 +12468,7 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", + "dev": true, "dependencies": { "wrappy": "1" } @@ -13012,6 +12493,7 @@ "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, "license": "MIT", "dependencies": { "deep-is": "^0.1.3", @@ -13075,6 +12557,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, "dependencies": { "yocto-queue": "^0.1.0" }, @@ -13204,6 +12687,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, "dependencies": { "callsites": "^3.0.0" }, @@ -13317,6 +12801,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=", + "dev": true, "engines": { "node": ">=0.10.0" } @@ -13589,38 +13074,12 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.8.0" } }, - "node_modules/prettier": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz", - "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", - "license": "MIT", - "bin": { - "prettier": "bin/prettier.cjs" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/prettier/prettier?sponsor=1" - } - }, - "node_modules/prettier-linter-helpers": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/prettier-linter-helpers/-/prettier-linter-helpers-1.0.0.tgz", - "integrity": "sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==", - "license": "MIT", - "dependencies": { - "fast-diff": "^1.1.2" - }, - "engines": { - "node": ">=6.0.0" - } - }, "node_modules/pretty-format": { "version": "30.0.2", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.0.2.tgz", @@ -13665,6 +13124,7 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "dev": true, "engines": { "node": ">=0.4.0" } @@ -13806,6 +13266,7 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, "engines": { "node": ">=6" } @@ -14020,6 +13481,7 @@ "version": "3.2.0", "resolved": "https://registry.npmjs.org/regexpp/-/regexpp-3.2.0.tgz", "integrity": "sha512-pq2bWo9mVD43nbts2wGv17XLiNLya+GklZ8kaDLV2Z08gDCsGpnKn9BFMepvWuHCbyVvY7J5o5+BVvoQbmlJLg==", + "dev": true, "engines": { "node": ">=8" }, @@ -14413,6 +13875,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, "dependencies": { "shebang-regex": "^3.0.0" }, @@ -14424,6 +13887,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, "engines": { "node": ">=8" } @@ -15006,6 +14470,7 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, "dependencies": { "ansi-regex": "^5.0.1" }, @@ -15059,6 +14524,7 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, "engines": { "node": ">=8" }, @@ -15171,6 +14637,7 @@ "version": "0.11.8", "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.8.tgz", "integrity": "sha512-+XZ+r1XGIJGeQk3VvXhT6xx/VpbHsRzsTkGgF6E5RX9TTXD0118l87puaEBZ566FhqblC6U0d4XnubznJDm30A==", + "dev": true, "license": "MIT", "dependencies": { "@pkgr/core": "^0.2.4" @@ -15351,7 +14818,8 @@ "node_modules/text-table": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", - "integrity": "sha1-f17oI66AUgfACvLfSoTsP8+lcLQ=" + "integrity": "sha1-f17oI66AUgfACvLfSoTsP8+lcLQ=", + "dev": true }, "node_modules/theseus": { "version": "0.5.2", @@ -15705,6 +15173,7 @@ "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, "license": "MIT", "dependencies": { "prelude-ls": "^1.2.1" @@ -15727,6 +15196,7 @@ "version": "0.20.2", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "dev": true, "engines": { "node": ">=10" }, @@ -15975,6 +15445,7 @@ "version": "4.2.2", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.2.2.tgz", "integrity": "sha512-KY9Frmirql91X2Qgjry0Wd4Y+YTdrdZheS8TFwvkbLWf/G5KNJDCh6pKL5OZctEW4+0Baa5idK2ZQuELRwPznQ==", + "dev": true, "dependencies": { "punycode": "^2.1.0" } @@ -16016,7 +15487,8 @@ "node_modules/v8-compile-cache": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/v8-compile-cache/-/v8-compile-cache-2.3.0.tgz", - "integrity": "sha512-l8lCEmLcLYZh4nbunNZvQCJc5pv7+RCwa8q/LdUx8u7lsWvPDKmpodJAJNwkAhJC//dFY48KuIEmjtd4RViDrA==" + "integrity": "sha512-l8lCEmLcLYZh4nbunNZvQCJc5pv7+RCwa8q/LdUx8u7lsWvPDKmpodJAJNwkAhJC//dFY48KuIEmjtd4RViDrA==", + "dev": true }, "node_modules/v8-to-istanbul": { "version": "9.3.0", @@ -16079,6 +15551,7 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, "engines": { "node": ">=12" } @@ -16349,15 +15822,6 @@ "node": ">=18" } }, - "node_modules/whatwg-mimetype": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", - "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==", - "license": "MIT", - "engines": { - "node": ">=12" - } - }, "node_modules/whatwg-url": { "version": "14.2.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", @@ -16376,6 +15840,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, "dependencies": { "isexe": "^2.0.0" }, @@ -16418,6 +15883,7 @@ "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, "engines": { "node": ">=0.10.0" } @@ -16572,7 +16038,8 @@ "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=" + "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=", + "dev": true }, "node_modules/write-file-atomic": { "version": "5.0.1", @@ -16787,6 +16254,7 @@ "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, "license": "MIT", "engines": { "node": ">=10" diff --git a/kahuna/package.json b/kahuna/package.json index de73fba305..72cafb3f83 100644 --- a/kahuna/package.json +++ b/kahuna/package.json @@ -1,7 +1,7 @@ { "dependencies": { "@babel/polyfill": "7.8.7", - "@guardian/cql": "1.8.1", + "@guardian/cql": "1.8.2", "@guardian/user-telemetry-client": "1.1.0", "@sentry/browser": "6.11.0", "@sentry/integrations": "6.11.0", diff --git a/kahuna/public/js/components/gr-cql-input/gr-cql-input.ts b/kahuna/public/js/components/gr-cql-input/gr-cql-input.ts index 03fcc2cef0..b8feac8c05 100644 --- a/kahuna/public/js/components/gr-cql-input/gr-cql-input.ts +++ b/kahuna/public/js/components/gr-cql-input/gr-cql-input.ts @@ -92,7 +92,9 @@ grCqlInput.directive< "ArrowUp", "ArrowDown", "ArrowLeft", - "ArrowRight" + "ArrowRight", + "PageUp", + "PageDown" ]; ["keydown", "keyup", "keypress"].forEach((eventType) => { cqlInput.addEventListener(eventType, (e) => { diff --git a/kahuna/public/js/components/gr-notifications-banner/gr-notifications-banner.tsx b/kahuna/public/js/components/gr-notifications-banner/gr-notifications-banner.tsx index d8b81d406c..0b5d93e211 100644 --- a/kahuna/public/js/components/gr-notifications-banner/gr-notifications-banner.tsx +++ b/kahuna/public/js/components/gr-notifications-banner/gr-notifications-banner.tsx @@ -151,12 +151,17 @@ const NotificationsBanner: React.FC = () => { }); }; - const newNotification = (event:any) => { - const notification = event.detail; - setNotifications(prev_notifs => mergeArraysByKey(prev_notifs, [notification], 'announceId')); - }; - useEffect(() => { + const newNotification = (event:any) => { + const notification = event.detail; + setNotifications(prev_notifs => mergeArraysByKey(prev_notifs, [notification], 'announceId')); + }; + + const removeNotification = (event:any) => { + const notificationId = event.detail.announceId; + setNotifications(prev_notifs => prev_notifs.filter(notif => notif.announceId !== notificationId)); + }; + const announce = window._clientConfig.announcements; const tdy = todayStr(); let notif_cookie = getCookie(NOTIFICATION_COOKIE); @@ -175,6 +180,7 @@ const NotificationsBanner: React.FC = () => { document.addEventListener("scroll", autoHideListener); document.addEventListener("keydown", autoHideListener); window.addEventListener("newNotification", newNotification); + window.addEventListener("removeNotification", removeNotification); // clean up cookie if (notif_cookie) { @@ -190,6 +196,7 @@ const NotificationsBanner: React.FC = () => { document.removeEventListener("scroll", autoHideListener); document.removeEventListener("keydown", autoHideListener); window.removeEventListener("newNotification", newNotification); + window.removeEventListener("removeNotification", removeNotification); clearInterval(checkNotificationsRef); }; diff --git a/kahuna/public/js/components/gr-panel-button/gr-panel-button.js b/kahuna/public/js/components/gr-panel-button/gr-panel-button.js index ed7592056d..11e1540e1e 100644 --- a/kahuna/public/js/components/gr-panel-button/gr-panel-button.js +++ b/kahuna/public/js/components/gr-panel-button/gr-panel-button.js @@ -16,12 +16,22 @@ panelButton.controller('GrPanelButton', ['$scope', 'inject$', function($scope, i 'Panel name': ctrl.name, 'Action': action }); - ctrl.showPanel = () => panel.setHidden(false); + ctrl.showPanel = () => { + panel.setHidden(false); + window.dispatchEvent(new CustomEvent("panelShow", { + detail: {panel: ctrl.name}, + bubbles: true + })); + }; ctrl.lockPanel = () => panel.setLocked(true); ctrl.unlockPanel = () => panel.setLocked(false); ctrl.hidePanel = () => { panel.setLocked(false); panel.setHidden(true); + window.dispatchEvent(new CustomEvent("panelHide", { + detail: {panel: ctrl.name}, + bubbles: true + })); }; ctrl.toolTipPosition = () => { diff --git a/kahuna/public/js/components/gr-panels/gr-panels.js b/kahuna/public/js/components/gr-panels/gr-panels.js index 0d84e88ff2..5ffb9fb6ff 100644 --- a/kahuna/public/js/components/gr-panels/gr-panels.js +++ b/kahuna/public/js/components/gr-panels/gr-panels.js @@ -69,7 +69,13 @@ panels.directive('grPanel', ['$timeout', '$window', 'inject$', 'subscribe$', // Then hide the panel subscribe$(scope, scrollWhileVisAndUnlocked$, () => { - scope.$apply(() => panel.setHidden(true)); + scope.$apply(() => { + panel.setHidden(true); + window.dispatchEvent(new CustomEvent("panelHide", { + detail: {panel: "scroll"}, + bubbles: true + })); + }); }); } }; diff --git a/kahuna/public/js/components/gr-search-wrapper/gr-search-wrapper.html b/kahuna/public/js/components/gr-search-wrapper/gr-search-wrapper.html index 4e8e3238f3..d6eb238b6d 100644 --- a/kahuna/public/js/components/gr-search-wrapper/gr-search-wrapper.html +++ b/kahuna/public/js/components/gr-search-wrapper/gr-search-wrapper.html @@ -26,6 +26,4 @@ - - diff --git a/kahuna/public/js/components/gr-sort-control/base-sort-control.tsx b/kahuna/public/js/components/gr-sort-control/base-sort-control.tsx new file mode 100644 index 0000000000..8881a02635 --- /dev/null +++ b/kahuna/public/js/components/gr-sort-control/base-sort-control.tsx @@ -0,0 +1,204 @@ +import * as React from "react"; +import { useEffect, useState, KeyboardEvent } from "react"; +import { DefaultSortOption, CollectionSortOption } from "./gr-sort-control-config"; + +import "./gr-sort-control.css"; + +const SELECT_OPTION = "Select an option"; +const DEFAULT_OPTION = DefaultSortOption.value; +const COLLECTION_OPTION = CollectionSortOption.value; +const CONTROL_TITLE = "Sort by:"; +const SORT_ORDER = "Sort order"; +const PANEL_IDENTIFIER = "info"; +const SCROLL_IDENTIFIER = "scroll"; + +const downArrowIcon = () => + + + ; + +const emptyIcon = () => + + + ; + +const tickIcon = () => + + + ; + +const sortIcon = () => + + + + + ; + +export interface SortDropdownOption { + value: string; + label: string; + isCollection: boolean; + isTaken: boolean; +} + +export interface SortDropdownProps { + options: SortDropdownOption[]; + startSelectedOption?: SortDropdownOption | null; + onSelect: (option: SortDropdownOption) => void; + startHasCollection?: boolean | false; + panelVisible?: boolean | false; + isSimple?: boolean | false; +} + +const hasClassInSelfOrParent = (node: Element | null, className: string): boolean => { + if (node !== null && node.classList && node.classList.contains(className)) { + return true; + } + + while (node && node.parentNode && node.parentNode !== document) { + node = node.parentNode as Element; + if (node.classList && node.classList.contains(className)) { + return true; + } + } + + return false; +}; + +export const BaseSortControl: React.FC = ({ + options, + startSelectedOption, + onSelect, + startHasCollection, + panelVisible, + isSimple + }) => { + + const hasCollection = startHasCollection; + const startSort:SortDropdownOption = startSelectedOption ? startSelectedOption : DefaultSortOption; + const [isOpen, setIsOpen] = useState(false); + const [selectedOption, setSelection] = useState(startSort); + const [previousOption, setPrevious] = useState(startSort); + const [currentIndex, setCurrentIndex] = useState(-1); + const [isPanelVisible, setPanelVisible] = useState(panelVisible); + + const handleArrowKeys = (event:KeyboardEvent) => { + if (event.key === 'ArrowDown' || + event.key === 'ArrowUp' || + event.key === 'Enter' || + event.code === 'Space') { + event.preventDefault(); + event.stopPropagation(); + let rowCount = options.length; + if (!hasCollection) { --rowCount; } + if (event.key === 'ArrowDown') { + setCurrentIndex((prevIndex) => (prevIndex + 1) % rowCount); + } else if (event.key === 'ArrowUp') { + setCurrentIndex((prevIndex) => (prevIndex - 1 + rowCount) % rowCount); + } else if (event.key === 'Enter' || event.code === 'Space') { + if (!isOpen) { + setCurrentIndex(options.findIndex(opt => opt.value === selectedOption.value)); + setIsOpen(true); + } else { + handleOptionClick(options[currentIndex]); + } + } + } + }; + + useEffect(() => { + if (selectedOption && selectedOption !== previousOption && !selectedOption.isCollection ) { + setPrevious(selectedOption); + } + }, [selectedOption]); + + // initialisation + useEffect(() => { + const autoHideListener = (event: any) => { + if (event.type === "keydown" && event.key === "Escape") { + setIsOpen(false); + } else if (event.type !== "keydown") { + if (!hasClassInSelfOrParent(event.target, "sort-control")) { + setIsOpen(false); + } + } + }; + + const handlePanelShow = (event: any) => { + const panel = event.detail.panel; + if (panel === PANEL_IDENTIFIER) { + setPanelVisible(true); + } + }; + + const handlePanelHide = (event: any) => { + const panel = event.detail.panel; + if (panel === PANEL_IDENTIFIER || panel === SCROLL_IDENTIFIER) { + setPanelVisible(false); + } + }; + + window.addEventListener("mouseup", autoHideListener); + window.addEventListener("scroll", autoHideListener); + window.addEventListener("keydown", autoHideListener); + window.addEventListener("panelHide", handlePanelHide); + window.addEventListener("panelShow", handlePanelShow); + + // Clean up the event listener when the component unmounts + return () => { + setCurrentIndex(-1); + window.removeEventListener("mouseup", autoHideListener); + window.removeEventListener("scroll", autoHideListener); + window.removeEventListener("keydown", autoHideListener); + window.removeEventListener("panelHide", handlePanelHide); + window.removeEventListener("panelShow", handlePanelShow); + }; + }, []); + + const handleOptionClick = (option: SortDropdownOption) => { + setIsOpen(false); + if (option.value !== selectedOption.value) { + setSelection(option); + onSelect(option); + } + }; + + return ( +
+
{CONTROL_TITLE}
+
+
setIsOpen(!isOpen)}> +
+
{(selectedOption ? selectedOption.label : SELECT_OPTION)}
+
{downArrowIcon()}
+
+
+
setIsOpen(!isOpen)}> +
+
{sortIcon()}
+ {SORT_ORDER} +
+
+ {isOpen && ( + + + {options.map((option) => (hasCollection || option.value != COLLECTION_OPTION) && ( + -1 && options[currentIndex].value) === option.value ? "sort-dropdown-item sort-dropdown-highlight" : "sort-dropdown-item"} + key={option.value + "row"} + onClick={() => handleOptionClick(option)} + aria-label={option.label}> + + + + ))} + +
+ {(selectedOption.value == option.value) ? tickIcon() : emptyIcon()} + + {option.label} +
+ )} +
+
+ ); +}; diff --git a/kahuna/public/js/components/gr-sort-control/gr-extended-sort-control.tsx b/kahuna/public/js/components/gr-sort-control/gr-extended-sort-control.tsx new file mode 100644 index 0000000000..95e3115d28 --- /dev/null +++ b/kahuna/public/js/components/gr-sort-control/gr-extended-sort-control.tsx @@ -0,0 +1,110 @@ +import * as React from "react"; +import * as angular from "angular"; +import { react2angular } from "react2angular"; +import { useEffect, useState } from "react"; +import { BaseSortControl, SortDropdownOption } from "./base-sort-control"; +import { SortOptions, DefaultSortOption } from "./gr-sort-control-config"; +import { TabControl } from "../gr-tab-swap/gr-tab-swap"; + +import "./gr-sort-control.css"; + +export interface ExtendedSortProps { + onSortSelect: (option: SortDropdownOption, tabSelected: string, userTakenSelect: boolean) => void; + query?: string | ""; + orderBy?: string | ""; + infoPanelVisible?: boolean | false; + collectionsPanelVisible?: boolean | false; + userTakenSelect?: boolean | false; + noTakenDateCount?: number | 0; +} + +export interface ExtendedSortWrapperProps { + props: ExtendedSortProps; +} + +const checkForCollection = (query:string): boolean => /~"[a-zA-Z0-9 #-_.://]+"/.test(query); + +const ExtendedSortControl: React.FC = ({ props }) => { + + const noTakenDateClause = "-has:dateTaken"; + const takenDateClause = "has:dateTaken"; + const sortOptions = SortOptions; + const orderBy = props.orderBy; + const query = props.query; + + let startSortOption = DefaultSortOption; + if (!query.includes(noTakenDateClause) && (sortOptions.filter(o => o.value === orderBy)).length > 0) { + startSortOption = sortOptions.find(o => o.value === orderBy); + } + + const startHasCollection = checkForCollection(query); + const [selSortOption, setSortOption] = useState(startSortOption); + const [userTakenSelect, setUserTakenSelect] = useState(props.userTakenSelect); + const noTakenDateCount = props.noTakenDateCount; + const [hasCollection, setHasCollection] = useState(startHasCollection); + + const onSortSelect = (selOption: SortDropdownOption) => { + setSortOption(selOption); + setUserTakenSelect(selOption.isTaken); + props.onSortSelect(selOption, 'with', selOption.isTaken); + }; + + const onTabSelect = (withTaken: boolean) => { + let withTakenStr = 'with'; + if (!withTaken) { + withTakenStr = 'without'; + setSortOption(DefaultSortOption); + } + props.onSortSelect(selSortOption, withTakenStr, userTakenSelect); + }; + + // initialisation + useEffect(() => { + const handleLogoClick = (e: any) => { + setSortOption(DefaultSortOption); + setUserTakenSelect(false); + props.onSortSelect(DefaultSortOption, 'with', false); + }; + + const handleQueryChange = (e: any) => { + const newQuery = e.detail.query ? (" " + e.detail.query) : ""; + setHasCollection(checkForCollection(newQuery)); + if (userTakenSelect && !newQuery.includes(takenDateClause)) { + props.onSortSelect(DefaultSortOption, 'with', false); + } + }; + + window.addEventListener("logoClick", handleLogoClick); + window.addEventListener("queryChangeEvent", handleQueryChange); + + // Clean up the event listener when the component unmounts + return () => { + window.removeEventListener("logoClick", handleLogoClick); + window.removeEventListener("queryChangeEvent", handleQueryChange); + }; + + }, []); + + return ( +
+ + +
+ ); +}; + +export const extendedSortControl = angular.module('gr.extendedSortControl', []) + .component('extendedSortControl', react2angular(ExtendedSortControl, ["props"])); + diff --git a/kahuna/public/js/components/gr-sort-control/gr-sort-control-config.ts b/kahuna/public/js/components/gr-sort-control/gr-sort-control-config.ts index d6853d42dc..4c85c46be6 100644 --- a/kahuna/public/js/components/gr-sort-control/gr-sort-control-config.ts +++ b/kahuna/public/js/components/gr-sort-control/gr-sort-control-config.ts @@ -3,8 +3,8 @@ import {SortDropdownOption} from "./gr-sort-control"; export function manageSortSelection(newSelection:string): string { let newVal; switch (newSelection) { - case "uploadNewOld": - newVal = undefined; + case "newest": + newVal = "newest"; break; case "oldest": newVal = "oldest"; @@ -19,7 +19,7 @@ export function manageSortSelection(newSelection:string): string { newVal = "dateAddedToCollection"; break; default: - newVal = undefined; + newVal = "newest"; break; } return newVal; @@ -27,31 +27,40 @@ export function manageSortSelection(newSelection:string): string { export const SortOptions: SortDropdownOption[] = [ { - value: "uploadNewOld", + value: "newest", label: "Upload date (new to old)", - isCollection: false + isCollection: false, + isTaken: false }, { value: "oldest", label: "Upload date (old to new)", - isCollection: false + isCollection: false, + isTaken: false }, { value: "-taken", label: "Taken date (new to old)", - isCollection: false + isCollection: false, + isTaken: true }, { value: "taken", label: "Taken date (old to new)", - isCollection: false + isCollection: false, + isTaken: true }, { value: "dateAddedToCollection", label: "Added to collection (new to old)", - isCollection: true + isCollection: true, + isTaken: false } ]; export const DefaultSortOption: SortDropdownOption = SortOptions[0]; export const CollectionSortOption: SortDropdownOption = SortOptions[4]; +export const HAS_DATE_TAKEN = "has:dateTaken"; +export const HASNT_DATE_TAKEN = "-has:dateTaken"; +export const TAKEN_SORT = "taken"; +export const COLLECTION_SORT_VALUE = SortOptions[4].value; diff --git a/kahuna/public/js/components/gr-sort-control/gr-sort-control.css b/kahuna/public/js/components/gr-sort-control/gr-sort-control.css index 772c620b52..78b23df5a2 100644 --- a/kahuna/public/js/components/gr-sort-control/gr-sort-control.css +++ b/kahuna/public/js/components/gr-sort-control/gr-sort-control.css @@ -16,6 +16,10 @@ padding-bottom: 10px; } +.sort-panel-margin { + margin-right: 285px; +} + .sort-dropdown-toggle-advanced { display: block; padding-top: 7px; @@ -87,15 +91,15 @@ .sort-dropdown-menu { position: absolute; width: 250px; - right: 0px; + right: 10px; border: 1px solid #ccc; border-collapse: collapse; background-color: #333; - margin-right: 8px; } .sort-dropdown-item { border: 1px solid #ccc; + font-size: 1.4rem; } .sort-dropdown-item:hover { @@ -126,6 +130,19 @@ stroke: #fff; } +.extended-sort-control { + display:flex; + justify-content:space-between; + align-items:center; + padding:45px 1rem 0 1rem; + position:fixed; + left:0; + z-index:2; + background-color:#333; + width:100%; + box-sizing: border-box; +} + @media screen and (max-width: 880px) { .sort-selection-label { display: none; diff --git a/kahuna/public/js/components/gr-sort-control/gr-sort-control.tsx b/kahuna/public/js/components/gr-sort-control/gr-sort-control.tsx index 0438c6fac4..962565bae0 100644 --- a/kahuna/public/js/components/gr-sort-control/gr-sort-control.tsx +++ b/kahuna/public/js/components/gr-sort-control/gr-sort-control.tsx @@ -1,235 +1,96 @@ import * as React from "react"; import * as angular from "angular"; import { react2angular } from "react2angular"; -import { useEffect, useState, KeyboardEvent } from "react"; -import { DefaultSortOption, CollectionSortOption } from "./gr-sort-control-config"; +import { useEffect, useState } from "react"; +import { BaseSortControl, SortDropdownOption } from "./base-sort-control"; +import { SortOptions, DefaultSortOption } from "./gr-sort-control-config"; -import "./gr-sort-control.css"; - -const SELECT_OPTION = "Select an option"; -const DEFAULT_OPTION = DefaultSortOption.value; -const COLLECTION_OPTION = CollectionSortOption.value; -const CONTROL_TITLE = "Sort by:"; -const SORT_ORDER = "Sort order"; - -const downArrowIcon = () => - - - ; - -const emptyIcon = () => - - - ; - -const tickIcon = () => - - - ; - -const sortIcon = () => - - - - - ; - -export interface SortDropdownOption { - value: string; - label: string; - isCollection: boolean; -} - -export interface SortDropdownProps { - options: SortDropdownOption[]; - selectedOption?: SortDropdownOption | null; - onSelect: (option: SortDropdownOption) => void; +export type { SortDropdownOption }; +export interface SortProps { + onSortSelect: (option: SortDropdownOption) => void; query?: string | ""; orderBy?: string | ""; } export interface SortWrapperProps { - props: SortDropdownProps; + props: SortProps; } const checkForCollection = (query:string): boolean => /~"[a-zA-Z0-9 #-_.://]+"/.test(query); -const hasClassInSelfOrParent = (node: Element | null, className: string): boolean => { - if (node !== null && node.classList && node.classList.contains(className)) { - return true; - } - - while (node && node.parentNode && node.parentNode !== document) { - node = node.parentNode as Element; - if (node.classList && node.classList.contains(className)) { - return true; - } - } - - return false; -}; - const SortControl: React.FC = ({ props }) => { - const defOptVal:string = DEFAULT_OPTION; - const [hasCollection, setHasCollection] = useState(false); - const options = props.options; - const defSort:SortDropdownOption = options.filter(opt => opt.value == defOptVal)[0]; - const [isOpen, setIsOpen] = useState(false); - const [selectedOption, setSelection] = useState(defSort); - const [previousOption, setPrevious] = useState(defSort); - const [currentIndex, setCurrentIndex] = useState(-1); - const autoHideListener = (event: any) => { - if (event.type === "keydown" && event.key === "Escape") { - setIsOpen(false); - } else if (event.type !== "keydown") { - if (!hasClassInSelfOrParent(event.target, "sort-control")) { - setIsOpen(false); - } - } - }; + const sortOptions = SortOptions; + const orderBy = props.orderBy; + const query = props.query; + const startHasCollection = checkForCollection(query); - const handleArrowKeys = (event:KeyboardEvent) => { - if (event.key === 'ArrowDown' || - event.key === 'ArrowUp' || - event.key === 'Enter' || - event.code === 'Space') { - event.preventDefault(); - event.stopPropagation(); - let rowCount = options.length; - if (!hasCollection) { --rowCount; } - if (event.key === 'ArrowDown') { - setCurrentIndex((prevIndex) => (prevIndex + 1) % rowCount); - } else if (event.key === 'ArrowUp') { - setCurrentIndex((prevIndex) => (prevIndex - 1 + rowCount) % rowCount); - } else if (event.key === 'Enter' || event.code === 'Space') { - if (!isOpen) { - setCurrentIndex(options.findIndex(opt => opt.value === selectedOption.value)); - setIsOpen(true); - } else { - handleOptionClick(options[currentIndex]); - } + let startSortOption = DefaultSortOption; + if (startHasCollection) { + if ((sortOptions.filter(o => o.isCollection)).length > 0) { + startSortOption = sortOptions.find(o => o.isCollection); } - } - }; - - const handleQueryChange = (e: any) => { - const newQuery = e.detail.query ? (" " + e.detail.query) : ""; - setHasCollection(checkForCollection(newQuery)); - }; - - const handleLogoClick = (e: any) => { - setSelection(defSort); - }; - - useEffect(() => { - if (hasCollection) { - const collOpt = options.filter(opt => opt.value == COLLECTION_OPTION)[0]; - setSelection(collOpt); } else { - if (selectedOption.isCollection) { - setSelection(previousOption); + if ((sortOptions.filter(o => o.value === orderBy)).length > 0) { + startSortOption = sortOptions.find(o => o.value === orderBy); } } - }, [hasCollection]); - - useEffect(() => { - if (selectedOption && selectedOption !== previousOption && !selectedOption.isCollection ) { - setPrevious(selectedOption); - } - }, [selectedOption]); - - useEffect(() => { - if (props.options.filter(o => o.value === props.orderBy).length > 0) { - setSelection(props.options.filter(o => o.value === props.orderBy)[0]); - } else { - setSelection(defSort); - } - if (props.query) { - setHasCollection(checkForCollection(props.query)); - } else if (props.options.filter(o => o.value === props.orderBy).length > 0) { - setHasCollection(props.options.filter(o => o.value === props.orderBy)[0].isCollection); - } else { - setHasCollection(false); - } + const [selSortOption, setSortOption] = useState(startSortOption); + const [hasCollection, setHasCollection] = useState(startHasCollection); - window.addEventListener("logoClick", handleLogoClick); - window.addEventListener("queryChangeEvent", handleQueryChange); - window.addEventListener("mouseup", autoHideListener); - window.addEventListener("scroll", autoHideListener); - window.addEventListener("keydown", autoHideListener); - - // Clean up the event listener when the component unmounts - return () => { - setCurrentIndex(-1); - window.removeEventListener("logoClick", handleLogoClick); - window.removeEventListener("queryChangeEvent", handleQueryChange); - window.removeEventListener("mouseup", autoHideListener); - window.removeEventListener("scroll", autoHideListener); - window.removeEventListener("keydown", autoHideListener); + const onSortSelect = (selOption: SortDropdownOption) => { + setSortOption(selOption); + props.onSortSelect(selOption); }; - }, []); - - const handleOptionClick = (option: SortDropdownOption) => { - setIsOpen(false); - if (option.value !== selectedOption.value) { - setSelection(option); - props.onSelect(option); - //-notification banner- - if (option.value.includes("taken")) { - const notificationEvent = new CustomEvent("newNotification", { - detail: { - announceId: "sortByTakenDate", - description: "Images without a Taken Date will appear at the end of the list", - category: "information", - lifespan: "transient" - }, - bubbles: true - }); - window.dispatchEvent(notificationEvent); - } - } - }; - return ( -
-
{CONTROL_TITLE}
-
-
setIsOpen(!isOpen)}> -
-
{(selectedOption ? selectedOption.label : SELECT_OPTION)}
-
{downArrowIcon()}
-
-
-
setIsOpen(!isOpen)}> -
-
{sortIcon()}
- {SORT_ORDER} -
-
- {isOpen && ( - - - {options.map((option) => (hasCollection || option.value != COLLECTION_OPTION) && ( - -1 && options[currentIndex].value) === option.value ? "sort-dropdown-item sort-dropdown-highlight" : "sort-dropdown-item"} - key={option.value + "row"} - onClick={() => handleOptionClick(option)} - aria-label={option.label}> - - - - ))} - -
- {(selectedOption.value == option.value) ? tickIcon() : emptyIcon()} - - {option.label} -
- )} -
-
- ); + // initialisation + useEffect(() => { + const handleLogoClick = (e: any) => { + setSortOption(DefaultSortOption); + props.onSortSelect(DefaultSortOption); + }; + + const handleQueryChange = (e: any) => { + const newQuery = e.detail.query ? (" " + e.detail.query) : ""; + const curHasCollec = e.detail.hasCollection ? e.detail.hasCollection : false; + const orderBy = e.detail.orderBy ? e.detail.orderBy : DefaultSortOption.value; + const newHasCollec = checkForCollection(newQuery); + setHasCollection(newHasCollec); + if (!curHasCollec && newHasCollec) { + let collecSortOption = DefaultSortOption; + if ((sortOptions.filter(o => o.isCollection)).length > 0) { + collecSortOption = sortOptions.filter(o => o.isCollection)[0]; + } + setSortOption(collecSortOption); + } + const eventOrderOpt = (sortOptions.filter(o => o.value == orderBy))[0]; + if (!newHasCollec && curHasCollec && eventOrderOpt.isCollection) { + setSortOption(DefaultSortOption); + } + }; + + window.addEventListener("logoClick", handleLogoClick); + window.addEventListener("queryChangeEvent", handleQueryChange); + + // Clean up the event listener when the component unmounts + return () => { + window.removeEventListener("logoClick", handleLogoClick); + window.removeEventListener("queryChangeEvent", handleQueryChange); + }; + + }, []); + + return ( + + ); }; export const sortControl = angular.module('gr.sortControl', []) diff --git a/kahuna/public/js/components/gr-tab-swap/gr-tab-swap.css b/kahuna/public/js/components/gr-tab-swap/gr-tab-swap.css new file mode 100644 index 0000000000..b4762cd15c --- /dev/null +++ b/kahuna/public/js/components/gr-tab-swap/gr-tab-swap.css @@ -0,0 +1,43 @@ +.gr-tab-wrapper { + padding: 4px; +} + +.gr-tab-panel-margin { + margin-left: 250px; +} + +.gr-tab-container { + font-family: "Open Sans", sans-serif; + display: flex; + width: fit-content; +} + +.gr-tab { + cursor: pointer; + font-size: 14px; + transition: background-color 0.3s ease; + padding: 8px 16px; + background-color: #444; + color: #ccc; + border: none; + outline: none; + position: relative; +} + +.gr-tab:hover { + color: white; +} + +.gr-tab.active { + background-color: #666; +} + +.gr-tab.active::before { + content: ""; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 3px; + background-color: #00bfff; +} diff --git a/kahuna/public/js/components/gr-tab-swap/gr-tab-swap.tsx b/kahuna/public/js/components/gr-tab-swap/gr-tab-swap.tsx new file mode 100644 index 0000000000..d2ea64f018 --- /dev/null +++ b/kahuna/public/js/components/gr-tab-swap/gr-tab-swap.tsx @@ -0,0 +1,110 @@ +import * as React from "react"; +import { useState, useEffect, KeyboardEvent } from "react"; + +import './gr-tab-swap.css'; + +export interface TabSwapProps { + onSelect: (withTaken: boolean) => void; + query: string; + showTakenTab: boolean; + noTakenDateCount: number; + panelVisible: boolean; +} + +export const TAB_WITH = "with"; + +export const TabControl: React.FC = ({ onSelect, query, showTakenTab, noTakenDateCount, panelVisible }) => { + + const withLabel = "With taken date"; + const withoutLabel = "Without taken date"; + const CONTROL_TITLE = "Has Taken Date Selector"; + const PANEL_IDENTIFIER = "collections"; + const SCROLL_IDENTIFIER = "scroll"; + const HAS_DATE_TAKEN_QUERY = "has:dateTaken"; + const without = `-${HAS_DATE_TAKEN_QUERY}`; + + let tabStart = 'with'; + if (query.includes(without)) { + tabStart = 'without'; + } + + const [activeTab, setActiveTab] = useState(tabStart); + const isSortTaken = showTakenTab; + const [isPanelVisible, setIsPanelVisible] = useState(panelVisible); + + let takenDateMsg = ""; + if (noTakenDateCount === 1) { + takenDateMsg = " (1 match)"; + } else if (noTakenDateCount > 1) { + takenDateMsg = ` (${noTakenDateCount.toLocaleString()} matches)`; + } + + const handleTabClick = (tabSelected: string) => { + if (tabSelected !== activeTab) { + setActiveTab(tabSelected); + onSelect('with' === tabSelected); + } + }; + + const handleKeyboard = (event:KeyboardEvent) => { + if (event.code === 'Space') { + event.preventDefault(); + event.stopPropagation(); + if (activeTab === 'without') { + setActiveTab('with'); + onSelect(true); + } else { + setActiveTab('without'); + onSelect(false); + } + } + }; + + useEffect(() => { + const handlePanelShow = (event: any) => { + const panel = event.detail.panel; + if (panel === PANEL_IDENTIFIER) { + setIsPanelVisible(true); + } + }; + + const handlePanelHide = (event: any) => { + const panel = event.detail.panel; + if (panel === PANEL_IDENTIFIER || panel === SCROLL_IDENTIFIER) { + setIsPanelVisible(false); + } + }; + + window.addEventListener("panelHide", handlePanelHide); + window.addEventListener("panelShow", handlePanelShow); + + // Clean up the event listener when the component unmounts + return () => { + window.removeEventListener("panelHide", handlePanelHide); + window.removeEventListener("panelShow", handlePanelShow); + }; + }, []); + + return ( +
+ {(isSortTaken && noTakenDateCount > 0) && ( +
+
handleTabClick('with')} + aria-label={`${withLabel} ${activeTab === 'with' ? 'selected' : ''}`} + > + {withLabel} +
+
handleTabClick('without')} + aria-label={`${withoutLabel} ${activeTab === 'without' ? 'selected' : ''}`} + > + {`${withoutLabel}${takenDateMsg}`} +
+
+ )} +
+ ); +}; diff --git a/kahuna/public/js/crop/controller.js b/kahuna/public/js/crop/controller.js index 70a7299cf8..370278f599 100644 --- a/kahuna/public/js/crop/controller.js +++ b/kahuna/public/js/crop/controller.js @@ -4,11 +4,13 @@ import '../components/gr-keyboard-shortcut/gr-keyboard-shortcut'; import {radioList} from '../components/gr-radio-list/gr-radio-list'; import {cropUtil} from "../util/crop"; import {cropOptions} from "../util/constants/cropOptions"; +import {storage as storageModule} from "../util/storage"; const crop = angular.module('kahuna.crop.controller', [ 'gr.keyboardShortcut', radioList.name, - cropUtil.name + cropUtil.name, + storageModule.name ]); crop.controller('ImageCropCtrl', [ @@ -26,6 +28,7 @@ crop.controller('ImageCropCtrl', [ 'square', 'freeform', 'pollUntilCropCreated', + 'storage', function( $scope, $rootScope, @@ -40,11 +43,28 @@ crop.controller('ImageCropCtrl', [ cropSettings, square, freeform, - pollUntilCropCreated) { + pollUntilCropCreated, + storage) { const ctrl = this; const imageId = $stateParams.imageId; + const circularMaskKey = 'crop.shouldUseCircularMask'; + try { + const stored = storage.getJs(circularMaskKey); + if (typeof stored === 'boolean') { + ctrl.shouldUseCircularMask = stored; + } + } catch (e) { + console.error(`failed to store '${circularMaskKey}' to local storage`, e); + } + + $scope.$watch(() => ctrl.shouldUseCircularMask, (shouldUseCircularMask) => { + if (typeof shouldUseCircularMask === 'boolean') { + storage.setJs(circularMaskKey, shouldUseCircularMask); + } + }); + cropSettings.set($stateParams); const allCropOptions = cropSettings.getCropOptions(); @@ -231,4 +251,3 @@ crop.controller('ImageCropCtrl', [ }); }); }]); - diff --git a/kahuna/public/js/preview/image.html b/kahuna/public/js/preview/image.html index 7af1a1a91b..11779c5576 100644 --- a/kahuna/public/js/preview/image.html +++ b/kahuna/public/js/preview/image.html @@ -118,8 +118,7 @@
- Uploaded: {{::ctrl.image.data.uploadTime | date:'dd/MM/yy'}} - {{::ctrl.image.data.uploadTime | date:'HH:mm'}} + Uploaded: {{::ctrl.image.data.uploadTime | date:'d MMM yyyy, HH:mm'}} diff --git a/kahuna/public/js/search/index.js b/kahuna/public/js/search/index.js index 842a6ad462..f8abe4df30 100644 --- a/kahuna/public/js/search/index.js +++ b/kahuna/public/js/search/index.js @@ -16,6 +16,7 @@ import '../components/gr-info-panel/gr-info-panel'; import '../components/gr-collections-panel/gr-collections-panel'; import '../components/gr-keyboard-shortcut/gr-keyboard-shortcut'; import '../components/gr-sort-control/gr-sort-control'; +import '../components/gr-sort-control/gr-extended-sort-control'; import '../components/gr-permissions-filter/gr-permissions-filter'; import '../components/gr-my-uploads/gr-my-uploads'; import '../components/gr-search-wrapper/gr-search-wrapper'; @@ -29,7 +30,7 @@ import panelTemplate from '../components/gr-info-panel/gr-info-panel.html import collectionsPanelTemplate from '../components/gr-collections-panel/gr-collections-panel.html'; import {cropUtil} from '../util/crop'; - +import { COLLECTION_SORT_VALUE } from '../components/gr-sort-control/gr-sort-control-config'; export var search = angular.module('kahuna.search', [ 'ct.ui.router.extras.dsr', @@ -43,6 +44,7 @@ export var search = angular.module('kahuna.search', [ 'gr.panels', 'gr.keyboardShortcut', 'gr.sortControl', + 'gr.extendedSortControl', 'gr.permissionsFilter', 'gr.myUploads', 'gr-searchWrapper', @@ -324,17 +326,14 @@ search.run(['$rootScope', '$state', function($rootScope, $state) { $state.go('search.results', null, {reload: true}); } }); - $rootScope.$on('$stateChangeStart', (_, toState, toParams, fromState, fromParams) => { + $rootScope.$on('$stateChangeStart', (_, toState, toParams) => { if (toState.name === 'search.results') { //If moving to a collection, sorts images by time added to a collection by default - //allows sorting by newest first if set by user. - if (toParams.query && toParams.query.indexOf('~') === 0) { - const sameQuery = toParams.query === fromParams.query; - toParams.orderBy = sameQuery ? toParams.orderBy : 'dateAddedToCollection'; - } - //If moving from a collection to a non-collection, reset order to default. - else if (toParams.orderBy === 'dateAddedToCollection') { - delete toParams.orderBy; + //allows sorting by newest first if set by user. Need to account for 'With Taken Date' tab impacts on query + const checkForCollection = (query) => /~"[a-zA-Z0-9 #-_.://]+"/.test(query); + const toQuery = toParams.query ? toParams.query : ""; + if (!checkForCollection(toQuery) && toParams.orderBy === COLLECTION_SORT_VALUE) { + delete toParams.orderBy; } } }); diff --git a/kahuna/public/js/search/query.js b/kahuna/public/js/search/query.js index abd7c0fdf6..244677a1d1 100644 --- a/kahuna/public/js/search/query.js +++ b/kahuna/public/js/search/query.js @@ -12,6 +12,7 @@ import template from './query.html'; import {syntax} from './syntax/syntax'; import {grStructuredQuery} from './structured-query/structured-query'; import '../components/gr-sort-control/gr-sort-control'; +import '../components/gr-sort-control/gr-extended-sort-control'; import '../components/gr-permissions-filter/gr-permissions-filter'; import '../components/gr-my-uploads/gr-my-uploads'; import { sendTelemetryForQuery } from '../services/telemetry'; @@ -21,7 +22,9 @@ import {updateFilterChips} from "../components/gr-permissions-filter/gr-permissi import { manageSortSelection, DefaultSortOption, - SortOptions + CollectionSortOption, + HAS_DATE_TAKEN, + TAKEN_SORT } from "../components/gr-sort-control/gr-sort-control-config"; export var query = angular.module('kahuna.search.query', [ @@ -33,6 +36,7 @@ export var query = angular.module('kahuna.search.query', [ grStructuredQuery.name, 'util.storage', 'gr.sortControl', + 'gr.extendedSortControl', 'gr.permissionsFilter', 'gr.myUploads' ]); @@ -85,9 +89,9 @@ query.controller('SearchQueryCtrl', [ window.dispatchEvent(customEvent); } - function raiseQueryChangeEvent(query) { + function raiseQueryChangeEvent(query, prevHasCollec, orderBy) { const customEvent = new CustomEvent('queryChangeEvent', { - detail: {query: query}, + detail: {query: query, hasCollection: prevHasCollec, orderBy: orderBy}, bubbles: true }); window.dispatchEvent(customEvent); @@ -184,14 +188,81 @@ query.controller('SearchQueryCtrl', [ ctrl.filter.orgOwned = false; } + function checkForCollection(query) { + return /~"[a-zA-Z0-9 #-_.://]+"/.test(query); + }; + + function storeCollection(query) { + const match = query ? query.match(/~"[a-zA-Z0-9 #-_.://]+"/) : undefined; + const collection = match ? match[0] : ""; + storage.setJs("currentCollection", collection); + return collection; + } + + function getCollection() { + const collection = storage.getJs("currentCollection") ? storage.getJs("currentCollection") : ""; + return collection; + } + + function getPriorOrderBy() { + const prior = storage.getJs("priorOrderBy") ? storage.getJs("priorOrderBy") : ""; + return prior; + } + + function setPriorOrderBy(priorOrderBy) { + storage.setJs("priorOrderBy", priorOrderBy); + } + + function revisedOrderBy(collectionSearch) { + if (collectionSearch) { + return CollectionSortOption.value; + } else { + return DefaultSortOption.value; + } + } + + function priorRevisedOrderBy(collectionSearch, newCollection, oldCollection) { + const priorOrderBy = getPriorOrderBy(); + if (collectionSearch && ((oldCollection !== newCollection) || ("" !== priorOrderBy))) { + if (priorOrderBy != "") { + setPriorOrderBy(""); + return priorOrderBy; + } else { + setPriorOrderBy(CollectionSortOption.value); + return null; + } + } else { + return null; + } + } + // eslint-disable-next-line complexity function watchSearchChange(newFilter, sender) { - const showPaid = newFilter.nonFree ? newFilter.nonFree : false; + let showPaid = newFilter.nonFree ? newFilter.nonFree : false; + if (sender && sender == "filterChange" && !newFilter.nonFree) { + showPaid = ctrl.user.permissions.showPaid; + } storage.setJs("isNonFree", showPaid, true); - let sortBy = ctrl.ordering["orderBy"] ? ctrl.ordering["orderBy"] : "newest"; - storage.setJs("orderBy", sortBy); - ctrl.collectionSearch = newFilter.query ? newFilter.query.indexOf('~') === 0 : false; + // check for taken date sort contradiction + const curCollectionSearch = ctrl.collectionSearch; + ctrl.collectionSearch = newFilter.query ? checkForCollection(newFilter.query) : false; + const oldCollection = getCollection(); + const newCollection = storeCollection(newFilter.query); + + if (ctrl.usePermissionsFilter) { + if (sender && ctrl.ordering["orderBy"] != $stateParams.orderBy) { + ctrl.ordering["orderBy"] = $stateParams.orderBy; + } + if ($stateParams.orderBy && $stateParams.orderBy.includes(TAKEN_SORT) && (!newFilter.query || !newFilter.query.includes(HAS_DATE_TAKEN))) { + ctrl.ordering["orderBy"] = revisedOrderBy(ctrl.collectionSearch); + } else { + const prior = priorRevisedOrderBy(ctrl.collectionSearch, newCollection, oldCollection); + ctrl.ordering["orderBy"] = prior ? prior : ctrl.ordering["orderBy"]; + } + } + let sortBy = ctrl.ordering["orderBy"] ? ctrl.ordering["orderBy"] : DefaultSortOption.value; + storage.setJs("orderBy", sortBy); //--update filter elements-- manageUploadedBy(newFilter, sender); @@ -207,10 +278,17 @@ query.controller('SearchQueryCtrl', [ nonFreeCheck = undefined; } ctrl.filter.nonFree = nonFreeCheck; - raiseQueryChangeEvent(ctrl.filter.query); sendTelemetryForQuery(ctrl.filter.query, nonFreeCheck, uploadedByMe); - $state.go('search.results', ctrl.filter); + if (ctrl.collectionSearch && !curCollectionSearch) { + storage.setJs("orderBy", CollectionSortOption.value); + ctrl.ordering["orderBy"] = CollectionSortOption.value; + raiseQueryChangeEvent(ctrl.filter.query, curCollectionSearch, CollectionSortOption.value); + $state.go('search.results', {...ctrl.filter, ...{orderBy: CollectionSortOption.value}}); + } else { + raiseQueryChangeEvent(ctrl.filter.query, curCollectionSearch, ctrl.ordering["orderBy"]); + $state.go('search.results', {...ctrl.filter, ...{orderBy: ctrl.ordering["orderBy"]}}); + } } //-my uploads- @@ -227,15 +305,13 @@ query.controller('SearchQueryCtrl', [ //-sort control- function updateSortChips (sortSel) { - ctrl.sortProps.selectedOption = sortSel; ctrl.ordering['orderBy'] = manageSortSelection(sortSel.value); - watchSearchChange(ctrl.filter, "sorting"); + storage.setJs("orderBy", ctrl.ordering["orderBy"]); + $state.go('search.results', {...ctrl.filter, ...{orderBy: ctrl.ordering['orderBy']}}); } ctrl.sortProps = { - options: SortOptions, - selectedOption: DefaultSortOption, - onSelect: updateSortChips, + onSortSelect: updateSortChips, query: ctrl.filter.query, orderBy: ctrl.ordering ? ctrl.ordering.orderBy : "" }; @@ -318,14 +394,13 @@ query.controller('SearchQueryCtrl', [ ctrl.filter[key] = valOrUndefined($stateParams[key]); } - ctrl.collectionSearch = ctrl.filter.query ? ctrl.filter.query.indexOf('~') === 0 : false; + ctrl.collectionSearch = ctrl.filter.query ? checkForCollection(ctrl.filter.query) : false; + storeCollection(ctrl.filter.query); $scope.$watch(() => $stateParams[key], onValChange(newVal => { // FIXME: broken for 'your uploads' // FIXME: + they triggers filter $watch and $state.go (breaks history) - if (key === 'orderBy') { - ctrl.ordering[key] = valOrUndefined(newVal); - } else { + if (key !== 'orderBy') { ctrl.filter[key] = valOrUndefined(newVal); } diff --git a/kahuna/public/js/search/results.html b/kahuna/public/js/search/results.html index 32a6abdc05..fc1e9bd1e7 100644 --- a/kahuna/public/js/search/results.html +++ b/kahuna/public/js/search/results.html @@ -21,7 +21,7 @@
+ image-results-count"> {{ctrl.totalResults | toLocaleString}} matches
-
    + +
+ +
-
Too many results to display
Please refine your search to limit the number of results
diff --git a/kahuna/public/js/search/results.js b/kahuna/public/js/search/results.js index 3f00803a85..a09d18bfd6 100644 --- a/kahuna/public/js/search/results.js +++ b/kahuna/public/js/search/results.js @@ -7,6 +7,7 @@ import '../services/panel'; import '../util/async'; import '../util/rx'; import '../util/seq'; +import '../util/storage'; import '../util/constants/sendToCapture-config'; import '../components/gu-lazy-table/gu-lazy-table'; import '../components/gu-lazy-table-shortcuts/gu-lazy-table-shortcuts'; @@ -18,6 +19,18 @@ import '../components/gr-batch-export-original-images/gr-batch-export-original-i import '../components/gr-panel-button/gr-panel-button'; import '../components/gr-toggle-button/gr-toggle-button'; import '../components/gr-confirmation-modal/gr-confirmation-modal'; +import '../components/gr-sort-control/gr-sort-control'; +import '../components/gr-sort-control/gr-extended-sort-control'; +import { + manageSortSelection, + DefaultSortOption, + TAKEN_SORT, + HAS_DATE_TAKEN, + HASNT_DATE_TAKEN +} from "../components/gr-sort-control/gr-sort-control-config"; +import { + TAB_WITH +} from "../components/gr-tab-swap/gr-tab-swap"; import { INVALIDIMAGES, sendToCaptureAllValid, sendToCaptureCancelBtnTxt, sendToCaptureConfirmBtnTxt, sendToCaptureInvalid, @@ -42,7 +55,9 @@ export var results = angular.module('kahuna.search.results', [ 'gr.undeleteImage', 'gr.panelButton', 'gr.toggleButton', - 'gr.confirmationModal' + 'gr.confirmationModal', + 'gr.sortControl', + 'gr.extendedSortControl' ]); @@ -79,6 +94,7 @@ results.controller('SearchResultsCtrl', [ 'panels', 'isReloadingPreviousSearch', 'globalErrors', + 'storage', function($rootScope, $scope, @@ -98,18 +114,89 @@ results.controller('SearchResultsCtrl', [ results, panels, isReloadingPreviousSearch, - globalErrors) { + globalErrors, + storage) { const ctrl = this; ctrl.$onInit = () => { ctrl.showSendToPhotoSales = () => $window._clientConfig.showSendToPhotoSales; + ctrl.usePermissionsFilter = () => $window._clientConfig.usePermissionsFilter; }; // Panel control ctrl.metadataPanel = panels.metadataPanel; ctrl.collectionsPanel = panels.collectionsPanel; + //-taken and sort controls- + const hasTakenDateClause = HAS_DATE_TAKEN; + const noTakenDateClause = HASNT_DATE_TAKEN; + const takenSort = TAKEN_SORT; + ctrl.setTakenVisible = (isVisible) => storage.setJs("takenTabVisible", isVisible ? "visible" : "hidden", true); + ctrl.getTakenVisible = () => { + const vis = storage.getJs("takenTabVisible", true) ? storage.getJs("takenTabVisible", true) : "hidden"; + return (vis == "visible"); + }; + ctrl.getCollectionsPanelVisible = () => storage.getJs("collectionsPanelState", false) ? !(storage.getJs("collectionsPanelState", false).hidden) : false; + ctrl.getInfoPanelVisible = () => storage.getJs("metadataPanelState", false) ? !(storage.getJs("metadataPanelState", false).hidden) : false; + ctrl.getLastTakenSort = () => storage.getJs("lastTakenSort", false) ? storage.getJs("lastTakenSort", false) : ""; + ctrl.setLastTakenSort = (orderBy) => storage.setJs("lastTakenSort", orderBy, false); + + //-sort control select- + function updateSortChange (sortSel, tabSelected, userSelectedTaken) { + var orderBy = manageSortSelection(sortSel.value); + var curQuery = $stateParams.query ? $stateParams.query : ''; + ctrl.setTakenVisible(userSelectedTaken); + curQuery = curQuery.replace(noTakenDateClause, "").replace(hasTakenDateClause, "").trim(); + if (sortSel.isTaken) { + ctrl.setLastTakenSort(orderBy); + } + if (userSelectedTaken) { + if (tabSelected === TAB_WITH) { + curQuery = `${curQuery} ${hasTakenDateClause}`.trim(); + orderBy = ctrl.getLastTakenSort(); + } else { // without + curQuery = `${curQuery} ${noTakenDateClause}`.trim(); + orderBy = DefaultSortOption.value; + } + } + storage.setJs("orderBy", orderBy); + const toParams = { + ...$stateParams, + orderBy: orderBy, + query: curQuery + }; + $state.go('search.results', toParams); + } + + async function checkForNoTakenDate() { + let tempQuery = $stateParams.query ? $stateParams.query : ''; + let isTaken = ($stateParams.orderBy && $stateParams.orderBy.includes(takenSort)) || tempQuery.includes(hasTakenDateClause); + if (!isTaken) { return 0; } + tempQuery = tempQuery.replace(noTakenDateClause, '').replace(hasTakenDateClause, '').trim(); + storage.setJs("previousQuery", tempQuery, true); + let query = `${tempQuery} ${noTakenDateClause}`.trim(); + var resp = await search({query: query, length: 0}); + return resp.total; + }; + + ctrl.extendedSortProps = { + onSortSelect: updateSortChange, + query: $stateParams.query ? $stateParams.query : "", + orderBy: $stateParams.orderBy ? $stateParams.orderBy : "", + infoPanelVisible: ctrl.getInfoPanelVisible(), + collectionsPanelVisible: ctrl.getCollectionsPanelVisible(), + userTakenSelect: ctrl.getTakenVisible(), + noTakenDateCount: 0 + }; + + checkForNoTakenDate().then(noTakenTotal => { + ctrl.extendedSortProps = { ...ctrl.extendedSortProps, + noTakenDateCount: noTakenTotal + }; + }); + //-end sort and taken tab controls- + ctrl.images = []; if (ctrl.image && ctrl.image.data.softDeletedMetadata !== undefined) { ctrl.isDeleted = true; } ctrl.newImagesCount = 0; @@ -144,7 +231,7 @@ results.controller('SearchResultsCtrl', [ // TODO: avoid this initial search (two API calls to init!) ctrl.searched = search({length: 1, orderBy: 'newest'}).then(function(images) { ctrl.totalResults = images.total; - // FIXME: https://github.com/argo-rest/theseus has forced us to co-opt the actions field for this + // FIXME: https://github.com/argo-rest/theseus has forced us to co-opt the actions field for this ctrl.orgOwnedCount = images.$response?.$$state?.value?.actions; ctrl.hasQuery = !!$stateParams.query; @@ -164,6 +251,8 @@ results.controller('SearchResultsCtrl', [ results.clear(); results.resize(totalLength); + notificationMessages(ctrl.extendedSortProps, images.total); + imagesPositions = new Map(); checkForNewImages(); @@ -238,6 +327,56 @@ results.controller('SearchResultsCtrl', [ const pollingPeriod = 15 * 1000; // ms + function notificationMessages(extendedProps, imagesTotal) { + if (!ctrl.usePermissionsFilter()) { + return; + } + if (extendedProps.orderBy.includes(takenSort) && extendedProps.query.includes(hasTakenDateClause)) { + if (imagesTotal === 0 && extendedProps.noTakenDateCount > 0) { // no images with taken date + updateSortChange(DefaultSortOption, 'with', false, extendedProps.noTakenDateCount); + const noMatchesStr = "There are no matching images with a taken date"; + const notificationEvent = new CustomEvent("newNotification", { + detail: { + announceId: "noTakenDateImages", + description: noMatchesStr, + category: "information", + lifespan: "transient" + }, + bubbles: true + }); + window.dispatchEvent(notificationEvent); + } else if (0 < extendedProps.noTakenDateCount) { + const oldNoTakenCount = storage.getJs("lastNoTakenCount", false) ? storage.getJs("lastNoTakenCount", false) : 0; + let imageStr = "There are " + extendedProps.noTakenDateCount.toLocaleString() + " images with no taken date"; + if (extendedProps.noTakenDateCount === 1) { + imageStr = "There is one image with no taken date"; + } + if (oldNoTakenCount !== extendedProps.noTakenDateCount) { + const notificationEvent = new CustomEvent("newNotification", { + detail: { + announceId: "sortByTakenDate", + description: imageStr, + category: "information", + lifespan: "transient" + }, + bubbles: true + }); + window.dispatchEvent(notificationEvent); + storage.setJs("lastNoTakenCount", extendedProps.noTakenDateCount, false); + } + } else { + const notificationEvent = new CustomEvent("removeNotification", { + detail: { + announceId: "sortByTakenDate" + }, + bubbles: true + }); + window.dispatchEvent(notificationEvent); + storage.setJs("lastNoTakenCount", 0, false); + } + } + } + // FIXME: this will only add up to 50 images (search capped) function checkForNewImages() { $timeout(() => { @@ -326,7 +465,7 @@ results.controller('SearchResultsCtrl', [ return $stateParams.query || '*'; } - function search({until, since, offset, length, orderBy, countAll} = {}) { + function search({query, until, since, offset, length, orderBy, countAll} = {}) { // FIXME: Think of a way to not have to add a param in a million places to add it /* @@ -344,6 +483,9 @@ results.controller('SearchResultsCtrl', [ * `checkForNewImages` deals with that. If it's the first search, we * will use `stateParams.until` if available. */ + if (angular.isUndefined(query)) { + query = $stateParams.query; + } if (angular.isUndefined(until)) { until = lastSearchFirstResultTime || $stateParams.until; } @@ -357,7 +499,8 @@ results.controller('SearchResultsCtrl', [ countAll = true; } - return mediaApi.search($stateParams.query, angular.extend({ + + return mediaApi.search(query, angular.extend({ ids: $stateParams.ids, archived: $stateParams.archived, free: $stateParams.nonFree === 'true' ? undefined : true, diff --git a/kahuna/public/js/search/structured-query/structured-query.js b/kahuna/public/js/search/structured-query/structured-query.js index 9aeb0a08ef..6583d4defc 100644 --- a/kahuna/public/js/search/structured-query/structured-query.js +++ b/kahuna/public/js/search/structured-query/structured-query.js @@ -51,7 +51,7 @@ grStructuredQuery.controller("grStructuredQueryCtrl", [ .debounce(500); ctrl.getSuggestions = querySuggestions.getChipSuggestions; - ctrl.filterFields = querySuggestions.filterFields; + ctrl.filterFields = querySuggestions.typeaheadFields.map(_ => _.fieldName); function valOrUndefined(str) { // Watch out for `false`, but we know it's a string here.. diff --git a/kahuna/public/js/search/syntax/syntax.html b/kahuna/public/js/search/syntax/syntax.html index cb660cb6fe..779833e722 100644 --- a/kahuna/public/js/search/syntax/syntax.html +++ b/kahuna/public/js/search/syntax/syntax.html @@ -167,5 +167,13 @@

Other filters

Search by person (email) or ftp source (folder). +
+
+ +
+
+ Returns images that have a 'Taken date' value set. +
+
diff --git a/kahuna/public/stylesheets/main.css b/kahuna/public/stylesheets/main.css index ae74b3b467..0e879046c3 100644 --- a/kahuna/public/stylesheets/main.css +++ b/kahuna/public/stylesheets/main.css @@ -1237,6 +1237,13 @@ textarea.ng-invalid { position: relative; } +.results--alternate { + display: flex; + flex-wrap: wrap; + top: 96px; + position: relative; +} + .results-controls { background: white; padding: 10px; @@ -1276,7 +1283,7 @@ textarea.ng-invalid { display: none; /* above thumbnail and overlay */ - z-index: 2; + z-index: 1; } .result__select--no-pointer-events { @@ -1537,7 +1544,7 @@ textarea.ng-invalid { .image-no-results { font-size: 3rem; text-align: center; - margin-top: 4rem; + margin-top: 10rem; } .image-loading-results { @@ -3073,7 +3080,7 @@ FIXME: what to do with touch devices text-align: left; /* above thumbnail and overlay */ - z-index: 2; + z-index: 1; } .result__select__overlay__text.alert { background-color: red;