diff --git a/CHANGELOG.md b/CHANGELOG.md index b4b86ea1ef7..2e994305826 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,8 @@ For upgrade instructions, please check the [migration guide](MIGRATIONS.md). - Added URLs to the tabs in the dashboard. [#3183](https://github.com/scalableminds/webknossos/pull/3183) - Improved security by enabling http security headers. [#3084](https://github.com/scalableminds/webknossos/pull/3084) - Added the possibility to write markdown in the annotation description. [#3081](https://github.com/scalableminds/webknossos/pull/3081) +- Added a view to restore any older version of a skeleton tracing. Access it through the dropdown next to the Save button. [#3194](https://github.com/scalableminds/webknossos/pull/3194) +![version-restore-highlight](https://user-images.githubusercontent.com/1702075/45428378-6842d380-b6a1-11e8-88c2-e4ffcd762cd5.png) - Added customizable layouting to the tracing view. [#3070](https://github.com/scalableminds/webknossos/pull/3070) - Added the brush size to the settings on the left in volume tracing. The size can now also be adjusted by using only the keyboard. [#3126](https://github.com/scalableminds/webknossos/pull/3126) - Added a user documentation for webKnossos [#3011](https://github.com/scalableminds/webknossos/pull/3011) diff --git a/app/assets/javascripts/admin/admin_rest_api.js b/app/assets/javascripts/admin/admin_rest_api.js index 4bd95630a6e..cef85f8eb5f 100644 --- a/app/assets/javascripts/admin/admin_rest_api.js +++ b/app/assets/javascripts/admin/admin_rest_api.js @@ -42,6 +42,7 @@ import type { ServerSkeletonTracingType, ServerVolumeTracingType, APIAnnotationTypeCompact, + APIUpdateActionBatch, ExperienceDomainListType, } from "admin/api_flow_types"; import { APITracingTypeEnum } from "admin/api_flow_types"; @@ -530,10 +531,11 @@ export function createExplorational( export async function getTracingForAnnotations( annotation: APIAnnotationType, + version?: number, ): Promise { const [_skeleton, _volume] = await Promise.all([ - getTracingForAnnotationType(annotation, "skeleton"), - getTracingForAnnotationType(annotation, "volume"), + getTracingForAnnotationType(annotation, "skeleton", version), + getTracingForAnnotationType(annotation, "volume", version), ]); const skeleton = ((_skeleton: any): ?ServerSkeletonTracingType); @@ -548,14 +550,18 @@ export async function getTracingForAnnotations( export async function getTracingForAnnotationType( annotation: APIAnnotationType, tracingType: "skeleton" | "volume", + version?: number, ): Promise { const tracingId = annotation.tracing[tracingType]; if (!tracingId) { return null; } + const possibleVersionString = version != null ? `&version=${version}` : ""; const tracingArrayBuffer = await doWithToken(token => Request.receiveArraybuffer( - `${annotation.dataStore.url}/data/tracings/${tracingType}/${tracingId}?token=${token}`, + `${ + annotation.dataStore.url + }/data/tracings/${tracingType}/${tracingId}?token=${token}${possibleVersionString}`, { headers: { Accept: "application/x-protobuf" } }, ), ); @@ -566,6 +572,18 @@ export async function getTracingForAnnotationType( return tracing; } +export function getUpdateActionLog( + dataStoreUrl: string, + tracingId: string, + tracingType: "skeleton" | "volume", +): Promise> { + return doWithToken(token => + Request.receiveJSON( + `${dataStoreUrl}/data/tracings/${tracingType}/${tracingId}/updateActionLog?token=${token}`, + ), + ); +} + // ### Datasets export async function getDatasets(): Promise> { const datasets = await Request.receiveJSON("/api/datasets"); diff --git a/app/assets/javascripts/admin/api_flow_types.js b/app/assets/javascripts/admin/api_flow_types.js index 3084c4a344a..c843731fe06 100644 --- a/app/assets/javascripts/admin/api_flow_types.js +++ b/app/assets/javascripts/admin/api_flow_types.js @@ -5,6 +5,7 @@ import type { SkeletonTracingStatsType } from "oxalis/model/accessors/skeletontracing_accessor"; import type { Vector3, Vector6, Point3 } from "oxalis/constants"; import type { BoundingBoxObjectType, EdgeType, CommentType, TreeGroupType } from "oxalis/store"; +import type { ServerUpdateAction } from "oxalis/model/sagas/update_actions"; import Enum from "Enumjs"; export type APIMessageType = { ["info" | "warning" | "error"]: string }; @@ -363,6 +364,11 @@ export type APIFeatureToggles = { }; // Tracing related datatypes +export type APIUpdateActionBatch = { + version: number, + value: Array, +}; + export type ServerNodeType = { id: number, position: Point3, diff --git a/app/assets/javascripts/messages.js b/app/assets/javascripts/messages.js index fc27408ea02..25c0727f31f 100644 --- a/app/assets/javascripts/messages.js +++ b/app/assets/javascripts/messages.js @@ -21,7 +21,8 @@ In order to restore the current window, a reload is necessary.`, "save.leave_page_unfinished": "WARNING: You have unsaved progress that may be lost when hitting OK. Please click cancel, wait until the progress is saved and the save button displays a checkmark before leaving the page..", "save.failed": "Failed to save tracing. Retrying.", - "undo.no_undo": "There is no action that could be undone.", + "undo.no_undo": + "There is no action that could be undone. However, if you want to restore an earlier version of this tracing, use the 'Restore Older Version' functionality in the dropdown next to the 'Save' button.", "undo.no_redo": "There is no action that could be redone.", "download.wait": "Please wait...", "download.close_window": "You may close this window after the download has started.", diff --git a/app/assets/javascripts/oxalis/api/api_latest.js b/app/assets/javascripts/oxalis/api/api_latest.js index 2c684d77bdc..144bdc2b677 100644 --- a/app/assets/javascripts/oxalis/api/api_latest.js +++ b/app/assets/javascripts/oxalis/api/api_latest.js @@ -412,10 +412,11 @@ class TracingApi { newTracingType: TracingTypeTracingType, newAnnotationId: string, newControlMode: ControlModeType, + version?: number, ) { Store.dispatch(restartSagaAction()); UrlManager.reset(); - await Model.fetch(newTracingType, newAnnotationId, newControlMode, false); + await Model.fetch(newTracingType, newAnnotationId, newControlMode, false, version); Store.dispatch(discardSaveQueuesAction()); Store.dispatch(wkReadyAction()); UrlManager.updateUnthrottled(); diff --git a/app/assets/javascripts/oxalis/controller.js b/app/assets/javascripts/oxalis/controller.js index 6aa6f936ee6..86517d8e8f9 100644 --- a/app/assets/javascripts/oxalis/controller.js +++ b/app/assets/javascripts/oxalis/controller.js @@ -83,11 +83,15 @@ class Controller extends React.PureComponent { Toast.error(messages["webgl.disabled"]); } + // Preview a working tracing version if the showVersionRestore URL parameter is supplied + const version = Utils.hasUrlParam("showVersionRestore") ? 1 : undefined; + Model.fetch( this.props.initialTracingType, this.props.initialAnnotationId, this.props.initialControlmode, true, + version, ) .then(() => this.modelFetchDone()) .catch(error => { diff --git a/app/assets/javascripts/oxalis/controller/url_manager.js b/app/assets/javascripts/oxalis/controller/url_manager.js index 30bbed732d6..142f32e369d 100644 --- a/app/assets/javascripts/oxalis/controller/url_manager.js +++ b/app/assets/javascripts/oxalis/controller/url_manager.js @@ -133,7 +133,7 @@ export function updateTypeAndId( // will only ever be updated for the annotations route as the other route is for // dataset viewing only return baseUrl.replace( - /^(.*\/annotations)\/(.*?)\/([^/]*)(\/?.*)$/, + /^(.*\/annotations)\/(.*?)\/([^/?]*)(\/?.*)$/, (all, base, type, id, rest) => `${base}/${tracingType}/${annotationId}${rest}`, ); } diff --git a/app/assets/javascripts/oxalis/model.js b/app/assets/javascripts/oxalis/model.js index 904590a71fe..bc170364323 100644 --- a/app/assets/javascripts/oxalis/model.js +++ b/app/assets/javascripts/oxalis/model.js @@ -31,12 +31,14 @@ export class OxalisModel { annotationIdOrDatasetName: string, controlMode: ControlModeType, initialFetch: boolean, + version?: number, ) { const initializationInformation = await initialize( tracingType, annotationIdOrDatasetName, controlMode, initialFetch, + version, ); if (initializationInformation) { // Only executed on initial fetch diff --git a/app/assets/javascripts/oxalis/model/actions/annotation_actions.js b/app/assets/javascripts/oxalis/model/actions/annotation_actions.js index 2ac175dfea6..436d33d794f 100644 --- a/app/assets/javascripts/oxalis/model/actions/annotation_actions.js +++ b/app/assets/javascripts/oxalis/model/actions/annotation_actions.js @@ -22,6 +22,11 @@ type SetAnnotationDescriptionActionType = { description: string, }; +type SetAnnotationAllowUpdateActionType = { + type: "SET_ANNOTATION_ALLOW_UPDATE", + allowUpdate: boolean, +}; + type SetUserBoundingBoxType = { type: "SET_USER_BOUNDING_BOX", userBoundingBox: ?BoundingBoxType, @@ -32,6 +37,7 @@ export type AnnotationActionTypes = | SetAnnotationNameActionType | SetAnnotationPubliceActionType | SetAnnotationDescriptionActionType + | SetAnnotationAllowUpdateActionType | SetUserBoundingBoxType; export const initializeAnnotationAction = ( @@ -58,6 +64,13 @@ export const setAnnotationDescriptionAction = ( description, }); +export const setAnnotationAllowUpdateAction = ( + allowUpdate: boolean, +): SetAnnotationAllowUpdateActionType => ({ + type: "SET_ANNOTATION_ALLOW_UPDATE", + allowUpdate, +}); + // Strictly speaking this is no annotation action but a tracing action, as the boundingBox is saved with // the tracing, hence no ANNOTATION in the action type. export const setUserBoundingBoxAction = ( diff --git a/app/assets/javascripts/oxalis/model/actions/ui_actions.js b/app/assets/javascripts/oxalis/model/actions/ui_actions.js index b1820bce0f4..d67f6ccc2ca 100644 --- a/app/assets/javascripts/oxalis/model/actions/ui_actions.js +++ b/app/assets/javascripts/oxalis/model/actions/ui_actions.js @@ -2,15 +2,29 @@ /* eslint-disable import/prefer-default-export */ type SetDropzoneModalVisibilityActionType = { - type: "SET_DROPZONE_MODAL_VISIBILITY_ACTION_TYPE", + type: "SET_DROPZONE_MODAL_VISIBILITY", visible: boolean, }; -export type UiActionType = SetDropzoneModalVisibilityActionType; +type SetVersionRestoreVisibilityActionType = { + type: "SET_VERSION_RESTORE_VISIBILITY", + active: boolean, +}; + +export type UiActionType = + | SetDropzoneModalVisibilityActionType + | SetVersionRestoreVisibilityActionType; export const setDropzoneModalVisibilityAction = ( visible: boolean, ): SetDropzoneModalVisibilityActionType => ({ - type: "SET_DROPZONE_MODAL_VISIBILITY_ACTION_TYPE", + type: "SET_DROPZONE_MODAL_VISIBILITY", visible, }); + +export const setVersionRestoreVisibilityAction = ( + active: boolean, +): SetVersionRestoreVisibilityActionType => ({ + type: "SET_VERSION_RESTORE_VISIBILITY", + active, +}); diff --git a/app/assets/javascripts/oxalis/model/reducers/annotation_reducer.js b/app/assets/javascripts/oxalis/model/reducers/annotation_reducer.js index 132d375667d..973c4ffd2e8 100644 --- a/app/assets/javascripts/oxalis/model/reducers/annotation_reducer.js +++ b/app/assets/javascripts/oxalis/model/reducers/annotation_reducer.js @@ -1,7 +1,7 @@ // @flow import update from "immutability-helper"; -import { updateKey, type StateShape1 } from "oxalis/model/helpers/deep_update"; +import { updateKey, updateKey2, type StateShape1 } from "oxalis/model/helpers/deep_update"; import type { OxalisState } from "oxalis/store"; import type { ActionType } from "oxalis/model/actions/actions"; import { convertServerAnnotationToFrontendAnnotation } from "oxalis/model/reducers/reducer_helpers"; @@ -36,6 +36,11 @@ function AnnotationReducer(state: OxalisState, action: ActionType): OxalisState }); } + case "SET_ANNOTATION_ALLOW_UPDATE": { + const { allowUpdate } = action; + return updateKey2(state, "tracing", "restrictions", { allowUpdate }); + } + case "SET_USER_BOUNDING_BOX": { const updaterObject = { userBoundingBox: { diff --git a/app/assets/javascripts/oxalis/model/reducers/ui_reducer.js b/app/assets/javascripts/oxalis/model/reducers/ui_reducer.js index 1a4043adaad..4008498dda1 100644 --- a/app/assets/javascripts/oxalis/model/reducers/ui_reducer.js +++ b/app/assets/javascripts/oxalis/model/reducers/ui_reducer.js @@ -6,7 +6,7 @@ import type { ActionType } from "oxalis/model/actions/actions"; function UiReducer(state: OxalisState, action: ActionType): OxalisState { switch (action.type) { - case "SET_DROPZONE_MODAL_VISIBILITY_ACTION_TYPE": { + case "SET_DROPZONE_MODAL_VISIBILITY": { return update(state, { uiInformation: { showDropzoneModal: { $set: action.visible }, @@ -14,6 +14,14 @@ function UiReducer(state: OxalisState, action: ActionType): OxalisState { }); } + case "SET_VERSION_RESTORE_VISIBILITY": { + return update(state, { + uiInformation: { + showVersionRestore: { $set: action.active }, + }, + }); + } + default: return state; } diff --git a/app/assets/javascripts/oxalis/model/sagas/skeletontracing_saga.js b/app/assets/javascripts/oxalis/model/sagas/skeletontracing_saga.js index eac92620b1b..b690de2369e 100644 --- a/app/assets/javascripts/oxalis/model/sagas/skeletontracing_saga.js +++ b/app/assets/javascripts/oxalis/model/sagas/skeletontracing_saga.js @@ -16,6 +16,7 @@ import { _takeEvery, select, race, + call, } from "oxalis/model/sagas/effect-generators"; import type { Saga } from "oxalis/model/sagas/effect-generators"; import { @@ -58,6 +59,7 @@ import type { UpdateAction } from "oxalis/model/sagas/update_actions"; import api from "oxalis/api/internal_api"; import DiffableMap, { diffDiffableMaps } from "libs/diffable_map"; import EdgeCollection, { diffEdgeCollections } from "oxalis/model/edge_collection"; +import { setVersionRestoreVisibilityAction } from "oxalis/model/actions/ui_actions"; function* centerActiveNode(action: ActionType): Saga { getActiveNode(yield* select((state: OxalisState) => enforceSkeletonTracing(state.tracing))).map( @@ -131,6 +133,13 @@ export function* watchTreeNames(): Saga { } } +export function* watchVersionRestoreParam(): Saga { + const showVersionRestore = yield* call(Utils.hasUrlParam, "showVersionRestore"); + if (showVersionRestore) { + yield* put(setVersionRestoreVisibilityAction(true)); + } +} + export function* watchSkeletonTracingAsync(): Saga { yield* take("INITIALIZE_SKELETONTRACING"); yield _takeEvery("WK_READY", watchTreeNames); @@ -149,6 +158,7 @@ export function* watchSkeletonTracingAsync(): Saga { ); yield* fork(watchFailedNodeCreations); yield* fork(watchBranchPointDeletion); + yield* fork(watchVersionRestoreParam); } function* diffNodes( diff --git a/app/assets/javascripts/oxalis/model/sagas/update_actions.js b/app/assets/javascripts/oxalis/model/sagas/update_actions.js index 771e23937dd..d9c75d780f7 100644 --- a/app/assets/javascripts/oxalis/model/sagas/update_actions.js +++ b/app/assets/javascripts/oxalis/model/sagas/update_actions.js @@ -14,9 +14,9 @@ import { convertFrontendBoundingBoxToServer } from "oxalis/model/reducers/reduce export type NodeWithTreeIdType = { treeId: number } & NodeType; -type UpdateTreeUpdateAction = { +export type UpdateTreeUpdateAction = {| name: "createTree" | "updateTree", - value: { + value: {| id: number, updatedId: ?number, color: Vector3, @@ -24,100 +24,104 @@ type UpdateTreeUpdateAction = { comments: Array, branchPoints: Array, groupId: ?number, - }, -}; -type DeleteTreeUpdateAction = { + timestamp: number, + |}, +|}; +export type DeleteTreeUpdateAction = {| name: "deleteTree", - value: { id: number }, -}; -type MoveTreeComponentUpdateAction = { + value: {| id: number |}, +|}; +type MoveTreeComponentUpdateAction = {| name: "moveTreeComponent", - value: { + value: {| sourceId: number, targetId: number, nodeIds: Array, - }, -}; -type MergeTreeUpdateAction = { + |}, +|}; +type MergeTreeUpdateAction = {| name: "mergeTree", - value: { + value: {| sourceId: number, targetId: number, - }, -}; -type CreateNodeUpdateAction = { + |}, +|}; +export type CreateNodeUpdateAction = {| name: "createNode", value: NodeWithTreeIdType, -}; -type UpdateNodeUpdateAction = { +|}; +type UpdateNodeUpdateAction = {| name: "updateNode", value: NodeWithTreeIdType, -}; -type ToggleTreeUpdateAction = { +|}; +type ToggleTreeUpdateAction = {| name: "toggleTree", - value: { + value: {| id: number, - }, -}; -type DeleteNodeUpdateAction = { + |}, +|}; +export type DeleteNodeUpdateAction = {| name: "deleteNode", - value: { + value: {| treeId: number, nodeId: number, - }, -}; -type CreateEdgeUpdateAction = { + |}, +|}; +type CreateEdgeUpdateAction = {| name: "createEdge", - value: { + value: {| treeId: number, source: number, target: number, - }, -}; -type DeleteEdgeUpdateAction = { + |}, +|}; +type DeleteEdgeUpdateAction = {| name: "deleteEdge", - value: { + value: {| treeId: number, source: number, target: number, - }, -}; -type UpdateSkeletonTracingUpdateAction = { + |}, +|}; +export type UpdateSkeletonTracingUpdateAction = {| name: "updateTracing", - value: { + value: {| activeNode: ?number, editPosition: Vector3, editRotation: Vector3, userBoundingBox: ?BoundingBoxObjectType, zoomLevel: number, - }, -}; -type UpdateVolumeTracingUpdateAction = { + |}, +|}; +type UpdateVolumeTracingUpdateAction = {| name: "updateTracing", - value: { + value: {| activeSegmentId: number, editPosition: Vector3, editRotation: Vector3, largestSegmentId: number, userBoundingBox: ?BoundingBoxObjectType, zoomLevel: number, - }, -}; -type UpdateBucketUpdateAction = { + |}, +|}; +type UpdateBucketUpdateAction = {| name: "updateBucket", value: SendBucketInfo & { base64Data: string, }, -}; -type UpdateTreeGroupsUpdateAction = { +|}; +type UpdateTreeGroupsUpdateAction = {| name: "updateTreeGroups", - value: { + value: {| treeGroups: Array, - }, -}; -type UpdateTracingUpdateAction = - | UpdateSkeletonTracingUpdateAction - | UpdateVolumeTracingUpdateAction; + |}, +|}; +export type RevertToVersionUpdateAction = {| + name: "revertToVersion", + value: {| + sourceVersion: number, + |}, +|}; export type UpdateAction = | UpdateTreeUpdateAction @@ -129,11 +133,40 @@ export type UpdateAction = | DeleteNodeUpdateAction | CreateEdgeUpdateAction | DeleteEdgeUpdateAction - | UpdateTracingUpdateAction + | UpdateSkeletonTracingUpdateAction + | UpdateVolumeTracingUpdateAction | UpdateBucketUpdateAction | ToggleTreeUpdateAction + | RevertToVersionUpdateAction | UpdateTreeGroupsUpdateAction; +type AddServerValuesFn = ( + T, +) => { + ...T, + value: { ...$PropertyType, actionTimestamp: number }, +}; +type AsServerAction = $Call; + +// Since flow does not provide ways to perform type transformations on the +// single parts of a union, we need to write this out manually. +export type ServerUpdateAction = + | AsServerAction + | AsServerAction + | AsServerAction + | AsServerAction + | AsServerAction + | AsServerAction + | AsServerAction + | AsServerAction + | AsServerAction + | AsServerAction + | AsServerAction + | AsServerAction + | AsServerAction + | AsServerAction + | AsServerAction; + export function createTree(tree: TreeType): UpdateTreeUpdateAction { return { name: "createTree", @@ -284,7 +317,10 @@ export function updateVolumeTracing( }, }; } -export function updateBucket(bucketInfo: SendBucketInfo, base64Data: string) { +export function updateBucket( + bucketInfo: SendBucketInfo, + base64Data: string, +): UpdateBucketUpdateAction { return { name: "updateBucket", value: Object.assign({}, bucketInfo, { @@ -300,3 +336,11 @@ export function updateTreeGroups(treeGroups: Array): UpdateTreeGr }, }; } +export function revertToVersion(version: number): RevertToVersionUpdateAction { + return { + name: "revertToVersion", + value: { + sourceVersion: version, + }, + }; +} diff --git a/app/assets/javascripts/oxalis/model_initialization.js b/app/assets/javascripts/oxalis/model_initialization.js index e6d9a1e5abb..caaaa94919a 100644 --- a/app/assets/javascripts/oxalis/model_initialization.js +++ b/app/assets/javascripts/oxalis/model_initialization.js @@ -72,6 +72,7 @@ export async function initialize( annotationIdOrDatasetName: string, controlMode: ControlModeType, initialFetch: boolean, + version?: number, ): Promise { return Promise.all([ getDataset(datasetName, getSharingToken()), @@ -140,7 +143,7 @@ async function fetchParallel( // Fetch the actual tracing from the datastore, if there is an skeletonAnnotation // (Also see https://github.com/facebook/flow/issues/4936) // $FlowFixMe: Type inference with Promise.all seems to be a bit broken in flow - annotation ? getTracingForAnnotations(annotation) : null, + annotation ? getTracingForAnnotations(annotation, version) : null, ]); } diff --git a/app/assets/javascripts/oxalis/store.js b/app/assets/javascripts/oxalis/store.js index 424ceeaa4c3..c9eb6f06550 100644 --- a/app/assets/javascripts/oxalis/store.js +++ b/app/assets/javascripts/oxalis/store.js @@ -332,6 +332,7 @@ export type ViewModeData = { type UiInformationType = { +showDropzoneModal: boolean, + +showVersionRestore: boolean, }; export type OxalisState = {| @@ -512,6 +513,7 @@ export const defaultState: OxalisState = { activeUser: null, uiInformation: { showDropzoneModal: false, + showVersionRestore: false, }, }; diff --git a/app/assets/javascripts/oxalis/view/action-bar/tracing_actions_view.js b/app/assets/javascripts/oxalis/view/action-bar/tracing_actions_view.js index 10862d377cd..05d0c59d7b9 100644 --- a/app/assets/javascripts/oxalis/view/action-bar/tracing_actions_view.js +++ b/app/assets/javascripts/oxalis/view/action-bar/tracing_actions_view.js @@ -13,6 +13,7 @@ import ButtonComponent from "oxalis/view/components/button_component"; import messages from "messages"; import api from "oxalis/api/internal_api"; import { undoAction, redoAction } from "oxalis/model/actions/save_actions"; +import { setVersionRestoreVisibilityAction } from "oxalis/model/actions/ui_actions"; import { copyAnnotationToUserAccount, finishAnnotation } from "admin/admin_rest_api"; import { location } from "libs/window"; import type { OxalisState, RestrictionsAndSettingsType, TaskType } from "oxalis/store"; @@ -54,6 +55,11 @@ class TracingActionsView extends PureComponent { Store.dispatch(undoAction()); }; + handleRestore = async () => { + await Model.save(); + Store.dispatch(setVersionRestoreVisibilityAction(true)); + }; + handleRedo = () => { Store.dispatch(redoAction()); }; @@ -234,6 +240,15 @@ class TracingActionsView extends PureComponent { ); } + if (isSkeletonMode && restrictions.allowUpdate) { + elements.push( + + + Restore Older Version + , + ); + } + elements.push(
+); + type Props = { viewMode: ModeType, controlMode: ControlModeType, tracing: TracingType, + showVersionRestore: boolean, }; // eslint-disable-next-line react/prefer-stateless-function @@ -24,7 +34,8 @@ class ActionBarView extends React.PureComponent { return (
- {isTraceMode ? : null} + {isTraceMode && !this.props.showVersionRestore ? : null} + {this.props.showVersionRestore ? VersionRestoreWarning : null} {hasVolume && isVolumeSupported ? : null} {isTraceMode ? : null} @@ -36,6 +47,7 @@ const mapStateToProps = (state: OxalisState): Props => ({ viewMode: state.temporaryConfiguration.viewMode, controlMode: state.temporaryConfiguration.controlMode, tracing: state.tracing, + showVersionRestore: state.uiInformation.showVersionRestore, }); export default connect(mapStateToProps)(ActionBarView); diff --git a/app/assets/javascripts/oxalis/view/layouting/tracing_layout_view.js b/app/assets/javascripts/oxalis/view/layouting/tracing_layout_view.js index 8ff5aefa30b..cbf71687204 100644 --- a/app/assets/javascripts/oxalis/view/layouting/tracing_layout_view.js +++ b/app/assets/javascripts/oxalis/view/layouting/tracing_layout_view.js @@ -9,6 +9,7 @@ import OxalisController from "oxalis/controller"; import SettingsView from "oxalis/view/settings/settings_view"; import ActionBarView from "oxalis/view/action_bar_view"; import TracingView from "oxalis/view/tracing_view"; +import VersionView from "oxalis/view/version_view"; import { Layout, Icon } from "antd"; import { location } from "libs/window"; import { withRouter } from "react-router-dom"; @@ -38,6 +39,7 @@ type StateProps = { viewMode: ModeType, displayScalebars: boolean, isUpdateTracingAllowed: boolean, + showVersionRestore: boolean, }; type Props = StateProps & { @@ -178,6 +180,11 @@ class TracingLayoutView extends React.PureComponent {
+ {this.props.showVersionRestore ? ( + + + + ) : null} @@ -190,6 +197,7 @@ function mapStateToProps(state: OxalisState): StateProps { viewMode: state.temporaryConfiguration.viewMode, displayScalebars: state.userConfiguration.displayScalebars, isUpdateTracingAllowed: state.tracing.restrictions.allowUpdate, + showVersionRestore: state.uiInformation.showVersionRestore, }; } diff --git a/app/assets/javascripts/oxalis/view/right-menu/tree_hierarchy_view.js b/app/assets/javascripts/oxalis/view/right-menu/tree_hierarchy_view.js index 0c99b45d951..3ec6b4ce0e6 100644 --- a/app/assets/javascripts/oxalis/view/right-menu/tree_hierarchy_view.js +++ b/app/assets/javascripts/oxalis/view/right-menu/tree_hierarchy_view.js @@ -230,7 +230,7 @@ class TreeHierarchyView extends React.PureComponent { const nameAndDropdown = ( - {displayableName} + {displayableName}{" "} diff --git a/app/assets/javascripts/oxalis/view/version_view.js b/app/assets/javascripts/oxalis/view/version_view.js new file mode 100644 index 00000000000..fa501f0249d --- /dev/null +++ b/app/assets/javascripts/oxalis/view/version_view.js @@ -0,0 +1,300 @@ +// @flow + +import * as React from "react"; +import _ from "lodash"; +import { Spin, Button, List, Avatar, Alert } from "antd"; +import { ControlModeEnum } from "oxalis/constants"; +import { connect } from "react-redux"; +import Model from "oxalis/model"; +import { getUpdateActionLog } from "admin/admin_rest_api"; +import Store from "oxalis/store"; +import { handleGenericError } from "libs/error_handling"; +import FormattedDate from "components/formatted_date"; +import api from "oxalis/api/internal_api"; +import classNames from "classnames"; +import { setVersionRestoreVisibilityAction } from "oxalis/model/actions/ui_actions"; +import { setAnnotationAllowUpdateAction } from "oxalis/model/actions/annotation_actions"; +import { revertToVersion } from "oxalis/model/sagas/update_actions"; +import { pushSaveQueueAction, setVersionNumberAction } from "oxalis/model/actions/save_actions"; +import { enforceSkeletonTracing } from "oxalis/model/accessors/skeletontracing_accessor"; +import type { OxalisState, SkeletonTracingType } from "oxalis/store"; +import type { + ServerUpdateAction, + CreateNodeUpdateAction, + DeleteNodeUpdateAction, + UpdateTreeUpdateAction, + DeleteTreeUpdateAction, + RevertToVersionUpdateAction, +} from "oxalis/model/sagas/update_actions"; +import type { APIUpdateActionBatch } from "admin/api_flow_types"; + +type DescriptionType = { description: string, type: string }; + +type Props = { + skeletonTracing: SkeletonTracingType, +}; + +type State = { + isLoading: boolean, + versions: Array, +}; + +const descriptionFns = { + deleteTree: (action: DeleteTreeUpdateAction): DescriptionType => ({ + description: `Deleted the tree with id ${action.value.id}.`, + type: "delete", + }), + deleteNode: (action: DeleteNodeUpdateAction): DescriptionType => ({ + description: `Deleted the node with id ${action.value.nodeId}.`, + type: "delete", + }), + revertToVersion: (action: RevertToVersionUpdateAction): DescriptionType => ({ + description: `Reverted to version ${action.value.sourceVersion}.`, + type: "backward", + }), + createNode: (action: CreateNodeUpdateAction): DescriptionType => ({ + description: `Created the node with id ${action.value.id}.`, + type: "plus", + }), + createTree: (action: UpdateTreeUpdateAction): DescriptionType => ({ + description: `Created the tree with id ${action.value.id}.`, + type: "plus", + }), + updateTreeGroups: (): DescriptionType => ({ + description: "Updated the tree groups.", + type: "edit", + }), + updateTree: (action: UpdateTreeUpdateAction): DescriptionType => ({ + description: `Updated the tree with id ${action.value.id}.`, + type: "edit", + }), +}; + +class VersionView extends React.Component { + state = { + isLoading: false, + versions: [], + }; + + componentDidMount() { + Store.dispatch(setAnnotationAllowUpdateAction(false)); + this.fetchData(this.props.skeletonTracing.tracingId); + } + + async fetchData(tracingId: string) { + const { url: dataStoreUrl } = Store.getState().dataset.dataStore; + this.setState({ isLoading: true }); + try { + const updateActionLog = await getUpdateActionLog(dataStoreUrl, tracingId, "skeleton"); + this.setState({ versions: updateActionLog }); + } catch (error) { + handleGenericError(error); + } finally { + this.setState({ isLoading: false }); + } + } + + getNewestVersion(): number { + return _.max(this.state.versions.map(batch => batch.version)) || 0; + } + + getDescriptionForSpecificBatch( + actions: Array, + type: string, + ): DescriptionType { + const firstAction = actions[0]; + if (firstAction.name !== type) { + throw new Error("Flow constraint violated"); + } + return descriptionFns[type](firstAction); + } + + getDescriptionForBatch(actions: Array): DescriptionType { + const groupedUpdateActions = _.groupBy(actions, "name"); + + const moveTreeComponentUAs = groupedUpdateActions.moveTreeComponent; + if (moveTreeComponentUAs != null) { + const firstMoveTreeComponentUA = moveTreeComponentUAs[0]; + if (firstMoveTreeComponentUA.name !== "moveTreeComponent") { + throw new Error("Flow constraint violated"); + } + if (groupedUpdateActions.createTree != null) { + return { + description: `Split off a tree with ${ + firstMoveTreeComponentUA.value.nodeIds.length + } nodes.`, + type: "arrows-alt", + }; + } else if (groupedUpdateActions.deleteTree != null) { + return { + description: `Merged a tree with ${firstMoveTreeComponentUA.value.nodeIds.length} nodes.`, + type: "shrink", + }; + } + } + + const deleteTreeUAs = groupedUpdateActions.deleteTree; + if (deleteTreeUAs != null) { + return this.getDescriptionForSpecificBatch(deleteTreeUAs, "deleteTree"); + } + + const deleteNodeUAs = groupedUpdateActions.deleteNode; + if (deleteNodeUAs != null) { + return this.getDescriptionForSpecificBatch(deleteNodeUAs, "deleteNode"); + } + + const revertToVersionUAs = groupedUpdateActions.revertToVersion; + if (revertToVersionUAs != null) { + return this.getDescriptionForSpecificBatch(revertToVersionUAs, "revertToVersion"); + } + + const createNodeUAs = groupedUpdateActions.createNode; + if (createNodeUAs != null) { + return this.getDescriptionForSpecificBatch(createNodeUAs, "createNode"); + } + + const createTreeUAs = groupedUpdateActions.createTree; + if (createTreeUAs != null) { + return this.getDescriptionForSpecificBatch(createTreeUAs, "createTree"); + } + + const updateTreeGroupsUAs = groupedUpdateActions.updateTreeGroups; + if (updateTreeGroupsUAs != null) { + return this.getDescriptionForSpecificBatch(updateTreeGroupsUAs, "updateTreeGroups"); + } + + const updateTreeUAs = groupedUpdateActions.updateTree; + if (updateTreeUAs != null) { + return this.getDescriptionForSpecificBatch(updateTreeUAs, "updateTree"); + } + + // Catch-all for other update actions, currently updateNode and updateTracing. + return { + description: "Modified the tracing.", + type: "edit", + }; + } + + async previewVersion(version: number) { + const { tracingType, annotationId } = Store.getState().tracing; + await api.tracing.restart(tracingType, annotationId, ControlModeEnum.TRACE, version); + Store.dispatch(setAnnotationAllowUpdateAction(false)); + } + + async restoreVersion(version: number) { + Store.dispatch(setVersionNumberAction(this.getNewestVersion(), "skeleton")); + Store.dispatch(pushSaveQueueAction([revertToVersion(version)], "skeleton")); + await Model.save(); + Store.dispatch(setVersionRestoreVisibilityAction(false)); + Store.dispatch(setAnnotationAllowUpdateAction(true)); + } + + handleClose = async () => { + await this.previewVersion(this.getNewestVersion()); + Store.dispatch(setVersionRestoreVisibilityAction(false)); + Store.dispatch(setAnnotationAllowUpdateAction(true)); + }; + + render() { + const VersionEntry = ({ actions, version, isNewest }) => { + const lastTimestamp = _.max(actions.map(action => action.value.actionTimestamp)); + const isActiveVersion = this.props.skeletonTracing.version === version; + const liClassName = classNames("version-entry", { + "active-version-entry": isActiveVersion, + }); + const restoreButton = ( + + ); + const { description, type } = this.getDescriptionForBatch(actions); + return ( + + + + Version {version} () + + } + onClick={() => this.previewVersion(version)} + avatar={} + description={ + + {isNewest ? ( + + Newest version
+
+ ) : null} + {description} +
+ } + /> + + + ); + }; + + const filteredVersions = this.state.versions.filter( + (batch, index) => + index === 0 || batch.value.length > 1 || batch.value[0].name !== "updateTracing", + ); + + return ( +
+
+

Version History

+
+
+ + + {filteredVersions.map((batch, index) => ( + + ))} + + +
+
+ ); + } +} + +function mapStateToProps(state: OxalisState): Props { + return { + skeletonTracing: enforceSkeletonTracing(state.tracing), + }; +} + +export default connect(mapStateToProps)(VersionView); diff --git a/app/assets/stylesheets/trace_view/_tracing_view.less b/app/assets/stylesheets/trace_view/_tracing_view.less index 3086929c988..5770cfd497a 100644 --- a/app/assets/stylesheets/trace_view/_tracing_view.less +++ b/app/assets/stylesheets/trace_view/_tracing_view.less @@ -1,3 +1,5 @@ +@import "../_variables.less"; + #tracing { position: relative; display: inline-block; @@ -123,3 +125,28 @@ .tracing-right-menu { min-width: 450px; } + +#version-restore-sider { + border-left: 1px solid #e8e8e8; + padding: 3; + padding-top: 15px; + position: fixed; + right: 0; + z-index: 1000; + top: @navbar-height; + bottom: 0; +} + +.version-entry { + padding: 3px; + &:hover:not(.active-version-entry) { + background-color: #e6f7ff; + } +} +.active-version-entry { + background-color: #bae7ff; +} + +.close-button > i { + margin-right: 0px; +} diff --git a/docs/tracing_ui.md b/docs/tracing_ui.md index a640d2ae3bf..40bf0043349 100644 --- a/docs/tracing_ui.md +++ b/docs/tracing_ui.md @@ -14,12 +14,13 @@ The toolbar contains frequently used commands, your current position within the The most common buttons are: - `Settings`: Toggles the visibility of the setting menu on the left-hand side to provide more space for your data. -- `Undo` / `Redo`: Undoes the last operation or redoes it if now changes have been made in the meantime. +- `Undo` / `Redo`: Undoes the last operation or redoes it if no new changes have been made in the meantime. Undo can only revert changes made in this session (since the moment the tracing view was opened). To revert to older versions use the "Restore Older Version" functionality, described later in this list. - `Save`: Saves your annotation work. webKnossos automatically saves every 30 seconds. - `Archive`: Only available for Explorative Annotations. Closes the annotation and archives it, removing it from a user's dashboard. Archived annotations can be found on a user's dashboard under "Explorative Annotations" and by clicking on "Show Archived Annotations". Use this to declutter your dashboard. - `Download`: Starts the download of the current annotation. Skeleton annotations are downloaded as [NML](./data_formats.md#nml) files. Volume annotation downloads contain the raw segmentation data as [WKW](./data_formats.md#wkw) files. - `Share`: Create a shareable link to your dataset containing the current position, rotation, zoom level etc. Use this to collaboratively work with colleagues. Read more about this feature in the [Sharing guide](./sharing.md). - `Add Script`: Using the [webKnossos frontend API](https://demo.webknossos.org/assets/docs/frontend-api/index.html) users can interact with webKnossos programmatically. User scripts can be executed from here. Admins can add often used scripts to webKnossos to make them available to all users for easy access. +- `Restore Older Version`: Only available for skeleton tracings. Opens a view that shows all previous versions of a skeleton tracing. From this view, any older version can be selected, previewed, and restored. A user can directly jump to positions within their datasets by entering them in the position input field. The same is true for the rotation in some tracing modes. diff --git a/package.json b/package.json index 4eeaa0a2d17..228ed50251e 100644 --- a/package.json +++ b/package.json @@ -162,7 +162,6 @@ "react-virtualized": "^9.20.1", "redux": "^3.6.0", "redux-saga": "^0.16.0", - "scroll-into-view-if-needed": "2.2.8", "stats.js": "^1.0.0", "three": "^0.87.0", "tween.js": "^16.3.1", diff --git a/webknossos-datastore/app/com/scalableminds/webknossos/datastore/tracings/FossilDBClient.scala b/webknossos-datastore/app/com/scalableminds/webknossos/datastore/tracings/FossilDBClient.scala index e37aa06ca9c..22ab856ef4b 100644 --- a/webknossos-datastore/app/com/scalableminds/webknossos/datastore/tracings/FossilDBClient.scala +++ b/webknossos-datastore/app/com/scalableminds/webknossos/datastore/tracings/FossilDBClient.scala @@ -105,11 +105,20 @@ class FossilDBClient(collection: String, config: DataStoreConfig) extends FoxImp def getMultipleVersions[T](key: String, newestVersion: Option[Long] = None, oldestVersion: Option[Long] = None) (implicit fromByteArray: Array[Byte] => Box[T]): Fox[List[T]] = { + for { + versionValueTuples <- getMultipleVersionsAsVersionValueTuple(key, newestVersion, oldestVersion) + } yield versionValueTuples.map(_._2) + } + + def getMultipleVersionsAsVersionValueTuple[T](key: String, newestVersion: Option[Long] = None, oldestVersion: Option[Long] = None) + (implicit fromByteArray: Array[Byte] => Box[T]): Fox[List[(Long,T)]] = { try { val reply = blockingStub.getMultipleVersions(GetMultipleVersionsRequest(collection, key, newestVersion, oldestVersion)) if (!reply.success) throw new Exception(reply.errorMessage.getOrElse("")) val parsedValues: List[Box[T]] = reply.values.map{v => fromByteArray(v.toByteArray)}.toList - Fox.combined(parsedValues.map{box: Box[T] => box.toFox}) + for { + values <- Fox.combined(parsedValues.map{box: Box[T] => box.toFox}) + } yield reply.versions.zip(values).toList } catch { case e: Exception => Fox.failure("could not get multiple versions from FossilDB" + e.getMessage) } diff --git a/webknossos-datastore/app/com/scalableminds/webknossos/datastore/tracings/skeleton/SkeletonTracingService.scala b/webknossos-datastore/app/com/scalableminds/webknossos/datastore/tracings/skeleton/SkeletonTracingService.scala index 366035a4fa1..09fb9a441e1 100644 --- a/webknossos-datastore/app/com/scalableminds/webknossos/datastore/tracings/skeleton/SkeletonTracingService.scala +++ b/webknossos-datastore/app/com/scalableminds/webknossos/datastore/tracings/skeleton/SkeletonTracingService.scala @@ -8,7 +8,7 @@ import com.scalableminds.webknossos.datastore.tracings.UpdateAction.SkeletonUpda import com.scalableminds.webknossos.datastore.tracings._ import com.scalableminds.webknossos.datastore.tracings.skeleton.updating._ import net.liftweb.common.{Empty, Full} -import play.api.libs.json.{Json, Writes} +import play.api.libs.json.{JsObject, Json, Writes} import scala.concurrent.ExecutionContext @@ -124,9 +124,16 @@ class SkeletonTracingService @Inject()(tracingDataStore: TracingDataStore, def merge(tracings: Seq[SkeletonTracing]): SkeletonTracing = tracings.reduceLeft(mergeTwo) def updateActionLog(tracingId: String) = { + def versionedTupleToJson(tuple: (Long, List[SkeletonUpdateAction])): JsObject = { + Json.obj( + "version" -> tuple._1, + "value" -> Json.toJson(tuple._2) + ) + } for { - updateActionGroups <- tracingDataStore.skeletonUpdates.getMultipleVersions(tracingId)(fromJson[List[SkeletonUpdateAction]]) - } yield (Json.toJson(updateActionGroups)) + updateActionGroups <- tracingDataStore.skeletonUpdates.getMultipleVersionsAsVersionValueTuple(tracingId)(fromJson[List[SkeletonUpdateAction]]) + updateActionGroupsJs = updateActionGroups.map(versionedTupleToJson) + } yield (Json.toJson(updateActionGroupsJs)) } def updateActionStatistics(tracingId: String) = { diff --git a/yarn.lock b/yarn.lock index 2636f6a913b..c02e3845dbb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9676,10 +9676,6 @@ scoped-regex@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/scoped-regex/-/scoped-regex-1.0.0.tgz#a346bb1acd4207ae70bd7c0c7ca9e566b6baddb8" -scroll-into-view-if-needed@2.2.8: - version "2.2.8" - resolved "https://registry.yarnpkg.com/scroll-into-view-if-needed/-/scroll-into-view-if-needed-2.2.8.tgz#c22ffbddce5c8a31949ab3e01c27a6c29ba7b979" - semver-compare@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/semver-compare/-/semver-compare-1.0.0.tgz#0dee216a1c941ab37e9efb1788f6afc5ff5537fc"