From db2ce2f2b4ef42178927184562f4514303f89754 Mon Sep 17 00:00:00 2001 From: Philipp Otto Date: Mon, 6 Mar 2023 10:04:19 +0100 Subject: [PATCH 1/7] avoid spinner when switching tabs in dashboard (#6894) --- frontend/javascripts/admin/admin_rest_api.ts | 2 ++ frontend/javascripts/dashboard/dashboard_view.tsx | 7 +++++-- frontend/stylesheets/_utils.less | 4 ++++ 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/frontend/javascripts/admin/admin_rest_api.ts b/frontend/javascripts/admin/admin_rest_api.ts index 06702383cdf..1b39dc8de8f 100644 --- a/frontend/javascripts/admin/admin_rest_api.ts +++ b/frontend/javascripts/admin/admin_rest_api.ts @@ -2053,6 +2053,8 @@ export async function getPricingPlanStatus(): Promise { return Request.receiveJSON("/api/pricing/status"); } +export const cachedGetPricingPlanStatus = _.memoize(getPricingPlanStatus); + // ### BuildInfo webknossos export function getBuildInfo(): Promise { return Request.receiveJSON("/api/buildinfo", { diff --git a/frontend/javascripts/dashboard/dashboard_view.tsx b/frontend/javascripts/dashboard/dashboard_view.tsx index 5e8fa76c791..64632af32ee 100644 --- a/frontend/javascripts/dashboard/dashboard_view.tsx +++ b/frontend/javascripts/dashboard/dashboard_view.tsx @@ -11,7 +11,7 @@ import type { APIOrganization, APIPricingPlanStatus, APIUser } from "types/api_f import type { OxalisState } from "oxalis/store"; import { enforceActiveUser } from "oxalis/model/accessors/user_accessor"; import { - getPricingPlanStatus, + cachedGetPricingPlanStatus, getUser, updateNovelUserExperienceInfos, } from "admin/admin_rest_api"; @@ -120,7 +120,10 @@ class DashboardView extends PureComponent { const user = this.props.userId != null ? await getUser(this.props.userId) : this.props.activeUser; - const pricingPlanStatus = await getPricingPlanStatus(); + // Use a cached version of this route to avoid that a tab switch in the dashboard + // causes a whole-page spinner. Since the different tabs are controlled by the + // router, the DashboardView re-mounts. + const pricingPlanStatus = await cachedGetPricingPlanStatus(); this.setState({ user, diff --git a/frontend/stylesheets/_utils.less b/frontend/stylesheets/_utils.less index 7692453877d..eb71950bb3d 100644 --- a/frontend/stylesheets/_utils.less +++ b/frontend/stylesheets/_utils.less @@ -122,3 +122,7 @@ td.nowrap * { .min-height-0 { min-height: 0; } + +.text-center { + text-align: center; +} From f1a70e4c8db3dbc7f16af3e49e1ef762eeeb7bf5 Mon Sep 17 00:00:00 2001 From: Florian M Date: Mon, 6 Mar 2023 10:35:26 +0100 Subject: [PATCH 2/7] When merging volume tracings, also merge segment lists (#6882) * When merging volume tracings, also merge segment lists * remap segment ids * Fix multi-volume upload, disable segment lists there * Changelog * fix typo --- CHANGELOG.unreleased.md | 2 + app/controllers/AnnotationIOController.scala | 6 +- app/controllers/JobsController.scala | 7 ++- conf/messages | 2 +- .../dataformats/wkw/WKWDataFormatHelper.scala | 1 + .../SkeletonTracingController.scala | 3 +- .../controllers/TracingController.scala | 18 +++--- .../controllers/VolumeTracingController.scala | 7 ++- .../tracings/TracingService.scala | 7 ++- .../skeleton/SkeletonTracingService.scala | 7 ++- .../tracings/volume/MergedVolume.scala | 29 +++++++++- .../volume/VolumeTracingBucketHelper.scala | 21 +++++-- .../volume/VolumeTracingService.scala | 56 ++++++++++++------- 13 files changed, 118 insertions(+), 48 deletions(-) diff --git a/CHANGELOG.unreleased.md b/CHANGELOG.unreleased.md index 946780020ac..18e03d21ff8 100644 --- a/CHANGELOG.unreleased.md +++ b/CHANGELOG.unreleased.md @@ -18,6 +18,8 @@ For upgrade instructions, please check the [migration guide](MIGRATIONS.released ### Fixed - Fixed a bug where N5 datasets reading with end-chunks that have a chunk size differing from the metadata-supplied chunk size would fail for some areas. [#6890](https://github.com/scalableminds/webknossos/pull/6890) +- Fixed a bug where merging multiple volume annotations would result in inconsistent segment lists. [#6882](https://github.com/scalableminds/webknossos/pull/6882) +- Fixed a bug where uploading multiple annotations with volume layers at once would fail. [#6882](https://github.com/scalableminds/webknossos/pull/6882) ### Removed diff --git a/app/controllers/AnnotationIOController.scala b/app/controllers/AnnotationIOController.scala index d4c2ceb9e8d..cefd27c32db 100755 --- a/app/controllers/AnnotationIOController.scala +++ b/app/controllers/AnnotationIOController.scala @@ -23,7 +23,7 @@ import com.scalableminds.webknossos.datastore.models.datasource.{ SegmentationLayer } import com.scalableminds.webknossos.tracingstore.tracings.TracingType -import com.scalableminds.webknossos.tracingstore.tracings.volume.VolumeTracingDefaults +import com.scalableminds.webknossos.tracingstore.tracings.volume.{VolumeTracingDefaults, VolumeTracingDownsampling} import com.typesafe.scalalogging.LazyLogging import io.swagger.annotations._ @@ -280,7 +280,9 @@ Expects: fallbackLayer = fallbackLayerOpt.map(_.name), largestSegmentId = annotationService.combineLargestSegmentIdsByPrecedence(volumeTracing.largestSegmentId, - fallbackLayerOpt.map(_.largestSegmentId)) + fallbackLayerOpt.map(_.largestSegmentId)), + resolutions = + VolumeTracingDownsampling.resolutionsForVolumeTracing(dataSource, fallbackLayerOpt).map(vec3IntToProto) ) } diff --git a/app/controllers/JobsController.scala b/app/controllers/JobsController.scala index 2bd65fc441f..3b0910c3f31 100644 --- a/app/controllers/JobsController.scala +++ b/app/controllers/JobsController.scala @@ -272,9 +272,10 @@ class JobsController @Inject()(jobDAO: JobDAO, sil.SecuredAction.async { implicit request => log(Some(slackNotificationService.noticeFailedJobRequest)) { for { - organization <- organizationDAO.findOneByName(organizationName) ?~> Messages("organization.notFound", - organizationName) - _ <- bool2Fox(request.identity._organization == organization._id) ?~> "job.applyMergerMode.notAllowed.organization" ~> FORBIDDEN + organization <- organizationDAO.findOneByName(organizationName)(GlobalAccessContext) ?~> Messages( + "organization.notFound", + organizationName) + _ <- bool2Fox(request.identity._organization == organization._id) ?~> "job.materializeVolumeAnnotation.notAllowed.organization" ~> FORBIDDEN dataSet <- dataSetDAO.findOneByNameAndOrganization(dataSetName, organization._id) ?~> Messages( "dataSet.notFound", dataSetName) ~> NOT_FOUND diff --git a/conf/messages b/conf/messages index d8d320bd3cd..f6bcec4ad08 100644 --- a/conf/messages +++ b/conf/messages @@ -292,7 +292,7 @@ job.inferNuclei.notAllowed.organization = Currently nuclei inferral is only allo job.inferNeurons.notAllowed.organization = Currently neuron inferral is only allowed for datasets of your own organization. job.meshFile.notAllowed.organization = Calculating mesh files is only allowed for datasets of your own organization. job.globalizeFloodfill.notAllowed.organization = Globalizing floodfills is only allowed for datasets of your own organization. -job.applyMergerMode.notAllowed.organization = Applying merger mode tracings is only allowed for datasets of your own organization. +job.materializeVolumeAnnotation.notAllowed.organization = Materializing volume annotations is only allowed for datasets of your own organization. job.noWorkerForDatastore = Processing jobs are not available for the datastore this dataset belongs to. voxelytics.disabled = Voxelytics workflow reporting and logging are not enabled for this WEBKNOSSOS instance. diff --git a/webknossos-datastore/app/com/scalableminds/webknossos/datastore/dataformats/wkw/WKWDataFormatHelper.scala b/webknossos-datastore/app/com/scalableminds/webknossos/datastore/dataformats/wkw/WKWDataFormatHelper.scala index c71248f6567..0cb130b112f 100644 --- a/webknossos-datastore/app/com/scalableminds/webknossos/datastore/dataformats/wkw/WKWDataFormatHelper.scala +++ b/webknossos-datastore/app/com/scalableminds/webknossos/datastore/dataformats/wkw/WKWDataFormatHelper.scala @@ -70,6 +70,7 @@ trait WKWDataFormatHelper { path match { case headerRx(_, resolutionStr) => Vec3Int.fromMagLiteral(resolutionStr, allowScalar = true) + case _ => None } } diff --git a/webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/controllers/SkeletonTracingController.scala b/webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/controllers/SkeletonTracingController.scala index 3fd644abc6d..91f87181e3e 100644 --- a/webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/controllers/SkeletonTracingController.scala +++ b/webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/controllers/SkeletonTracingController.scala @@ -7,6 +7,7 @@ import com.scalableminds.webknossos.datastore.SkeletonTracing.{SkeletonTracing, import com.scalableminds.webknossos.datastore.services.UserAccessRequest import com.scalableminds.webknossos.tracingstore.slacknotification.TSSlackNotificationService import com.scalableminds.webknossos.tracingstore.tracings.skeleton._ +import com.scalableminds.webknossos.tracingstore.tracings.volume.MergedVolumeStats import com.scalableminds.webknossos.tracingstore.{TSRemoteWebKnossosClient, TracingStoreAccessTokenService} import play.api.i18n.Messages import play.api.libs.json.Json @@ -38,7 +39,7 @@ class SkeletonTracingController @Inject()(val tracingService: SkeletonTracingSer log() { accessTokenService.validateAccess(UserAccessRequest.webknossos, urlOrHeaderToken(token, request)) { val tracings: List[Option[SkeletonTracing]] = request.body - val mergedTracing = tracingService.merge(tracings.flatten) + val mergedTracing = tracingService.merge(tracings.flatten, MergedVolumeStats.empty) val processedTracing = tracingService.remapTooLargeTreeIds(mergedTracing) for { newId <- tracingService.save(processedTracing, None, processedTracing.version, toCache = !persist) diff --git a/webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/controllers/TracingController.scala b/webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/controllers/TracingController.scala index d7d571f4a2a..e298fe948d8 100644 --- a/webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/controllers/TracingController.scala +++ b/webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/controllers/TracingController.scala @@ -72,7 +72,7 @@ trait TracingController[T <: GeneratedMessage, Ts <: GeneratedMessage] extends C accessTokenService.validateAccess(UserAccessRequest.webknossos, urlOrHeaderToken(token, request)) { val savedIds = Fox.sequence(request.body.map { tracingOpt: Option[T] => tracingOpt match { - case Some(tracing) => tracingService.save(tracing, None, 0, toCache = false).map(Some(_)) + case Some(tracing) => tracingService.save(tracing, None, 0).map(Some(_)) case _ => Fox.successful(None) } }) @@ -236,15 +236,17 @@ trait TracingController[T <: GeneratedMessage, Ts <: GeneratedMessage] extends C log() { accessTokenService.validateAccess(UserAccessRequest.webknossos, urlOrHeaderToken(token, request)) { for { - tracings <- tracingService.findMultiple(request.body, applyUpdates = true) ?~> Messages("tracing.notFound") + tracingOpts <- tracingService.findMultiple(request.body, applyUpdates = true) ?~> Messages( + "tracing.notFound") + tracings = tracingOpts.flatten newId = tracingService.generateTracingId - mergedTracing = tracingService.merge(tracings.flatten) + mergedVolumeStats <- tracingService.mergeVolumeData(request.body.flatten, + tracings, + newId, + newVersion = 0L, + toCache = !persist) + mergedTracing = tracingService.merge(tracings, mergedVolumeStats) _ <- tracingService.save(mergedTracing, Some(newId), version = 0, toCache = !persist) - _ <- tracingService.mergeVolumeData(request.body.flatten, - tracings.flatten, - newId, - mergedTracing, - toCache = !persist) } yield { Ok(Json.toJson(newId)) } diff --git a/webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/controllers/VolumeTracingController.scala b/webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/controllers/VolumeTracingController.scala index d03120a4314..f244ee9fa37 100644 --- a/webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/controllers/VolumeTracingController.scala +++ b/webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/controllers/VolumeTracingController.scala @@ -18,6 +18,7 @@ import com.scalableminds.webknossos.tracingstore.tracings.editablemapping.{ MinCutParameters } import com.scalableminds.webknossos.tracingstore.tracings.volume.{ + MergedVolumeStats, ResolutionRestrictions, UpdateMappingNameAction, VolumeTracingService @@ -91,7 +92,11 @@ class VolumeTracingController @Inject()( log() { accessTokenService.validateAccess(UserAccessRequest.webknossos, urlOrHeaderToken(token, request)) { val tracings: List[Option[VolumeTracing]] = request.body - val mergedTracing = tracingService.merge(tracings.flatten) + val mergedTracing = + tracingService + .merge(tracings.flatten, MergedVolumeStats.empty) + // segment lists for multi-volume uploads are not supported yet, compare https://github.com/scalableminds/webknossos/issues/6887 + .copy(segments = List.empty) tracingService.save(mergedTracing, None, mergedTracing.version, toCache = !persist).map { newId => Ok(Json.toJson(newId)) } diff --git a/webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/tracings/TracingService.scala b/webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/tracings/TracingService.scala index c882c568e24..3cd5e2488e3 100644 --- a/webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/tracings/TracingService.scala +++ b/webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/tracings/TracingService.scala @@ -3,6 +3,7 @@ package com.scalableminds.webknossos.tracingstore.tracings import com.scalableminds.util.tools.{Fox, FoxImplicits, JsonHelper} import com.scalableminds.webknossos.tracingstore.TracingStoreRedisStore import com.scalableminds.webknossos.tracingstore.tracings.TracingType.TracingType +import com.scalableminds.webknossos.tracingstore.tracings.volume.MergedVolumeStats import com.typesafe.scalalogging.LazyLogging import play.api.libs.json._ import scalapb.{GeneratedMessage, GeneratedMessageCompanion} @@ -178,14 +179,14 @@ trait TracingService[T <: GeneratedMessage] def handledGroupIdStoreContains(tracingId: String, transactionId: String, version: Long): Fox[Boolean] = handledGroupIdStore.contains(handledGroupKey(tracingId, transactionId, version)) - def merge(tracings: Seq[T]): T + def merge(tracings: Seq[T], mergedVolumeStats: MergedVolumeStats): T def remapTooLargeTreeIds(tracing: T): T = tracing def mergeVolumeData(tracingSelectors: Seq[TracingSelector], tracings: Seq[T], newId: String, - newTracing: T, - toCache: Boolean): Fox[Unit] + newVersion: Long, + toCache: Boolean): Fox[MergedVolumeStats] } diff --git a/webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/tracings/skeleton/SkeletonTracingService.scala b/webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/tracings/skeleton/SkeletonTracingService.scala index f06c4a0a628..57554972fc1 100644 --- a/webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/tracings/skeleton/SkeletonTracingService.scala +++ b/webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/tracings/skeleton/SkeletonTracingService.scala @@ -10,6 +10,7 @@ import com.scalableminds.webknossos.tracingstore.TracingStoreRedisStore import com.scalableminds.webknossos.tracingstore.tracings.UpdateAction.SkeletonUpdateAction import com.scalableminds.webknossos.tracingstore.tracings.{TracingType, _} import com.scalableminds.webknossos.tracingstore.tracings.skeleton.updating._ +import com.scalableminds.webknossos.tracingstore.tracings.volume.MergedVolumeStats import net.liftweb.common.{Empty, Full} import play.api.libs.json.{JsObject, JsValue, Json} @@ -158,7 +159,7 @@ class SkeletonTracingService @Inject()( save(finalTracing, None, finalTracing.version) } - def merge(tracings: Seq[SkeletonTracing]): SkeletonTracing = + def merge(tracings: Seq[SkeletonTracing], mergedVolumeStats: MergedVolumeStats): SkeletonTracing = tracings .reduceLeft(mergeTwo) .copy( @@ -196,8 +197,8 @@ class SkeletonTracingService @Inject()( def mergeVolumeData(tracingSelectors: Seq[TracingSelector], tracings: Seq[SkeletonTracing], newId: String, - newTracing: SkeletonTracing, - toCache: Boolean): Fox[Unit] = Fox.successful(()) + newVersion: Long, + toCache: Boolean): Fox[MergedVolumeStats] = Fox.successful(MergedVolumeStats.empty) def updateActionLog(tracingId: String, newestVersion: Option[Long], oldestVersion: Option[Long]): Fox[JsValue] = { def versionedTupleToJson(tuple: (Long, List[SkeletonUpdateAction])): JsObject = diff --git a/webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/tracings/volume/MergedVolume.scala b/webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/tracings/volume/MergedVolume.scala index a2e958f5ce3..40584c25418 100644 --- a/webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/tracings/volume/MergedVolume.scala +++ b/webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/tracings/volume/MergedVolume.scala @@ -1,18 +1,27 @@ package com.scalableminds.webknossos.tracingstore.tracings.volume import java.io.File - import com.scalableminds.util.geometry.Vec3Int import com.scalableminds.util.tools.{ByteUtils, Fox} import com.scalableminds.webknossos.datastore.models.{BucketPosition, UnsignedInteger, UnsignedIntegerArray} import com.scalableminds.webknossos.datastore.services.DataConverter import com.scalableminds.webknossos.datastore.VolumeTracing.VolumeTracing.ElementClass +import com.scalableminds.webknossos.datastore.geometry.Vec3IntProto import com.scalableminds.webknossos.datastore.helpers.ProtoGeometryImplicits import net.liftweb.common.Box import scala.collection.mutable import scala.concurrent.ExecutionContext +case class MergedVolumeStats( + largestSegmentId: Long, + sortedResolutionList: Option[List[Vec3IntProto]], // None means do not touch the resolution list + labelMaps: List[Map[Long, Long]]) + +object MergedVolumeStats { + def empty: MergedVolumeStats = MergedVolumeStats(0L, None, List.empty) +} + class MergedVolume(elementClass: ElementClass, initialLargestSegmentId: Long = 0) extends DataConverter with ByteUtils @@ -51,7 +60,7 @@ class MergedVolume(elementClass: ElementClass, initialLargestSegmentId: Long = 0 addLabelSet(labelSet) } - def addLabelSet(labelSet: mutable.Set[UnsignedInteger]): Unit = labelSets += labelSet + private def addLabelSet(labelSet: mutable.Set[UnsignedInteger]): Unit = labelSets += labelSet private def prepareLabelMaps(): Unit = if (labelSets.isEmpty || (labelSets.length == 1 && initialLargestSegmentId == 0) || labelMaps.nonEmpty) { @@ -131,4 +140,20 @@ class MergedVolume(elementClass: ElementClass, initialLargestSegmentId: Long = 0 case (bucketPosition: BucketPosition, _) => bucketPosition.mag }.toSet + def stats: MergedVolumeStats = + MergedVolumeStats( + largestSegmentId.toLong, + Some(presentResolutions.toList.sortBy(_.maxDim).map(vec3IntToProto)), + labelMapsToLongMaps + ) + + private def labelMapsToLongMaps = + labelMaps.toList.map { unsignedIntegerMap => + val longMap = new mutable.HashMap[Long, Long]() + unsignedIntegerMap.foreach { keyValueTuple => + longMap += ((keyValueTuple._1.toLong, keyValueTuple._2.toLong)) + } + longMap.toMap + } + } diff --git a/webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/tracings/volume/VolumeTracingBucketHelper.scala b/webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/tracings/volume/VolumeTracingBucketHelper.scala index a4d6bfb17f6..8ec9bfa8495 100644 --- a/webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/tracings/volume/VolumeTracingBucketHelper.scala +++ b/webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/tracings/volume/VolumeTracingBucketHelper.scala @@ -54,8 +54,11 @@ trait VolumeBucketCompression extends LazyLogging { } } - def expectedUncompressedBucketSizeFor(dataLayer: DataLayer): Int = { - val bytesPerVoxel = ElementClass.bytesPerElement(dataLayer.elementClass) + def expectedUncompressedBucketSizeFor(dataLayer: DataLayer): Int = + expectedUncompressedBucketSizeFor(dataLayer.elementClass) + + def expectedUncompressedBucketSizeFor(elementClass: ElementClass.Value): Int = { + val bytesPerVoxel = ElementClass.bytesPerElement(elementClass) bytesPerVoxel * scala.math.pow(DataLayer.bucketLength, 3).intValue } } @@ -162,9 +165,17 @@ trait VolumeTracingBucketHelper bucket: BucketPosition, data: Array[Byte], version: Long, - toCache: Boolean = false): Fox[Unit] = { - val key = buildBucketKey(dataLayer.name, bucket) - val compressedBucket = compressVolumeBucket(data, expectedUncompressedBucketSizeFor(dataLayer)) + toCache: Boolean = false): Fox[Unit] = + saveBucket(dataLayer.name, dataLayer.elementClass, bucket, data, version, toCache) + + def saveBucket(tracingId: String, + elementClass: ElementClass.Value, + bucket: BucketPosition, + data: Array[Byte], + version: Long, + toCache: Boolean): Fox[Unit] = { + val key = buildBucketKey(tracingId, bucket) + val compressedBucket = compressVolumeBucket(data, expectedUncompressedBucketSizeFor(elementClass)) if (toCache) { // Note that this cache is for temporary volumes only (e.g. compound projects) // and cannot be used for download or versioning diff --git a/webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/tracings/volume/VolumeTracingService.scala b/webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/tracings/volume/VolumeTracingService.scala index 4c84c9324f2..99dc2baf39b 100644 --- a/webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/tracings/volume/VolumeTracingService.scala +++ b/webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/tracings/volume/VolumeTracingService.scala @@ -170,9 +170,9 @@ class VolumeTracingService @Inject()( val resolutionSet = resolutionSetFromZipfile(dataZip) if (resolutionSet.nonEmpty) resolutionSets.add(resolutionSet) } - // if none of the tracings contained any volume data do not save buckets, use full resolution list + // if none of the tracings contained any volume data do not save buckets, use full resolution list, as already initialized on wk-side if (resolutionSets.isEmpty) - getRequiredMags(tracing, tracingId).map(_.toSet) + Fox.successful(tracing.resolutions.map(vec3IntFromProto).toSet) else { val resolutionsDoMatch = resolutionSets.headOption.forall { head => resolutionSets.forall(_ == head) @@ -309,10 +309,10 @@ class VolumeTracingService @Inject()( .withBoundingBox(dataSetBoundingBox.get) } else tracing - def duplicateData(sourceId: String, - sourceTracing: VolumeTracing, - destinationId: String, - destinationTracing: VolumeTracing): Fox[Unit] = + private def duplicateData(sourceId: String, + sourceTracing: VolumeTracing, + destinationId: String, + destinationTracing: VolumeTracing): Fox[Unit] = for { isTemporaryTracing <- isTemporaryTracing(sourceId) sourceDataLayer = volumeTracingLayer(sourceId, sourceTracing, isTemporaryTracing) @@ -419,22 +419,41 @@ class VolumeTracingService @Inject()( } else None } yield bucketPosOpt - def merge(tracings: Seq[VolumeTracing]): VolumeTracing = - tracings - .reduceLeft(mergeTwo) + def merge(tracings: Seq[VolumeTracing], mergedVolumeStats: MergedVolumeStats): VolumeTracing = { + def mergeTwoWithStats(tracingAWithIndex: (VolumeTracing, Int), + tracingBWithIndex: (VolumeTracing, Int)): (VolumeTracing, Int) = + (mergeTwo(tracingAWithIndex._1, tracingBWithIndex._1, tracingBWithIndex._2, mergedVolumeStats), + tracingAWithIndex._2) + + tracings.zipWithIndex + .reduceLeft(mergeTwoWithStats) + ._1 .copy( createdTimestamp = System.currentTimeMillis(), version = 0L, ) + } - private def mergeTwo(tracingA: VolumeTracing, tracingB: VolumeTracing): VolumeTracing = { + private def mergeTwo(tracingA: VolumeTracing, + tracingB: VolumeTracing, + indexB: Int, + mergedVolumeStats: MergedVolumeStats): VolumeTracing = { val largestSegmentId = combineLargestSegmentIdsByMaxDefined(tracingA.largestSegmentId, tracingB.largestSegmentId) val mergedBoundingBox = combineBoundingBoxes(Some(tracingA.boundingBox), Some(tracingB.boundingBox)) val userBoundingBoxes = combineUserBoundingBoxes(tracingA.userBoundingBox, tracingB.userBoundingBox, tracingA.userBoundingBoxes, tracingB.userBoundingBoxes) - + val tracingBSegments = + if (indexB >= mergedVolumeStats.labelMaps.length) tracingB.segments + else { + val labelMap = mergedVolumeStats.labelMaps(indexB) + tracingB.segments.map { segment => + segment.copy( + segmentId = labelMap.getOrElse(segment.segmentId, segment.segmentId) + ) + } + } tracingA.copy( largestSegmentId = largestSegmentId, boundingBox = mergedBoundingBox.getOrElse( @@ -443,7 +462,8 @@ class VolumeTracingService @Inject()( 0, 0, 0)), // should never be empty for volumes - userBoundingBoxes = userBoundingBoxes + userBoundingBoxes = userBoundingBoxes, + segments = tracingA.segments.toList ::: tracingBSegments.toList ) } @@ -464,8 +484,8 @@ class VolumeTracingService @Inject()( def mergeVolumeData(tracingSelectors: Seq[TracingSelector], tracings: Seq[VolumeTracing], newId: String, - newTracing: VolumeTracing, - toCache: Boolean): Fox[Unit] = { + newVersion: Long, + toCache: Boolean): Fox[MergedVolumeStats] = { val elementClass = tracings.headOption.map(_.elementClass).getOrElse(elementClassToProto(ElementClass.uint8)) val resolutionSets = new mutable.HashSet[Set[Vec3Int]]() @@ -483,7 +503,7 @@ class VolumeTracingService @Inject()( // If none of the tracings contained any volume data. Do not save buckets, do not touch resolution list if (resolutionSets.isEmpty) - Fox.successful(()) + Fox.successful(MergedVolumeStats.empty) else { val resolutionsIntersection: Set[Vec3Int] = resolutionSets.headOption.map { head => resolutionSets.foldLeft(head) { (acc, element) => @@ -504,15 +524,13 @@ class VolumeTracingService @Inject()( val bucketStream = bucketStreamFromSelector(selector, tracing) mergedVolume.addFromBucketStream(sourceVolumeIndex, bucketStream, Some(resolutionsIntersection)) } - val destinationDataLayer = volumeTracingLayer(newId, newTracing) for { _ <- bool2Fox(ElementClass.largestSegmentIdIsInRange(mergedVolume.largestSegmentId.toLong, elementClass)) ?~> "annotation.volume.largestSegmentIdExceedsRange" _ <- mergedVolume.withMergedBuckets { (bucketPosition, bucketBytes) => - saveBucket(destinationDataLayer, bucketPosition, bucketBytes, newTracing.version, toCache) + saveBucket(newId, elementClass, bucketPosition, bucketBytes, newVersion, toCache) } - _ <- updateResolutionList(newId, newTracing, mergedVolume.presentResolutions) - } yield () + } yield mergedVolume.stats } } From 9cf421510318c394c05fe012eaf0a1c3dbfed36b Mon Sep 17 00:00:00 2001 From: Florian M Date: Mon, 6 Mar 2023 11:22:04 +0100 Subject: [PATCH 3/7] Logging on password reset/change (#6901) --- app/controllers/AuthenticationController.scala | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/controllers/AuthenticationController.scala b/app/controllers/AuthenticationController.scala index 799be7e8284..3b3aafbbc67 100755 --- a/app/controllers/AuthenticationController.scala +++ b/app/controllers/AuthenticationController.scala @@ -394,6 +394,7 @@ class AuthenticationController @Inject()( bearerTokenAuthenticatorService.userForToken(passwords.token.trim).futureBox.flatMap { case Full(user) => for { + - <- Fox.successful(logger.info(s"Multiuser ${user._multiUser} reset their password.")) _ <- multiUserDAO.updatePasswordInfo(user._multiUser, passwordHasher.hash(passwords.password1))( GlobalAccessContext) _ <- bearerTokenAuthenticatorService.remove(passwords.token.trim) @@ -420,6 +421,7 @@ class AuthenticationController @Inject()( Future.successful(NotFound(Messages("error.noUser"))) case Some(user) => for { + - <- Fox.successful(logger.info(s"Multiuser ${user._multiUser} changed their password.")) _ <- multiUserDAO.updatePasswordInfo(user._multiUser, passwordHasher.hash(passwords.password1)) _ <- combinedAuthenticatorService.discard(request.authenticator, Ok) userEmail <- userService.emailFor(user) From b992d9a01011af473a40e4f379dda602c8683ea1 Mon Sep 17 00:00:00 2001 From: Philipp Otto Date: Mon, 6 Mar 2023 12:33:22 +0100 Subject: [PATCH 4/7] Slim down view mode dropdown by using icons (#6900) * slim down view mode dropdown by using icons * update changelog * fix linting * disable view mode items when necessary --- CHANGELOG.unreleased.md | 1 + .../view/action-bar/dataset_position_view.tsx | 1 + .../view/action-bar/view_modes_view.tsx | 67 +++++++++++++------ package.json | 2 +- yarn.lock | 8 +-- 5 files changed, 54 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.unreleased.md b/CHANGELOG.unreleased.md index 18e03d21ff8..9df4c7cdab8 100644 --- a/CHANGELOG.unreleased.md +++ b/CHANGELOG.unreleased.md @@ -15,6 +15,7 @@ For upgrade instructions, please check the [migration guide](MIGRATIONS.released ### Changed - Upgraded antd UI library to v4.24.8 [#6865](https://github.com/scalableminds/webknossos/pull/6865) +- The view mode dropdown was slimmed down by using icons to make the toolbar more space efficient. [#6900](https://github.com/scalableminds/webknossos/pull/6900) ### Fixed - Fixed a bug where N5 datasets reading with end-chunks that have a chunk size differing from the metadata-supplied chunk size would fail for some areas. [#6890](https://github.com/scalableminds/webknossos/pull/6890) diff --git a/frontend/javascripts/oxalis/view/action-bar/dataset_position_view.tsx b/frontend/javascripts/oxalis/view/action-bar/dataset_position_view.tsx index e7e5de35f3e..50b732634fc 100644 --- a/frontend/javascripts/oxalis/view/action-bar/dataset_position_view.tsx +++ b/frontend/javascripts/oxalis/view/action-bar/dataset_position_view.tsx @@ -24,6 +24,7 @@ type Props = { }; const positionIconStyle: React.CSSProperties = { transform: "rotate(-45deg)", + marginRight: 0, }; const warningColors: React.CSSProperties = { color: "rgb(255, 155, 85)", diff --git a/frontend/javascripts/oxalis/view/action-bar/view_modes_view.tsx b/frontend/javascripts/oxalis/view/action-bar/view_modes_view.tsx index d6f62c4e374..08423fcdda9 100644 --- a/frontend/javascripts/oxalis/view/action-bar/view_modes_view.tsx +++ b/frontend/javascripts/oxalis/view/action-bar/view_modes_view.tsx @@ -1,4 +1,4 @@ -import { Select } from "antd"; +import { Button, Dropdown, MenuProps, Space } from "antd"; import { connect } from "react-redux"; import type { Dispatch } from "redux"; import React, { PureComponent } from "react"; @@ -9,9 +9,9 @@ import { import type { OxalisState, AllowedMode } from "oxalis/store"; import Store from "oxalis/store"; import * as Utils from "libs/utils"; -import type { ViewMode } from "oxalis/constants"; +import { ViewMode, ViewModeValues } from "oxalis/constants"; import constants from "oxalis/constants"; -const { Option } = Select; + type StateProps = { viewMode: ViewMode; allowedModes: Array; @@ -21,6 +21,17 @@ type DispatchProps = { }; type Props = StateProps & DispatchProps; +const VIEW_MODE_TO_ICON = { + [constants.MODE_PLANE_TRACING]: , + [constants.MODE_ARBITRARY]: , + [constants.MODE_ARBITRARY_PLANE]: ( + + ), +}; + class ViewModesView extends PureComponent { handleChange = (mode: ViewMode) => { // If we switch back from any arbitrary mode we stop recording. @@ -51,24 +62,40 @@ class ViewModesView extends PureComponent { } render() { + const handleMenuClick: MenuProps["onClick"] = (args) => { + if (ViewModeValues.includes(args.key as ViewMode)) { + this.handleChange(args.key as ViewMode); + } + }; + + const MENU_ITEMS: MenuProps["items"] = [ + { + key: "1", + type: "group", + label: "Select View Mode", + children: ViewModeValues.map((mode) => ({ + label: Utils.capitalize(mode), + key: mode, + disabled: this.isDisabled(mode), + icon: {VIEW_MODE_TO_ICON[mode]}, + })), + }, + ]; + + const menuProps = { + items: MENU_ITEMS, + onClick: handleMenuClick, + }; + return ( - + // The outer div is necessary for proper spacing. +
+ + + +
); } } diff --git a/package.json b/package.json index 5632c500fef..523973828f6 100644 --- a/package.json +++ b/package.json @@ -137,7 +137,7 @@ "@airbrake/browser": "^2.1.7", "@ant-design/colors": "^6.0.0", "@ant-design/icons": "^4.6.2", - "@fortawesome/fontawesome-free": "^5.14.0", + "@fortawesome/fontawesome-free": "^5.15.4", "@rehooks/document-title": "^1.0.2", "@scalableminds/prop-types": "^15.6.1", "@tanstack/query-sync-storage-persister": "^4.14.1", diff --git a/yarn.lock b/yarn.lock index e6bf6d71078..17591f9a59e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -572,10 +572,10 @@ minimatch "^3.0.4" strip-json-comments "^3.1.1" -"@fortawesome/fontawesome-free@^5.14.0": - version "5.15.1" - resolved "https://registry.yarnpkg.com/@fortawesome/fontawesome-free/-/fontawesome-free-5.15.1.tgz#ccfef6ddbe59f8fe8f694783e1d3eb88902dc5eb" - integrity sha512-OEdH7SyC1suTdhBGW91/zBfR6qaIhThbcN8PUXtXilY4GYnSBbVqOntdHbC1vXwsDnX0Qix2m2+DSU1J51ybOQ== +"@fortawesome/fontawesome-free@^5.15.4": + version "5.15.4" + resolved "https://registry.yarnpkg.com/@fortawesome/fontawesome-free/-/fontawesome-free-5.15.4.tgz#ecda5712b61ac852c760d8b3c79c96adca5554e5" + integrity sha512-eYm8vijH/hpzr/6/1CJ/V/Eb1xQFW2nnUKArb3z+yUWv7HTwj6M7SP957oMjfZjAHU6qpoNc2wQvIxBLWYa/Jg== "@humanwhocodes/config-array@^0.9.2": version "0.9.5" From c5673b90821d37277568cb8e0e2adf57a4e74e3f Mon Sep 17 00:00:00 2001 From: Philipp Otto Date: Thu, 9 Mar 2023 11:57:20 +0100 Subject: [PATCH 5/7] Fix rare crash when viewing shared annotation (#6892) * avoid that failing editAnnotation saga can crash wk; don't try to save in the first place if it's not allowed * add missing file * Apply suggestions from code review * Apply suggestions from code review * update changelog * fix tests by avoiding generator acrobatics in effect-generators module (not necessary anymore since we ditched Flow) --- CHANGELOG.unreleased.md | 1 + .../model/accessors/annotation_accessor.ts | 13 ++++++ .../oxalis/model/sagas/annotation_saga.tsx | 43 +++++++++++++------ .../oxalis/model/sagas/effect-generators.ts | 9 ++-- .../view/action-bar/share_modal_view.tsx | 6 +-- .../dataset_info_tab_view.tsx | 7 ++- 6 files changed, 59 insertions(+), 20 deletions(-) create mode 100644 frontend/javascripts/oxalis/model/accessors/annotation_accessor.ts diff --git a/CHANGELOG.unreleased.md b/CHANGELOG.unreleased.md index 9df4c7cdab8..3d694c1a70f 100644 --- a/CHANGELOG.unreleased.md +++ b/CHANGELOG.unreleased.md @@ -19,6 +19,7 @@ For upgrade instructions, please check the [migration guide](MIGRATIONS.released ### Fixed - Fixed a bug where N5 datasets reading with end-chunks that have a chunk size differing from the metadata-supplied chunk size would fail for some areas. [#6890](https://github.com/scalableminds/webknossos/pull/6890) +- Fixed potential crash when trying to edit certain annotation properties of a shared annotation. [#6892](https://github.com/scalableminds/webknossos/pull/6892) - Fixed a bug where merging multiple volume annotations would result in inconsistent segment lists. [#6882](https://github.com/scalableminds/webknossos/pull/6882) - Fixed a bug where uploading multiple annotations with volume layers at once would fail. [#6882](https://github.com/scalableminds/webknossos/pull/6882) diff --git a/frontend/javascripts/oxalis/model/accessors/annotation_accessor.ts b/frontend/javascripts/oxalis/model/accessors/annotation_accessor.ts new file mode 100644 index 00000000000..0fef0b0084f --- /dev/null +++ b/frontend/javascripts/oxalis/model/accessors/annotation_accessor.ts @@ -0,0 +1,13 @@ +import type { OxalisState } from "oxalis/store"; + +export function mayEditAnnotationProperties(state: OxalisState) { + const { owner, restrictions } = state.tracing; + const activeUser = state.activeUser; + + return !!( + restrictions.allowUpdate && + restrictions.allowSave && + activeUser && + owner?.id === activeUser.id + ); +} diff --git a/frontend/javascripts/oxalis/model/sagas/annotation_saga.tsx b/frontend/javascripts/oxalis/model/sagas/annotation_saga.tsx index 9e74c00edd2..e70bcfd9104 100644 --- a/frontend/javascripts/oxalis/model/sagas/annotation_saga.tsx +++ b/frontend/javascripts/oxalis/model/sagas/annotation_saga.tsx @@ -20,7 +20,6 @@ import { import type { Saga } from "oxalis/model/sagas/effect-generators"; import { takeLatest, - select, take, retry, delay, @@ -31,6 +30,7 @@ import { cancel, cancelled, } from "typed-redux-saga"; +import { select } from "oxalis/model/sagas/effect-generators"; import { getMappingInfo } from "oxalis/model/accessors/dataset_accessor"; import { getRequestLogZoomStep } from "oxalis/model/accessors/flycam_accessor"; import { Model } from "oxalis/singletons"; @@ -40,14 +40,17 @@ import constants, { MappingStatusEnum } from "oxalis/constants"; import messages from "messages"; import { APIUserCompact } from "types/api_flow_types"; import { Button } from "antd"; +import ErrorHandling from "libs/error_handling"; +import { mayEditAnnotationProperties } from "../accessors/annotation_accessor"; /* Note that this must stay in sync with the back-end constant compare https://github.com/scalableminds/webknossos/issues/5223 */ const MAX_MAG_FOR_AGGLOMERATE_MAPPING = 16; -export function* pushAnnotationUpdateAsync() { - const tracing = yield* select((state) => state.tracing); - if (!tracing.restrictions.allowUpdate) { +export function* pushAnnotationUpdateAsync(action: Action) { + const tracing = yield* select((state) => state.tracing); + const mayEdit = yield* select((state) => mayEditAnnotationProperties(state)); + if (!mayEdit) { return; } @@ -66,14 +69,30 @@ export function* pushAnnotationUpdateAsync() { description: tracing.description, viewConfiguration, }; - yield* retry( - SETTINGS_MAX_RETRY_COUNT, - SETTINGS_RETRY_DELAY, - editAnnotation, - tracing.annotationId, - tracing.annotationType, - editObject, - ); + try { + yield* retry( + SETTINGS_MAX_RETRY_COUNT, + SETTINGS_RETRY_DELAY, + editAnnotation, + tracing.annotationId, + tracing.annotationType, + editObject, + ); + } catch (error) { + // If the annotation cannot be saved repeatedly (retries will continue for 5 minutes), + // we will only notify the user if the name, visibility or description could not be changed. + // Otherwise, we won't notify the user and won't let the sagas crash as the actual skeleton/volume + // tracings are handled separately. + console.error(error); + ErrorHandling.notify(error as Error); + if ( + ["SET_ANNOTATION_NAME", "SET_ANNOTATION_VISIBILITY", "SET_ANNOTATION_DESCRIPTION"].includes( + action.type, + ) + ) { + Toast.error("Could not update annotation property. Please try again."); + } + } } function* pushAnnotationLayerUpdateAsync(action: EditAnnotationLayerAction): Saga { diff --git a/frontend/javascripts/oxalis/model/sagas/effect-generators.ts b/frontend/javascripts/oxalis/model/sagas/effect-generators.ts index 1bc882ea076..55a30ce89a6 100644 --- a/frontend/javascripts/oxalis/model/sagas/effect-generators.ts +++ b/frontend/javascripts/oxalis/model/sagas/effect-generators.ts @@ -5,9 +5,12 @@ import { select as _select, take as _take } from "typed-redux-saga"; import type { Channel } from "redux-saga"; import { ActionPattern } from "redux-saga/effects"; -export function* select(fn: (state: OxalisState) => T) { - const res: T = yield _select(fn); - return res; +// Ensures that the type of state is known. Otherwise, +// a statement such as +// const tracing = yield* select((state) => state.tracing); +// would result in tracing being any. +export function select(fn: (state: OxalisState) => T) { + return _select(fn); } export function* take( diff --git a/frontend/javascripts/oxalis/view/action-bar/share_modal_view.tsx b/frontend/javascripts/oxalis/view/action-bar/share_modal_view.tsx index 3f69597df13..fe83866fa70 100644 --- a/frontend/javascripts/oxalis/view/action-bar/share_modal_view.tsx +++ b/frontend/javascripts/oxalis/view/action-bar/share_modal_view.tsx @@ -46,6 +46,7 @@ import { makeComponentLazy } from "libs/react_helpers"; import { AsyncButton } from "components/async_clickables"; import { PricingEnforcedBlur } from "components/pricing_enforcers"; import { PricingPlanEnum } from "admin/organization/pricing_plan_utils"; +import { mayEditAnnotationProperties } from "oxalis/model/accessors/annotation_accessor"; const RadioGroup = Radio.Group; const sharingActiveNode = true; @@ -181,11 +182,10 @@ function _ShareModalView(props: Props) { const [sharedTeams, setSharedTeams] = useState([]); const sharingToken = useDatasetSharingToken(dataset); - const { owner, othersMayEdit, restrictions } = tracing; + const { othersMayEdit } = tracing; const [newOthersMayEdit, setNewOthersMayEdit] = useState(othersMayEdit); - const hasUpdatePermissions = - restrictions.allowUpdate && restrictions.allowSave && activeUser && owner?.id === activeUser.id; + const hasUpdatePermissions = useSelector(mayEditAnnotationProperties); useEffect(() => setVisibility(annotationVisibility), [annotationVisibility]); const fetchAndSetSharedTeams = async () => { diff --git a/frontend/javascripts/oxalis/view/right-border-tabs/dataset_info_tab_view.tsx b/frontend/javascripts/oxalis/view/right-border-tabs/dataset_info_tab_view.tsx index c9d82eeb89e..39ef2d75400 100644 --- a/frontend/javascripts/oxalis/view/right-border-tabs/dataset_info_tab_view.tsx +++ b/frontend/javascripts/oxalis/view/right-border-tabs/dataset_info_tab_view.tsx @@ -28,6 +28,7 @@ import { } from "oxalis/view/right-border-tabs/starting_job_modals"; import { formatUserName } from "oxalis/model/accessors/user_accessor"; import { mayUserEditDataset } from "libs/utils"; +import { mayEditAnnotationProperties } from "oxalis/model/accessors/annotation_accessor"; const enum StartableJobsEnum { NUCLEI_INFERRAL = "nuclei inferral", @@ -40,6 +41,7 @@ type StateProps = { task: Task | null | undefined; activeUser: APIUser | null | undefined; activeResolution: Vector3; + mayEditAnnotation: boolean; }; type DispatchProps = { setAnnotationName: (arg0: string) => void; @@ -453,7 +455,7 @@ class DatasetInfoTabView extends React.PureComponent { {annotationType} : {this.props.task.id} ); - } else if (!this.props.tracing.restrictions.allowUpdate) { + } else if (!this.props.mayEditAnnotation) { // For readonly tracings display the non-editable explorative tracing name annotationTypeLabel = Annotation: {tracingName}; } else { @@ -477,7 +479,7 @@ class DatasetInfoTabView extends React.PureComponent { const tracingDescription = this.props.tracing.description || "[no description]"; let descriptionEditField; - if (this.props.tracing.restrictions.allowUpdate) { + if (this.props.mayEditAnnotation) { descriptionEditField = ( ({ task: state.task, activeUser: state.activeUser, activeResolution: getCurrentResolution(state), + mayEditAnnotation: mayEditAnnotationProperties(state), }); const mapDispatchToProps = (dispatch: Dispatch) => ({ From be44b113ead55ae798e34d3114c9d5d9365ab015 Mon Sep 17 00:00:00 2001 From: Tom Herold Date: Thu, 9 Mar 2023 14:06:07 +0100 Subject: [PATCH 6/7] Fix date formatting for VX reports (#6908) * add localized format plugin to dayjs * updated changelog --- CHANGELOG.unreleased.md | 1 + frontend/javascripts/libs/format_utils.ts | 2 ++ 2 files changed, 3 insertions(+) diff --git a/CHANGELOG.unreleased.md b/CHANGELOG.unreleased.md index 3d694c1a70f..5b69b39b5b3 100644 --- a/CHANGELOG.unreleased.md +++ b/CHANGELOG.unreleased.md @@ -22,6 +22,7 @@ For upgrade instructions, please check the [migration guide](MIGRATIONS.released - Fixed potential crash when trying to edit certain annotation properties of a shared annotation. [#6892](https://github.com/scalableminds/webknossos/pull/6892) - Fixed a bug where merging multiple volume annotations would result in inconsistent segment lists. [#6882](https://github.com/scalableminds/webknossos/pull/6882) - Fixed a bug where uploading multiple annotations with volume layers at once would fail. [#6882](https://github.com/scalableminds/webknossos/pull/6882) +- Fixed a bug where dates were formatted incorrectly in Voxelytics reports. [#6908](https://github.com/scalableminds/webknossos/pull/6908) ### Removed diff --git a/frontend/javascripts/libs/format_utils.ts b/frontend/javascripts/libs/format_utils.ts index 830cad8b08c..0d46800d20f 100644 --- a/frontend/javascripts/libs/format_utils.ts +++ b/frontend/javascripts/libs/format_utils.ts @@ -7,6 +7,7 @@ import dayjs from "dayjs"; import duration from "dayjs/plugin/duration"; import updateLocale from "dayjs/plugin/updateLocale"; import relativeTime from "dayjs/plugin/relativeTime"; +import localizedFormat from "dayjs/plugin/localizedFormat"; import calendar from "dayjs/plugin/calendar"; import utc from "dayjs/plugin/utc"; import weekday from "dayjs/plugin/weekday"; @@ -22,6 +23,7 @@ dayjs.extend(utc); dayjs.extend(calendar); dayjs.extend(weekday); dayjs.extend(localeData); +dayjs.extend(localizedFormat); dayjs.updateLocale("en", { weekStart: 1, calendar: { From 0f6233f4b94049129df5df4c40743ea7e987c639 Mon Sep 17 00:00:00 2001 From: Florian M Date: Mon, 13 Mar 2023 09:56:42 +0100 Subject: [PATCH 7/7] Avoid SQL error when fetching view config for zero-layer dataset (#6912) --- app/models/user/User.scala | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/app/models/user/User.scala b/app/models/user/User.scala index e66f66126a7..528912eadb4 100644 --- a/app/models/user/User.scala +++ b/app/models/user/User.scala @@ -417,14 +417,18 @@ class UserDataSetLayerConfigurationDAO @Inject()(sqlClient: SqlClient, userDAO: def findAllByLayerNameForUserAndDataset(layerNames: List[String], userId: ObjectId, dataSetId: ObjectId): Fox[Map[String, LayerViewConfiguration]] = - for { - rows <- run(q"""select layerName, viewConfiguration + if (layerNames.isEmpty) { + Fox.successful(Map.empty[String, LayerViewConfiguration]) + } else { + for { + rows <- run(q"""select layerName, viewConfiguration from webknossos.user_dataSetLayerConfigurations where _dataset = $dataSetId and _user = $userId and layerName in ${SqlToken.tupleFromList(layerNames)}""".as[(String, String)]) - parsed = rows.flatMap(t => Json.parse(t._2).asOpt[LayerViewConfiguration].map((t._1, _))) - } yield parsed.toMap + parsed = rows.flatMap(t => Json.parse(t._2).asOpt[LayerViewConfiguration].map((t._1, _))) + } yield parsed.toMap + } def updateDatasetConfigurationForUserAndDatasetAndLayer( userId: ObjectId,