diff --git a/CHANGELOG.unreleased.md b/CHANGELOG.unreleased.md index 4d26846d21..ef65948306 100644 --- a/CHANGELOG.unreleased.md +++ b/CHANGELOG.unreleased.md @@ -13,6 +13,7 @@ For upgrade instructions, please check the [migration guide](MIGRATIONS.released ### Added - Added CTRL+Scroll for zooming, which enables pinch-to-zoom on some trackpads. [#5224](https://github.com/scalableminds/webknossos/pull/5224) - The time spent on a project is now displayed in the project list. [#5209](https://github.com/scalableminds/webknossos/pull/5209) +- Added the possibility to export binary data as tiff (if long-runnings jobs are enabled). [#5195](https://github.com/scalableminds/webknossos/pull/5195) ### Changed - diff --git a/app/controllers/JobsController.scala b/app/controllers/JobsController.scala index b0eff22242..915459c70e 100644 --- a/app/controllers/JobsController.scala +++ b/app/controllers/JobsController.scala @@ -1,6 +1,10 @@ package controllers +import java.nio.file.{Files, Paths} +import java.util.Date + import com.mohiva.play.silhouette.api.Silhouette +import com.scalableminds.util.geometry.BoundingBox import com.scalableminds.util.tools.{Fox, FoxImplicits} import com.scalableminds.webknossos.datastore.rpc.{RPC, RPCRequest} import com.scalableminds.webknossos.schema.Tables.{Jobs, JobsRow} @@ -91,7 +95,7 @@ class JobService @Inject()(wkConf: WkConf, jobDAO: JobDAO, rpc: RPC, analyticsSe } else { val updateResult = for { _ <- Fox.successful(celeryInfosLastUpdated = System.currentTimeMillis()) - celeryInfoJson <- flowerRpc("/api/tasks").getWithJsonResponse[JsObject] + celeryInfoJson <- flowerRpc("/api/tasks?offset=0").getWithJsonResponse[JsObject] celeryInfoMap <- celeryInfoJson .validate[Map[String, JsObject]] ?~> "Could not validate celery response as json map" _ <- Fox.serialCombined(celeryInfoMap.keys.toList)(jobId => @@ -131,6 +135,13 @@ class JobService @Inject()(wkConf: WkConf, jobDAO: JobDAO, rpc: RPC, analyticsSe private def flowerRpc(route: String): RPCRequest = rpc(wkConf.Jobs.Flower.uri + route).withBasicAuth(wkConf.Jobs.Flower.username, wkConf.Jobs.Flower.password) + + def assertTiffExportBoundingBoxLimits(bbox: String): Fox[Unit] = + for { + boundingBox <- BoundingBox.fromForm(bbox).toFox + _ <- bool2Fox(boundingBox.volume <= wkConf.Features.exportTiffMaxVolumeMVx * 1024 * 1024) ?~> "job.export.tiff.volumeExceeded" + _ <- bool2Fox(boundingBox.dimensions.maxDim <= wkConf.Features.exportTiffMaxEdgeLengthVx) ?~> "job.export.tiff.edgeLengthExceeded" + } yield () } class JobsController @Inject()(jobDAO: JobDAO, @@ -171,4 +182,38 @@ class JobsController @Inject()(jobDAO: JobDAO, } yield Ok(js) } + def runTiffExportJob(organizationName: String, + dataSetName: String, + layerName: String, + bbox: String): Action[AnyContent] = + sil.SecuredAction.async { implicit request => + for { + organization <- organizationDAO.findOneByName(organizationName) ?~> Messages("organization.notFound", + organizationName) + _ <- bool2Fox(request.identity._organization == organization._id) ~> FORBIDDEN + _ <- jobService.assertTiffExportBoundingBoxLimits(bbox) + command = "export_tiff" + exportFileName = s"${formatDateForFilename(new Date())}__${dataSetName}__${layerName}.zip" + commandArgs = Json.obj( + "kwargs" -> Json.obj("organization_name" -> organizationName, + "dataset_name" -> dataSetName, + "layer_name" -> layerName, + "bbox" -> bbox, + "export_file_name" -> exportFileName)) + job <- jobService.runJob(command, commandArgs, request.identity) ?~> "job.couldNotRunTiffExport" + js <- jobService.publicWrites(job) + } yield Ok(js) + } + + def downloadExport(jobId: String, exportFileName: String): Action[AnyContent] = + sil.SecuredAction.async { implicit request => + for { + jobIdValidated <- ObjectId.parse(jobId) + job <- jobDAO.findOne(jobIdValidated) + organization <- organizationDAO.findOne(request.identity._organization) + filePath = Paths.get("binaryData", organization.name, ".export", job.celeryJobId, exportFileName) + _ <- bool2Fox(Files.exists(filePath)) ?~> "job.export.fileNotFound" + } yield Ok.sendPath(filePath, inline = false) + } + } diff --git a/app/utils/WkConf.scala b/app/utils/WkConf.scala index 4a2ce141e8..470eeca1c4 100644 --- a/app/utils/WkConf.scala +++ b/app/utils/WkConf.scala @@ -100,6 +100,8 @@ class WkConf @Inject()(configuration: Configuration) extends ConfigReader { val taskReopenAllowed: FiniteDuration = get[Int]("features.taskReopenAllowedInSeconds") seconds val allowDeleteDatasets: Boolean = get[Boolean]("features.allowDeleteDatasets") val publicDemoDatasetUrl: String = get[String]("features.publicDemoDatasetUrl") + val exportTiffMaxVolumeMVx: Long = get[Long]("features.exportTiffMaxVolumeMVx") + val exportTiffMaxEdgeLengthVx: Long = get[Long]("features.exportTiffMaxEdgeLengthVx") } object BackendAnalytics { diff --git a/conf/application.conf b/conf/application.conf index 205bbadab5..54da3d30f3 100644 --- a/conf/application.conf +++ b/conf/application.conf @@ -87,6 +87,8 @@ features { # If isDemoInstance == false, `/view` is appended to the URL so that it's opened in view mode (since the user might not # have an account). publicDemoDatasetUrl = "https://webknossos.org/datasets/scalable_minds/l4dense_motta_et_al_demo" + exportTiffMaxVolumeMVx = 1024 + exportTiffMaxEdgeLengthVx = 8192 } tracingstore { diff --git a/conf/messages b/conf/messages index a92db85ee7..fe68ad2da4 100644 --- a/conf/messages +++ b/conf/messages @@ -313,7 +313,11 @@ initialData.organizationsNotEmpty=There are already organizations present in the job.notFound = Job with id {0} could not be found. job.couldNotRunCubing = Failed to start WKW conversion job. +job.couldNotRunTiffExport = Failed to start Tiff export job. job.disabled = Long-running jobs are not enabled for this webKnossos instance. +job.export.fileNotFound = Exported file not found. The link may be expired. +job.export.tiff.volumeExceeded = The volume of the selected bounding box is too large. +job.export.tiff.edgeLengthExceeded = An edge length of the selected bounding box is too large. agglomerateSkeleton.failed=Could not generate agglomerate skeleton. diff --git a/conf/webknossos.latest.routes b/conf/webknossos.latest.routes index 1ed6134954..331f13d257 100644 --- a/conf/webknossos.latest.routes +++ b/conf/webknossos.latest.routes @@ -2,197 +2,199 @@ # This file defines all application routes (Higher priority routes first) # ~~~~ -GET /buildinfo controllers.Application.buildInfo -GET /features controllers.Application.features -GET /health controllers.Application.health -POST /analytics/:namespace controllers.Application.analytics(namespace) -POST /triggers/initialData controllers.InitialDataController.triggerInsert -GET /maintenance controllers.MaintenanceController.info -POST /maintenance controllers.MaintenanceController.initMaintenance -DELETE /maintenance controllers.MaintenanceController.closeMaintenance +GET /buildinfo controllers.Application.buildInfo +GET /features controllers.Application.features +GET /health controllers.Application.health +POST /analytics/:namespace controllers.Application.analytics(namespace) +POST /triggers/initialData controllers.InitialDataController.triggerInsert +GET /maintenance controllers.MaintenanceController.info +POST /maintenance controllers.MaintenanceController.initMaintenance +DELETE /maintenance controllers.MaintenanceController.closeMaintenance # Authentication -POST /auth/register controllers.AuthenticationController.register -POST /auth/joinOrganization/:inviteToken controllers.AuthenticationController.joinOrganization(inviteToken: String) -POST /auth/login controllers.AuthenticationController.authenticate -GET /auth/token controllers.AuthenticationController.getToken -DELETE /auth/token controllers.AuthenticationController.deleteToken -GET /auth/switch controllers.AuthenticationController.switchMultiUser(to: String) -POST /auth/switchOrganization/:organizationName controllers.AuthenticationController.switchOrganization(organizationName: String) -POST /auth/sendInvites controllers.AuthenticationController.sendInvites -POST /auth/startResetPassword controllers.AuthenticationController.handleStartResetPassword -POST /auth/changePassword controllers.AuthenticationController.changePassword -POST /auth/resetPassword controllers.AuthenticationController.handleResetPassword -GET /auth/logout controllers.AuthenticationController.logout -GET /auth/sso controllers.AuthenticationController.singleSignOn(sso: String, sig: String) -POST /auth/createOrganizationWithAdmin controllers.AuthenticationController.createOrganizationWithAdmin +POST /auth/register controllers.AuthenticationController.register +POST /auth/joinOrganization/:inviteToken controllers.AuthenticationController.joinOrganization(inviteToken: String) +POST /auth/login controllers.AuthenticationController.authenticate +GET /auth/token controllers.AuthenticationController.getToken +DELETE /auth/token controllers.AuthenticationController.deleteToken +GET /auth/switch controllers.AuthenticationController.switchMultiUser(to: String) +POST /auth/switchOrganization/:organizationName controllers.AuthenticationController.switchOrganization(organizationName: String) +POST /auth/sendInvites controllers.AuthenticationController.sendInvites +POST /auth/startResetPassword controllers.AuthenticationController.handleStartResetPassword +POST /auth/changePassword controllers.AuthenticationController.changePassword +POST /auth/resetPassword controllers.AuthenticationController.handleResetPassword +GET /auth/logout controllers.AuthenticationController.logout +GET /auth/sso controllers.AuthenticationController.singleSignOn(sso: String, sig: String) +POST /auth/createOrganizationWithAdmin controllers.AuthenticationController.createOrganizationWithAdmin # Configurations -GET /user/userConfiguration controllers.ConfigurationController.read -PUT /user/userConfiguration controllers.ConfigurationController.update -POST /dataSetConfigurations/:organizationName/:dataSetName controllers.ConfigurationController.readDataSetViewConfiguration(organizationName: String, dataSetName: String, sharingToken: Option[String]) -PUT /dataSetConfigurations/:organizationName/:dataSetName controllers.ConfigurationController.updateDataSetViewConfiguration(organizationName: String, dataSetName: String) -GET /dataSetConfigurations/default/:organizationName/:dataSetName controllers.ConfigurationController.readDataSetAdminViewConfiguration(organizationName: String, dataSetName: String) -PUT /dataSetConfigurations/default/:organizationName/:dataSetName controllers.ConfigurationController.updateDataSetAdminViewConfiguration(organizationName: String, dataSetName: String) +GET /user/userConfiguration controllers.ConfigurationController.read +PUT /user/userConfiguration controllers.ConfigurationController.update +POST /dataSetConfigurations/:organizationName/:dataSetName controllers.ConfigurationController.readDataSetViewConfiguration(organizationName: String, dataSetName: String, sharingToken: Option[String]) +PUT /dataSetConfigurations/:organizationName/:dataSetName controllers.ConfigurationController.updateDataSetViewConfiguration(organizationName: String, dataSetName: String) +GET /dataSetConfigurations/default/:organizationName/:dataSetName controllers.ConfigurationController.readDataSetAdminViewConfiguration(organizationName: String, dataSetName: String) +PUT /dataSetConfigurations/default/:organizationName/:dataSetName controllers.ConfigurationController.updateDataSetAdminViewConfiguration(organizationName: String, dataSetName: String) # Users -POST /user/tasks/request controllers.TaskController.request -GET /user/tasks/peek controllers.TaskController.peekNext - -GET /users controllers.UserController.list -GET /user controllers.UserController.current -GET /user/tasks controllers.UserController.tasks(isFinished: Option[Boolean], limit: Option[Int], pageNumber: Option[Int], includeTotalCount: Option[Boolean]) -GET /user/annotations controllers.UserController.annotations(isFinished: Option[Boolean], limit: Option[Int], pageNumber: Option[Int], includeTotalCount: Option[Boolean]) -GET /user/loggedTime controllers.UserController.loggedTime -GET /users/:id controllers.UserController.user(id: String) -PATCH /users/:id controllers.UserController.update(id: String) -PUT /users/:id/taskTypeId controllers.UserController.updateLastTaskTypeId(id: String) -PUT /users/:id/novelUserExperienceInfos controllers.UserController.updateNovelUserExperienceInfos(id: String) -GET /users/:id/tasks controllers.UserController.userTasks(id: String, isFinished: Option[Boolean], limit: Option[Int], pageNumber: Option[Int], includeTotalCount: Option[Boolean]) -GET /users/:id/loggedTime controllers.UserController.userLoggedTime(id: String) -POST /users/loggedTime controllers.UserController.usersLoggedTime -GET /users/:id/annotations controllers.UserController.userAnnotations(id: String, isFinished: Option[Boolean], limit: Option[Int], pageNumber: Option[Int], includeTotalCount: Option[Boolean]) +POST /user/tasks/request controllers.TaskController.request +GET /user/tasks/peek controllers.TaskController.peekNext + +GET /users controllers.UserController.list +GET /user controllers.UserController.current +GET /user/tasks controllers.UserController.tasks(isFinished: Option[Boolean], limit: Option[Int], pageNumber: Option[Int], includeTotalCount: Option[Boolean]) +GET /user/annotations controllers.UserController.annotations(isFinished: Option[Boolean], limit: Option[Int], pageNumber: Option[Int], includeTotalCount: Option[Boolean]) +GET /user/loggedTime controllers.UserController.loggedTime +GET /users/:id controllers.UserController.user(id: String) +PATCH /users/:id controllers.UserController.update(id: String) +PUT /users/:id/taskTypeId controllers.UserController.updateLastTaskTypeId(id: String) +PUT /users/:id/novelUserExperienceInfos controllers.UserController.updateNovelUserExperienceInfos(id: String) +GET /users/:id/tasks controllers.UserController.userTasks(id: String, isFinished: Option[Boolean], limit: Option[Int], pageNumber: Option[Int], includeTotalCount: Option[Boolean]) +GET /users/:id/loggedTime controllers.UserController.userLoggedTime(id: String) +POST /users/loggedTime controllers.UserController.usersLoggedTime +GET /users/:id/annotations controllers.UserController.userAnnotations(id: String, isFinished: Option[Boolean], limit: Option[Int], pageNumber: Option[Int], includeTotalCount: Option[Boolean]) # Team -GET /teams controllers.TeamController.list(isEditable: Option[Boolean]) -POST /teams controllers.TeamController.create -DELETE /teams/:id controllers.TeamController.delete(id: String) -GET /teams/:id/openTasksOverview controllers.ReportController.openTasksOverview(id: String) -GET /teams/:id/progressOverview controllers.ReportController.projectProgressOverview(id: String) +GET /teams controllers.TeamController.list(isEditable: Option[Boolean]) +POST /teams controllers.TeamController.create +DELETE /teams/:id controllers.TeamController.delete(id: String) +GET /teams/:id/openTasksOverview controllers.ReportController.openTasksOverview(id: String) +GET /teams/:id/progressOverview controllers.ReportController.projectProgressOverview(id: String) # DataSets -POST /datasets/:organizationName/:dataSetName/createExplorational controllers.AnnotationController.createExplorational(organizationName: String, dataSetName: String) -GET /datasets controllers.DataSetController.list -POST /datasets controllers.DataSetController.create(typ: String) -POST /datasets/addForeign controllers.DataSetController.addForeignDataStoreAndDataSet -GET /datasets/disambiguate/:dataSetName/toNew controllers.DataSetController.getOrganizationForDataSet(dataSetName: String) -GET /datasets/:organizationName/:dataSetName/health controllers.DataSetController.health(organizationName: String, dataSetName: String, sharingToken: Option[String]) -PATCH /datasets/:organizationName/:dataSetName controllers.DataSetController.update(organizationName: String, dataSetName: String) -GET /datasets/:organizationName/:dataSetName/accessList controllers.DataSetController.accessList(organizationName: String, dataSetName: String) -GET /datasets/:organizationName/:dataSetName/sharingToken controllers.DataSetController.getSharingToken(organizationName: String, dataSetName: String) -DELETE /datasets/:organizationName/:dataSetName/sharingToken controllers.DataSetController.deleteSharingToken(organizationName: String, dataSetName: String) -PATCH /datasets/:organizationName/:dataSetName/teams controllers.DataSetController.updateTeams(organizationName: String, dataSetName: String) -GET /datasets/:organizationName/:dataSetName/layers/:layer/thumbnail controllers.DataSetController.thumbnail(organizationName: String, dataSetName: String, layer: String, w: Option[Int], h: Option[Int]) -PUT /datasets/:organizationName/:dataSetName/clearThumbnailCache controllers.DataSetController.removeFromThumbnailCache(organizationName: String, dataSetName: String) -GET /datasets/:organizationName/:dataSetName/isValidNewName controllers.DataSetController.isValidNewName(organizationName: String, dataSetName: String) -GET /datasets/:organizationName/:dataSetName controllers.DataSetController.read(organizationName: String, dataSetName: String, sharingToken: Option[String]) +POST /datasets/:organizationName/:dataSetName/createExplorational controllers.AnnotationController.createExplorational(organizationName: String, dataSetName: String) +GET /datasets controllers.DataSetController.list +POST /datasets controllers.DataSetController.create(typ: String) +POST /datasets/addForeign controllers.DataSetController.addForeignDataStoreAndDataSet +GET /datasets/disambiguate/:dataSetName/toNew controllers.DataSetController.getOrganizationForDataSet(dataSetName: String) +GET /datasets/:organizationName/:dataSetName/health controllers.DataSetController.health(organizationName: String, dataSetName: String, sharingToken: Option[String]) +PATCH /datasets/:organizationName/:dataSetName controllers.DataSetController.update(organizationName: String, dataSetName: String) +GET /datasets/:organizationName/:dataSetName/accessList controllers.DataSetController.accessList(organizationName: String, dataSetName: String) +GET /datasets/:organizationName/:dataSetName/sharingToken controllers.DataSetController.getSharingToken(organizationName: String, dataSetName: String) +DELETE /datasets/:organizationName/:dataSetName/sharingToken controllers.DataSetController.deleteSharingToken(organizationName: String, dataSetName: String) +PATCH /datasets/:organizationName/:dataSetName/teams controllers.DataSetController.updateTeams(organizationName: String, dataSetName: String) +GET /datasets/:organizationName/:dataSetName/layers/:layer/thumbnail controllers.DataSetController.thumbnail(organizationName: String, dataSetName: String, layer: String, w: Option[Int], h: Option[Int]) +PUT /datasets/:organizationName/:dataSetName/clearThumbnailCache controllers.DataSetController.removeFromThumbnailCache(organizationName: String, dataSetName: String) +GET /datasets/:organizationName/:dataSetName/isValidNewName controllers.DataSetController.isValidNewName(organizationName: String, dataSetName: String) +GET /datasets/:organizationName/:dataSetName controllers.DataSetController.read(organizationName: String, dataSetName: String, sharingToken: Option[String]) # Datastores -GET /datastores controllers.DataStoreController.list -PUT /datastores/:name/datasource controllers.WKDataStoreController.updateOne(name: String) -PUT /datastores/:name/datasources controllers.WKDataStoreController.updateAll(name: String) -PATCH /datastores/:name/status controllers.WKDataStoreController.statusUpdate(name: String) -POST /datastores/:name/verifyUpload controllers.WKDataStoreController.validateDataSetUpload(name: String) -POST /datastores/:name/reportDatasetUpload controllers.WKDataStoreController.reportDatasetUpload(name: String, token: String, dataSetName: String, dataSetSizeBytes: Long) -POST /datastores/:name/deleteErroneous controllers.WKDataStoreController.deleteErroneous(name: String) -POST /datastores/:name/reportIsosurfaceRequest controllers.WKDataStoreController.reportIsosurfaceRequest(name: String, token: Option[String]) -POST /datastores/:name/validateUserAccess controllers.UserTokenController.validateAccessViaDatastore(name: String, token: Option[String]) -POST /datastores controllers.DataStoreController.create -DELETE /datastores/:name controllers.DataStoreController.delete(name: String) -PUT /datastores/:name controllers.DataStoreController.update(name: String) +GET /datastores controllers.DataStoreController.list +PUT /datastores/:name/datasource controllers.WKDataStoreController.updateOne(name: String) +PUT /datastores/:name/datasources controllers.WKDataStoreController.updateAll(name: String) +PATCH /datastores/:name/status controllers.WKDataStoreController.statusUpdate(name: String) +POST /datastores/:name/verifyUpload controllers.WKDataStoreController.validateDataSetUpload(name: String) +POST /datastores/:name/reportDatasetUpload controllers.WKDataStoreController.reportDatasetUpload(name: String, token: String, dataSetName: String, dataSetSizeBytes: Long) +POST /datastores/:name/deleteErroneous controllers.WKDataStoreController.deleteErroneous(name: String) +POST /datastores/:name/reportIsosurfaceRequest controllers.WKDataStoreController.reportIsosurfaceRequest(name: String, token: Option[String]) +POST /datastores/:name/validateUserAccess controllers.UserTokenController.validateAccessViaDatastore(name: String, token: Option[String]) +POST /datastores controllers.DataStoreController.create +DELETE /datastores/:name controllers.DataStoreController.delete(name: String) +PUT /datastores/:name controllers.DataStoreController.update(name: String) # Tracingstores -GET /tracingstore controllers.TracingStoreController.listOne -POST /tracingstores/:name/handleTracingUpdateReport controllers.WKTracingStoreController.handleTracingUpdateReport(name: String) -POST /tracingstores/:name/reportIsosurfaceRequest controllers.WKTracingStoreController.reportIsosurfaceRequest(name: String, token: Option[String]) -POST /tracingstores/:name/validateUserAccess controllers.UserTokenController.validateAccessViaTracingstore(name: String, token: Option[String]) -PUT /tracingstores/:name controllers.TracingStoreController.update(name: String) -GET /tracingstores/:name/dataSource/:dataSetName controllers.WKTracingStoreController.dataSource(name: String, organizationName: Option[String], dataSetName: String) +GET /tracingstore controllers.TracingStoreController.listOne +POST /tracingstores/:name/handleTracingUpdateReport controllers.WKTracingStoreController.handleTracingUpdateReport(name: String) +POST /tracingstores/:name/reportIsosurfaceRequest controllers.WKTracingStoreController.reportIsosurfaceRequest(name: String, token: Option[String]) +POST /tracingstores/:name/validateUserAccess controllers.UserTokenController.validateAccessViaTracingstore(name: String, token: Option[String]) +PUT /tracingstores/:name controllers.TracingStoreController.update(name: String) +GET /tracingstores/:name/dataSource/:dataSetName controllers.WKTracingStoreController.dataSource(name: String, organizationName: Option[String], dataSetName: String) # User access tokens for datastore authentification -POST /userToken/generate controllers.UserTokenController.generateTokenForDataStore +POST /userToken/generate controllers.UserTokenController.generateTokenForDataStore # Annotations -POST /annotations/upload controllers.AnnotationIOController.upload -POST /annotations/:typ/:id/duplicate controllers.AnnotationController.duplicate(typ: String, id: String) -PATCH /annotations/:typ/:id/edit controllers.AnnotationController.editAnnotation(typ: String, id: String) - -PATCH /annotations/:typ/:id/finish controllers.AnnotationController.finish(typ: String, id: String, timestamp: Long) -PATCH /annotations/:typ/finish controllers.AnnotationController.finishAll(typ: String, timestamp: Long) -PATCH /annotations/:typ/:id/reopen controllers.AnnotationController.reopen(typ: String, id: String) -PUT /annotations/:typ/:id/reset controllers.AnnotationController.reset(typ: String, id: String) -PATCH /annotations/:typ/:id/transfer controllers.AnnotationController.transfer(typ: String, id: String) - -GET /annotations/:typ/:id/info controllers.AnnotationController.info(typ: String, id: String, timestamp: Long) -PATCH /annotations/:typ/:id/makeHybrid controllers.AnnotationController.makeHybrid(typ: String, id: String) -PATCH /annotations/:typ/:id/downsample controllers.AnnotationController.downsample(typ: String, id: String) -PATCH /annotations/:typ/:id/unlinkFallback controllers.AnnotationController.unlinkFallback(typ: String, id: String) -DELETE /annotations/:typ/:id controllers.AnnotationController.cancel(typ: String, id: String) -POST /annotations/:typ/:id/merge/:mergedTyp/:mergedId controllers.AnnotationController.merge(typ: String, id: String, mergedTyp: String, mergedId: String) -GET /annotations/:typ/:id/download controllers.AnnotationIOController.download(typ: String, id: String, skeletonVersion: Option[Long], volumeVersion: Option[Long], skipVolumeData: Option[Boolean]) - -GET /annotations/:typ/:id/loggedTime controllers.AnnotationController.loggedTime(typ: String, id: String) - -GET /annotations/shared controllers.AnnotationController.sharedAnnotations() -GET /annotations/:typ/:id/sharedTeams controllers.AnnotationController.getSharedTeams(typ: String, id: String) -PATCH /annotations/:typ/:id/sharedTeams controllers.AnnotationController.updateSharedTeams(typ: String, id: String) +POST /annotations/upload controllers.AnnotationIOController.upload +POST /annotations/:typ/:id/duplicate controllers.AnnotationController.duplicate(typ: String, id: String) +PATCH /annotations/:typ/:id/edit controllers.AnnotationController.editAnnotation(typ: String, id: String) + +PATCH /annotations/:typ/:id/finish controllers.AnnotationController.finish(typ: String, id: String, timestamp: Long) +PATCH /annotations/:typ/finish controllers.AnnotationController.finishAll(typ: String, timestamp: Long) +PATCH /annotations/:typ/:id/reopen controllers.AnnotationController.reopen(typ: String, id: String) +PUT /annotations/:typ/:id/reset controllers.AnnotationController.reset(typ: String, id: String) +PATCH /annotations/:typ/:id/transfer controllers.AnnotationController.transfer(typ: String, id: String) + +GET /annotations/:typ/:id/info controllers.AnnotationController.info(typ: String, id: String, timestamp: Long) +PATCH /annotations/:typ/:id/makeHybrid controllers.AnnotationController.makeHybrid(typ: String, id: String) +PATCH /annotations/:typ/:id/downsample controllers.AnnotationController.downsample(typ: String, id: String) +PATCH /annotations/:typ/:id/unlinkFallback controllers.AnnotationController.unlinkFallback(typ: String, id: String) +DELETE /annotations/:typ/:id controllers.AnnotationController.cancel(typ: String, id: String) +POST /annotations/:typ/:id/merge/:mergedTyp/:mergedId controllers.AnnotationController.merge(typ: String, id: String, mergedTyp: String, mergedId: String) +GET /annotations/:typ/:id/download controllers.AnnotationIOController.download(typ: String, id: String, skeletonVersion: Option[Long], volumeVersion: Option[Long], skipVolumeData: Option[Boolean]) + +GET /annotations/:typ/:id/loggedTime controllers.AnnotationController.loggedTime(typ: String, id: String) + +GET /annotations/shared controllers.AnnotationController.sharedAnnotations() +GET /annotations/:typ/:id/sharedTeams controllers.AnnotationController.getSharedTeams(typ: String, id: String) +PATCH /annotations/:typ/:id/sharedTeams controllers.AnnotationController.updateSharedTeams(typ: String, id: String) # Meshes -POST /meshes controllers.MeshController.create -PUT /meshes/:id controllers.MeshController.update(id: String) -DELETE /meshes/:id controllers.MeshController.delete(id: String) -GET /meshes/:id controllers.MeshController.get(id: String) -PUT /meshes/:id/data controllers.MeshController.updateData(id: String) -GET /meshes/:id/data controllers.MeshController.getData(id: String) +POST /meshes controllers.MeshController.create +PUT /meshes/:id controllers.MeshController.update(id: String) +DELETE /meshes/:id controllers.MeshController.delete(id: String) +GET /meshes/:id controllers.MeshController.get(id: String) +PUT /meshes/:id/data controllers.MeshController.updateData(id: String) +GET /meshes/:id/data controllers.MeshController.getData(id: String) # Tasks -POST /tasks controllers.TaskController.create -POST /tasks/createFromFiles controllers.TaskController.createFromFiles -POST /tasks/list controllers.TaskController.listTasks -GET /tasks/experienceDomains controllers.TaskController.listExperienceDomains -GET /tasks/:id controllers.TaskController.read(id: String) -DELETE /tasks/:id controllers.TaskController.delete(id: String) -PUT /tasks/:id controllers.TaskController.update(id: String) -GET /tasks/:id/annotations controllers.AnnotationController.annotationsForTask(id: String) +POST /tasks controllers.TaskController.create +POST /tasks/createFromFiles controllers.TaskController.createFromFiles +POST /tasks/list controllers.TaskController.listTasks +GET /tasks/experienceDomains controllers.TaskController.listExperienceDomains +GET /tasks/:id controllers.TaskController.read(id: String) +DELETE /tasks/:id controllers.TaskController.delete(id: String) +PUT /tasks/:id controllers.TaskController.update(id: String) +GET /tasks/:id/annotations controllers.AnnotationController.annotationsForTask(id: String) # TaskTypes -GET /taskTypes controllers.TaskTypeController.list -POST /taskTypes controllers.TaskTypeController.create -DELETE /taskTypes/:id controllers.TaskTypeController.delete(id: String) -GET /taskTypes/:id/tasks controllers.TaskController.listTasksForType(id: String) -GET /taskTypes/:id/projects controllers.ProjectController.projectsForTaskType(id: String) -GET /taskTypes/:id controllers.TaskTypeController.get(id: String) -PUT /taskTypes/:id controllers.TaskTypeController.update(id: String) +GET /taskTypes controllers.TaskTypeController.list +POST /taskTypes controllers.TaskTypeController.create +DELETE /taskTypes/:id controllers.TaskTypeController.delete(id: String) +GET /taskTypes/:id/tasks controllers.TaskController.listTasksForType(id: String) +GET /taskTypes/:id/projects controllers.ProjectController.projectsForTaskType(id: String) +GET /taskTypes/:id controllers.TaskTypeController.get(id: String) +PUT /taskTypes/:id controllers.TaskTypeController.update(id: String) # Scripts -GET /scripts controllers.ScriptController.list -POST /scripts controllers.ScriptController.create -GET /scripts/:id controllers.ScriptController.get(id: String) -PUT /scripts/:id controllers.ScriptController.update(id: String) -DELETE /scripts/:id controllers.ScriptController.delete(id: String) +GET /scripts controllers.ScriptController.list +POST /scripts controllers.ScriptController.create +GET /scripts/:id controllers.ScriptController.get(id: String) +PUT /scripts/:id controllers.ScriptController.update(id: String) +DELETE /scripts/:id controllers.ScriptController.delete(id: String) # Projects -GET /projects controllers.ProjectController.list -GET /projects/assignments controllers.ProjectController.listWithStatus -POST /projects controllers.ProjectController.create -GET /projects/:name controllers.ProjectController.read(name: String) -DELETE /projects/:name controllers.ProjectController.delete(name: String) -PUT /projects/:name controllers.ProjectController.update(name: String) -GET /projects/:name/tasks controllers.ProjectController.tasksForProject(name: String, limit: Option[Int], pageNumber: Option[Int], includeTotalCount: Option[Boolean]) -PATCH /projects/:name/incrementEachTasksInstances controllers.ProjectController.incrementEachTasksInstances(name: String, delta: Option[Long]) -PATCH /projects/:name/pause controllers.ProjectController.pause(name: String) -PATCH /projects/:name/resume controllers.ProjectController.resume(name: String) -GET /projects/:name/usersWithActiveTasks controllers.ProjectController.usersWithActiveTasks(name:String) -POST /projects/:name/transferActiveTasks controllers.ProjectController.transferActiveTasks(name:String) +GET /projects controllers.ProjectController.list +GET /projects/assignments controllers.ProjectController.listWithStatus +POST /projects controllers.ProjectController.create +GET /projects/:name controllers.ProjectController.read(name: String) +DELETE /projects/:name controllers.ProjectController.delete(name: String) +PUT /projects/:name controllers.ProjectController.update(name: String) +GET /projects/:name/tasks controllers.ProjectController.tasksForProject(name: String, limit: Option[Int], pageNumber: Option[Int], includeTotalCount: Option[Boolean]) +PATCH /projects/:name/incrementEachTasksInstances controllers.ProjectController.incrementEachTasksInstances(name: String, delta: Option[Long]) +PATCH /projects/:name/pause controllers.ProjectController.pause(name: String) +PATCH /projects/:name/resume controllers.ProjectController.resume(name: String) +GET /projects/:name/usersWithActiveTasks controllers.ProjectController.usersWithActiveTasks(name:String) +POST /projects/:name/transferActiveTasks controllers.ProjectController.transferActiveTasks(name:String) # Statistics -GET /statistics/webknossos controllers.StatisticsController.webKnossos(interval: String, start: Option[Long], end: Option[Long]) -GET /statistics/users controllers.StatisticsController.users(interval: String, start: Option[Long], end: Option[Long], limit: Int) +GET /statistics/webknossos controllers.StatisticsController.webKnossos(interval: String, start: Option[Long], end: Option[Long]) +GET /statistics/users controllers.StatisticsController.users(interval: String, start: Option[Long], end: Option[Long], limit: Int) #Organizations -GET /organizations controllers.OrganizationController.list -GET /organizations/byInvite/:inviteToken controllers.OrganizationController.getByInvite(inviteToken: String) -GET /organizations/default controllers.OrganizationController.getDefault -GET /organizationsIsEmpty controllers.OrganizationController.organizationsIsEmpty -GET /organizations/:organizationName controllers.OrganizationController.get(organizationName: String) -GET /operatorData controllers.OrganizationController.getOperatorData +GET /organizations controllers.OrganizationController.list +GET /organizations/byInvite/:inviteToken controllers.OrganizationController.getByInvite(inviteToken: String) +GET /organizations/default controllers.OrganizationController.getDefault +GET /organizationsIsEmpty controllers.OrganizationController.organizationsIsEmpty +GET /organizations/:organizationName controllers.OrganizationController.get(organizationName: String) +GET /operatorData controllers.OrganizationController.getOperatorData #Timelogging -GET /time/allusers/:year/:month controllers.TimeController.getWorkingHoursOfAllUsers(year: Int, month: Int, startDay: Option[Int], endDay: Option[Int]) -GET /time/userlist/:year/:month controllers.TimeController.getWorkingHoursOfUsers(email: String, year: Int, month: Int, startDay: Option[Int], endDay: Option[Int]) -GET /time/user/:userId controllers.TimeController.getWorkingHoursOfUser(userId: String, startDate: Long, endDate: Long) +GET /time/allusers/:year/:month controllers.TimeController.getWorkingHoursOfAllUsers(year: Int, month: Int, startDay: Option[Int], endDay: Option[Int]) +GET /time/userlist/:year/:month controllers.TimeController.getWorkingHoursOfUsers(email: String, year: Int, month: Int, startDay: Option[Int], endDay: Option[Int]) +GET /time/user/:userId controllers.TimeController.getWorkingHoursOfUser(userId: String, startDate: Long, endDate: Long) # Long-Running Jobs -GET /jobs controllers.JobsController.list -GET /jobs/:id controllers.JobsController.get(id: String) -GET /jobs/run/cubing/:organizationName/:dataSetName controllers.JobsController.runCubingJob(organizationName: String, dataSetName: String, scale: String) +GET /jobs controllers.JobsController.list +GET /jobs/:id controllers.JobsController.get(id: String) +GET /jobs/:id/downloadExport/:exportFileName controllers.JobsController.downloadExport(id: String, exportFileName: String) +GET /jobs/run/cubing/:organizationName/:dataSetName controllers.JobsController.runCubingJob(organizationName: String, dataSetName: String, scale: String) +GET /jobs/run/tiffExport/:organizationName/:dataSetName/:layerName controllers.JobsController.runTiffExportJob(organizationName: String, dataSetName: String, layerName: String, bbox: String) diff --git a/frontend/javascripts/admin/admin_rest_api.js b/frontend/javascripts/admin/admin_rest_api.js index 71f12e75c8..65c3544adf 100644 --- a/frontend/javascripts/admin/admin_rest_api.js +++ b/frontend/javascripts/admin/admin_rest_api.js @@ -53,7 +53,7 @@ import type { DatasetConfiguration, Tracing } from "oxalis/store"; import type { NewTask, TaskCreationResponseContainer } from "admin/task/task_create_bulk_view"; import type { QueryObject } from "admin/task/task_search_form"; import { V3 } from "libs/mjs"; -import type { Vector3 } from "oxalis/constants"; +import type { Vector3, Vector6 } from "oxalis/constants"; import type { Versions } from "oxalis/view/version_view"; import { parseProtoTracing } from "oxalis/model/helpers/proto_helpers"; import DataLayer from "oxalis/model/data_layer"; @@ -794,18 +794,34 @@ export async function getJobs(): Promise> { id: job.id, type: job.command, datasetName: job.commandArgs.kwargs.dataset_name, - state: job.celeryInfo.state, + organizationName: job.commandArgs.kwargs.organization_name, + layerName: job.commandArgs.kwargs.layer_name, + exportFileName: job.commandArgs.kwargs.export_file_name, + state: job.celeryInfo.state || "UNKNOWN", createdAt: job.created, })); } -export async function startJob( - jobName: string, - organization: string, +export async function startCubingJob( + datasetName: string, + organizationName: string, scale: Vector3, ): Promise> { return Request.receiveJSON( - `/api/jobs/run/cubing/${organization}/${jobName}?scale=${scale.toString()}`, + `/api/jobs/run/cubing/${organizationName}/${datasetName}?scale=${scale.toString()}`, + ); +} + +export async function startTiffExportJob( + datasetName: string, + organizationName: string, + layerName: string, + bbox: Vector6, +): Promise> { + return Request.receiveJSON( + `/api/jobs/run/tiffExport/${organizationName}/${datasetName}/${layerName}?bbox=${bbox.join( + ",", + )}`, ); } diff --git a/frontend/javascripts/admin/dataset/dataset_upload_view.js b/frontend/javascripts/admin/dataset/dataset_upload_view.js index 793f80f993..d36448b7b9 100644 --- a/frontend/javascripts/admin/dataset/dataset_upload_view.js +++ b/frontend/javascripts/admin/dataset/dataset_upload_view.js @@ -7,7 +7,7 @@ import _ from "lodash"; import { type RouterHistory, withRouter } from "react-router-dom"; import type { APITeam, APIDataStore, APIUser, APIDatasetId } from "types/api_flow_types"; import type { OxalisState } from "oxalis/store"; -import { finishDatasetUpload, createResumableUpload, startJob } from "admin/admin_rest_api"; +import { finishDatasetUpload, createResumableUpload, startCubingJob } from "admin/admin_rest_api"; import Toast from "libs/toast"; import * as Utils from "libs/utils"; import messages from "messages"; @@ -141,7 +141,7 @@ class DatasetUploadView extends React.PureComponent The conversion for the uploaded dataset was started. diff --git a/frontend/javascripts/admin/job/job_list_view.js b/frontend/javascripts/admin/job/job_list_view.js index 02efeb5dd9..42ae8e1320 100644 --- a/frontend/javascripts/admin/job/job_list_view.js +++ b/frontend/javascripts/admin/job/job_list_view.js @@ -3,7 +3,7 @@ import _ from "lodash"; import { PropTypes } from "@scalableminds/prop-types"; import { Link, type RouterHistory, withRouter } from "react-router-dom"; -import { Table, Spin, Input, Icon } from "antd"; +import { Table, Spin, Input, Icon, Tooltip } from "antd"; import { connect } from "react-redux"; import * as React from "react"; @@ -12,6 +12,7 @@ import { getJobs } from "admin/admin_rest_api"; import Persistence from "libs/persistence"; import * as Utils from "libs/utils"; import type { OxalisState } from "oxalis/store"; +import FormattedDate from "components/formatted_date"; const refreshInterval = 5000; @@ -81,6 +82,64 @@ class JobListView extends React.PureComponent { this.setState({ searchQuery: event.target.value }); }; + renderDescription = (__: any, job: APIJob) => { + if (job.type === "tiff_cubing" && job.datasetName) { + return {`Tiff to WKW conversion of ${job.datasetName}`}; + } else if (job.type === "export_tiff" && job.organizationName && job.datasetName) { + return ( + + Tiff export from {job.layerName || "a"} layer of{" "} + + {job.datasetName} + + + ); + } else { + return {job.type}; + } + }; + + renderActions = (__: any, job: APIJob) => { + if (job.type === "tiff_cubing") { + return ( + + {job.state === "SUCCESS" && job.datasetName && this.props.activeUser && ( + + + View + + )} + + ); + } else if (job.type === "export_tiff") { + return ( + + {job.state === "SUCCESS" && job.exportFileName && this.props.activeUser && ( + + + Download + + )} + + ); + } else return null; + }; + + renderState = (__: any, job: APIJob) => { + const stateString = _.capitalize(job.state.toLowerCase()); + if (job.state === "SUCCESS") return stateString; + else { + return ( + + {stateString} + + ); + } + }; + render() { return (
@@ -115,21 +174,17 @@ class JobListView extends React.PureComponent { key="id" sorter={Utils.localeCompareBy(typeHint, job => job.id)} /> - `${job.type} of ${job.datasetName}`} - /> + new Date(job.createdAt).toUTCString()} + render={job => } sorter={Utils.compareBy(typeHint, job => job.createdAt)} /> _.capitalize(job.state.toLowerCase())} + render={this.renderState} sorter={Utils.localeCompareBy(typeHint, job => job.state)} /> { key="actions" fixed="right" width={150} - render={(__, job: APIJob) => ( - - {job.state === "SUCCESS" && job.datasetName && this.props.activeUser && ( - - - View - - )} - - )} + render={this.renderActions} /> diff --git a/frontend/javascripts/oxalis/view/settings/export_bounding_box_modal.js b/frontend/javascripts/oxalis/view/settings/export_bounding_box_modal.js new file mode 100644 index 0000000000..39a71ec95f --- /dev/null +++ b/frontend/javascripts/oxalis/view/settings/export_bounding_box_modal.js @@ -0,0 +1,123 @@ +// @flow +import { Button, Modal, Alert } from "antd"; +import React, { useState } from "react"; +import type { BoundingBoxType } from "oxalis/constants"; +import type { APIDataset } from "types/api_flow_types"; +import { startTiffExportJob } from "admin/admin_rest_api"; +import features from "features"; +import * as Utils from "libs/utils"; + +type Props = { + destroy: () => void, + dataset: APIDataset, + boundingBox: BoundingBoxType, +}; + +const ExportBoundingBoxModal = ({ destroy, dataset, boundingBox }: Props) => { + const [startedExports, setStartedExports] = useState([]); + + const handleClose = () => { + destroy(); + }; + + const handleStartExport = layerName => { + startTiffExportJob( + dataset.name, + dataset.owningOrganization, + layerName, + Utils.computeArrayFromBoundingBox(boundingBox), + ); + setStartedExports(startedExports.concat(layerName)); + }; + + const layerNames = dataset.dataSource.dataLayers.map(layer => { + const nameIfColor = layer.category === "color" ? layer.name : null; + const nameIfVolume = + layer.category === "segmentation" && layer.fallbackLayerInfo && layer.fallbackLayerInfo.name + ? layer.fallbackLayerInfo.name + : null; + return nameIfColor || nameIfVolume; + }); + + const exportButtonsList = layerNames.map(layerName => + layerName ? ( +

+ +

+ ) : null, + ); + + const dimensions = boundingBox.max.map((maxItem, index) => maxItem - boundingBox.min[index]); + const volume = dimensions[0] * dimensions[1] * dimensions[2]; + const volumeExceeded = volume > features().exportTiffMaxVolumeMVx * 1024 * 1024; + const edgeLengthExceeded = dimensions.some( + length => length > features().exportTiffMaxEdgeLengthVx, + ); + const volumeExceededMessage = volumeExceeded ? ( + + ) : null; + const edgeLengthExceededMessage = edgeLengthExceeded ? ( + + ) : null; + + const downloadHint = + startedExports.length > 0 ? ( +

+ Go to{" "} + + Jobs Overview Page + {" "} + to see running exports and to download the results. +

+ ) : null; + + const bboxText = Utils.computeArrayFromBoundingBox(boundingBox).join(", "); + + return ( + +

+ Data from the selected bounding box at {bboxText} will be exported as a tiff stack zip + archive. +

+ + {volumeExceededMessage} + {edgeLengthExceededMessage} + + {volumeExceeded || edgeLengthExceeded ? null : ( +
+ {" "} +

Please select a layer to export:

{exportButtonsList} +
+ )} + + {downloadHint} +
+ ); +}; + +export default ExportBoundingBoxModal; diff --git a/frontend/javascripts/oxalis/view/settings/setting_input_views.js b/frontend/javascripts/oxalis/view/settings/setting_input_views.js index 7bd6056812..cee11f1d1f 100644 --- a/frontend/javascripts/oxalis/view/settings/setting_input_views.js +++ b/frontend/javascripts/oxalis/view/settings/setting_input_views.js @@ -6,6 +6,8 @@ import _ from "lodash"; import type { Vector3, Vector6 } from "oxalis/constants"; import * as Utils from "libs/utils"; +import features from "features"; + type NumberSliderSettingProps = { onChange: (value: number) => void, value: number, @@ -254,6 +256,7 @@ type UserBoundingBoxInputProps = { tooltipTitle: string, onChange: UserBoundingBoxInputUpdate => void, onDelete: () => void, + onExport: () => void, }; type State = { @@ -338,13 +341,21 @@ export class UserBoundingBoxInput extends React.PureComponent colorPart * 255): any): Vector3); const iconStyle = { margin: "auto 0px auto 6px" }; + const exportColumn = features().jobsEnabled ? ( + + + + + + ) : null; + const visibilityColSpan = exportColumn == null ? 22 : 20; return ( - + + {exportColumn} diff --git a/frontend/javascripts/oxalis/view/settings/user_settings_view.js b/frontend/javascripts/oxalis/view/settings/user_settings_view.js index c2678056be..b813b2baca 100644 --- a/frontend/javascripts/oxalis/view/settings/user_settings_view.js +++ b/frontend/javascripts/oxalis/view/settings/user_settings_view.js @@ -47,6 +47,9 @@ import Constants, { type ControlMode, ControlModeEnum, type ViewMode } from "oxa import Toast from "libs/toast"; import * as Utils from "libs/utils"; +import renderIndependently from "libs/render_independently"; +import ExportBoundingBoxModal from "oxalis/view/settings/export_bounding_box_modal"; + const { Panel } = Collapse; type UserSettingsViewProps = { @@ -136,6 +139,20 @@ class UserSettingsView extends PureComponent { this.props.onChangeBoundingBoxes(updatedUserBoundingBoxes); }; + handleExportUserBoundingBox = (id: number) => { + const { userBoundingBoxes } = getSomeTracing(this.props.tracing); + const selectedBoundingBox = userBoundingBoxes.find(boundingBox => boundingBox.id === id); + if (selectedBoundingBox) { + renderIndependently(destroy => ( + + )); + } + }; + getViewportOptions = () => { switch (this.props.viewMode) { case Constants.MODE_PLANE_TRACING: @@ -437,6 +454,7 @@ class UserSettingsView extends PureComponent { isVisible={bb.isVisible} onChange={_.partial(this.handleChangeUserBoundingBox, bb.id)} onDelete={_.partial(this.handleDeleteUserBoundingBox, bb.id)} + onExport={_.partial(this.handleExportUserBoundingBox, bb.id)} /> ))}
diff --git a/frontend/javascripts/test/snapshots/public/test-bundle/test/backend-snapshot-tests/misc.e2e.js.md b/frontend/javascripts/test/snapshots/public/test-bundle/test/backend-snapshot-tests/misc.e2e.js.md index 25cf10e15b..aa67033b92 100644 --- a/frontend/javascripts/test/snapshots/public/test-bundle/test/backend-snapshot-tests/misc.e2e.js.md +++ b/frontend/javascripts/test/snapshots/public/test-bundle/test/backend-snapshot-tests/misc.e2e.js.md @@ -72,6 +72,8 @@ Generated by [AVA](https://avajs.dev). autoBrushReadyDatasets: [], discussionBoard: 'https://support.webknossos.org', discussionBoardRequiresAdmin: false, + exportTiffMaxEdgeLengthVx: 8192, + exportTiffMaxVolumeMVx: 1024, hideNavbarLogin: false, isDemoInstance: false, jobsEnabled: false, diff --git a/frontend/javascripts/test/snapshots/public/test-bundle/test/backend-snapshot-tests/misc.e2e.js.snap b/frontend/javascripts/test/snapshots/public/test-bundle/test/backend-snapshot-tests/misc.e2e.js.snap index 92b0951426..34376707b6 100644 Binary files a/frontend/javascripts/test/snapshots/public/test-bundle/test/backend-snapshot-tests/misc.e2e.js.snap and b/frontend/javascripts/test/snapshots/public/test-bundle/test/backend-snapshot-tests/misc.e2e.js.snap differ diff --git a/frontend/javascripts/types/api_flow_types.js b/frontend/javascripts/types/api_flow_types.js index f520cfb331..383c7c900c 100644 --- a/frontend/javascripts/types/api_flow_types.js +++ b/frontend/javascripts/types/api_flow_types.js @@ -541,11 +541,16 @@ export type APIFeatureToggles = { +allowDeleteDatasets: boolean, +jobsEnabled: boolean, +publicDemoDatasetUrl: string, + +exportTiffMaxVolumeMVx: number, + +exportTiffMaxEdgeLengthVx: number, }; export type APIJob = { +id: string, +datasetName: ?string, + +exportFileName: ?string, + +layerName: ?string, + +organizationName: ?string, +type: string, +state: string, +createdAt: number, diff --git a/util/src/main/scala/com/scalableminds/util/geometry/BoundingBox.scala b/util/src/main/scala/com/scalableminds/util/geometry/BoundingBox.scala index d4ec992dee..fb3092b874 100644 --- a/util/src/main/scala/com/scalableminds/util/geometry/BoundingBox.scala +++ b/util/src/main/scala/com/scalableminds/util/geometry/BoundingBox.scala @@ -34,6 +34,13 @@ case class BoundingBox(topLeft: Point3D, width: Int, height: Int, depth: Int) { def toSql = List(topLeft.x, topLeft.y, topLeft.z, width, height, depth) + + def volume: Long = + width * height * depth + + def dimensions: Point3D = + Point3D(width, height, depth) + } object BoundingBox { diff --git a/util/src/main/scala/com/scalableminds/util/mvc/Formatter.scala b/util/src/main/scala/com/scalableminds/util/mvc/Formatter.scala index fea4f6c65f..549e902698 100644 --- a/util/src/main/scala/com/scalableminds/util/mvc/Formatter.scala +++ b/util/src/main/scala/com/scalableminds/util/mvc/Formatter.scala @@ -16,6 +16,11 @@ trait Formatter { sdf.format(date) } + def formatDateForFilename(date: Date): String = { + val sdf = new SimpleDateFormat("YYYY-MM-DD_HH-mm") + sdf.format(date) + } + def formatHash(id: String): String = id.takeRight(6)