/* global LSF_VERSION */ import { destroy, detach, flow, getEnv, getParent, getSnapshot, isRoot, types, walk } from "mobx-state-tree"; import uniqBy from "lodash/uniqBy"; import InfoModal from "../components/Infomodal/Infomodal"; import { Hotkey } from "../core/Hotkey"; import { destroy as destroySharedStore } from "../mixins/SharedChoiceStore/mixin"; import ToolsManager from "../tools/Manager"; import Utils from "../utils"; import { guidGenerator } from "../utils/unique"; import { clamp, delay, isDefined } from "../utils/utilities"; import { CREATE_RELATION_MODE } from "./Annotation/LinkingModes"; import AnnotationStore from "./Annotation/store"; import Project from "./ProjectStore"; import Settings from "./SettingsStore"; import Task from "./TaskStore"; import { UserExtended } from "./UserStore"; import { UserLabels } from "./UserLabels"; import { FF_CUSTOM_SCRIPT, FF_DEV_1536, FF_LSDV_4620_3_ML, FF_LSDV_4998, FF_REVIEWER_FLOW, FF_SIMPLE_INIT, isFF, } from "../utils/feature-flags"; import { CommentStore } from "./Comment/CommentStore"; import { CustomButton } from "./CustomButton"; const hotkeys = Hotkey("AppStore", "Global Hotkeys"); export default types .model("AppStore", { /** * XML config */ config: types.string, /** * Task with data, id and project */ task: types.maybeNull(Task), project: types.maybeNull(Project), /** * History of task {taskId, annotationId}: */ taskHistory: types.array( types.model({ taskId: types.number, annotationId: types.maybeNull(types.string), }), [], ), /** * Configure the visual UI shown to the user */ interfaces: types.array(types.string), /** * Flag for labeling of tasks */ explore: types.optional(types.boolean, false), /** * Annotations Store */ annotationStore: types.optional(AnnotationStore, { annotations: [], predictions: [], history: [], }), /** * Comments Store */ commentStore: types.optional(CommentStore, { comments: [], }), /** * User of Label Studio */ user: types.optional(types.maybeNull(types.safeReference(UserExtended)), null), /** * Debug for development environment */ debug: window.HTX_DEBUG === true, /** * Settings of Label Studio */ settings: types.optional(Settings, {}), /** * Data of description flag */ description: types.maybeNull(types.string), // apiCalls: types.optional(types.boolean, true), /** * Flag for settings */ showingSettings: types.optional(types.boolean, false), /** * Flag * Description of task in Label Studio */ showingDescription: types.optional(types.boolean, false), /** * Loading of Label Studio */ isLoading: types.optional(types.boolean, false), /** * Submitting task; used to prevent from duplicating requests */ isSubmitting: false, /** * Flag for disable task in Label Studio */ noTask: types.optional(types.boolean, false), /** * Flag for no access to specific task */ noAccess: types.optional(types.boolean, false), /** * Finish of labeling */ labeledSuccess: types.optional(types.boolean, false), /** * Show or hide comments section */ showComments: false, /** * Dynamic preannotations */ _autoAnnotation: false, /** * Auto accept suggested annotations */ _autoAcceptSuggestions: false, /** * Indicator for suggestions awaiting */ awaitingSuggestions: false, users: types.optional(types.array(UserExtended), []), userLabels: isFF(FF_DEV_1536) ? types.optional(UserLabels, { controls: {} }) : types.undefined, queueTotal: types.optional(types.number, 0), queuePosition: types.optional(types.number, 0), /** * Project field used for applying classifications to comments */ commentClassificationConfig: types.maybeNull(types.string), customButtons: types.map( types.union(types.string, CustomButton, types.array(types.union(types.string, CustomButton))), ), }) .preProcessSnapshot((sn) => { // This should only be handled if the sn.user value is an object, and converted to a reference id for other // entities. if (typeof sn.user !== "number") { const currentUser = sn.user ?? window.APP_SETTINGS?.user ?? null; // This should never be null, but just incase the app user is missing from constructor or the window if (currentUser) { sn.user = currentUser.id; sn.users = sn.users?.length ? [currentUser, ...sn.users.filter(({ id }) => id !== currentUser.id)] : [currentUser]; } } // fix for old version of custom buttons which were just an array // @todo remove after a short time if (Array.isArray(sn.customButtons)) { sn.customButtons = { _replace: sn.customButtons }; } return { ...sn, _autoAnnotation: localStorage.getItem("autoAnnotation") === "true", _autoAcceptSuggestions: localStorage.getItem("autoAcceptSuggestions") === "true", }; }) .volatile(() => ({ version: typeof LSF_VERSION === "string" ? LSF_VERSION : "0.0.0", initialized: false, hydrated: false, suggestionsRequest: null, // @todo should be removed along with the FF; it's used to detect FF in other parts simpleInit: isFF(FF_SIMPLE_INIT), })) .views((self) => ({ get events() { return getEnv(self).events; }, get hasSegmentation() { // not an object and not a classification const isSegmentation = (t) => !t.getAvailableStates && !t.perRegionVisible; return Array.from(self.annotationStore.names.values()).some(isSegmentation); }, get canGoNextTask() { const hasHistory = self.task && self.taskHistory && self.taskHistory.length > 1; if (hasHistory) { const lastTaskId = self.taskHistory[self.taskHistory.length - 1].taskId; return self.task.id !== lastTaskId; } return false; }, get canGoPrevTask() { const hasHistory = self.task && self.taskHistory && self.taskHistory.length > 1; if (hasHistory) { const firstTaskId = self.taskHistory[0].taskId; return self.task.id !== firstTaskId; } return false; }, get forceAutoAnnotation() { return getEnv(self).forceAutoAnnotation; }, get forceAutoAcceptSuggestions() { return getEnv(self).forceAutoAcceptSuggestions; }, get autoAnnotation() { return self.forceAutoAnnotation || self._autoAnnotation; }, get autoAcceptSuggestions() { return self.forceAutoAcceptSuggestions || self._autoAcceptSuggestions; }, })) .actions((self) => { let appControls; function setAppControls(controls) { appControls = controls; } function clearApp() { appControls?.clear(); } function renderApp() { appControls?.render(); } /** * Update settings display state */ function toggleSettings() { self.showingSettings = !self.showingSettings; } /** * Update description display state */ function toggleDescription() { self.showingDescription = !self.showingDescription; } function setFlags(flags) { const names = [ "showingSettings", "showingDescription", "isLoading", "isSubmitting", "noTask", "noAccess", "labeledSuccess", "awaitingSuggestions", ]; for (const n of names) if (n in flags) self[n] = flags[n]; } /** * Check for interfaces * @param {string} name * @returns {string | undefined} */ function hasInterface(...names) { return self.interfaces.find((i) => names.includes(i)) !== undefined; } function addInterface(name) { return self.interfaces.push(name); } function toggleInterface(name, value) { const index = self.interfaces.indexOf(name); const newValue = value ?? index < 0; if (newValue) { if (index < 0) self.interfaces.push(name); } else { if (index < 0) return; self.interfaces.splice(index, 1); } } function toggleComments(state) { return (self.showComments = state); } /** * Function */ function afterCreate() { ToolsManager.setRoot(self); // important thing to detect Area atomatically: it hasn't access to store, only via global window.Htx = self; self.attachHotkeys(); getEnv(self).events.invoke("labelStudioLoad", self); } function attachHotkeys() { // Unbind previous keys in case LS was re-initialized hotkeys.unbindAll(); /** * Hotkey for submit */ if (self.hasInterface("submit", "update", "review")) { hotkeys.addNamed("annotation:submit", () => { const annotationStore = self.annotationStore; const shouldDenyEmptyAnnotation = self.hasInterface("annotations:deny-empty"); const entity = annotationStore.selected; const areResultsEmpty = entity.results.length === 0; const isReview = self.hasInterface("review") || entity.canBeReviewed; const isUpdate = !isReview && isDefined(entity.pk); // no changes were made over previously submitted version — no drafts, no pending changes const noChanges = !entity.history.canUndo && !entity.draftId; const isUpdateDisabled = isFF(FF_REVIEWER_FLOW) && isUpdate && noChanges; if (shouldDenyEmptyAnnotation && areResultsEmpty) return; if (annotationStore.viewingAll) return; if (isUpdateDisabled) return; if (entity.isReadOnly()) return; entity?.submissionInProgress(); if (self.hasInterface("annotation:bulk")) { const customButtons = self.customButtons?.get("_replace"); const submitButton = customButtons?.find((btn) => btn.name === "submit"); if (submitButton && !submitButton.disabled) { self.handleCustomButton?.(submitButton); } } else if (isReview) { self.acceptAnnotation(); } else if (!isUpdate && self.hasInterface("submit")) { self.submitAnnotation(); } else if (self.hasInterface("update")) { self.updateAnnotation(); } }); } /** * Hotkey for skip task */ if (self.hasInterface("skip", "review")) { hotkeys.addNamed("annotation:skip", () => { if (self.annotationStore.viewingAll) return; const entity = self.annotationStore.selected; entity?.submissionInProgress(); if (self.hasInterface("review")) { self.rejectAnnotation(); } else { self.skipTask(); } }); } /** * Hotkey for delete */ hotkeys.addNamed("region:delete-all", () => { const { selected } = self.annotationStore; if (window.confirm(getEnv(self).messages.CONFIRM_TO_DELETE_ALL_REGIONS)) { selected.deleteAllRegions(); } }); // create relation hotkeys.addNamed("region:relation", () => { const c = self.annotationStore.selected; if (c && c.highlightedNode && !c.isLinkingMode) { c.startLinkingMode(CREATE_RELATION_MODE, c.highlightedNode); } }); // Focus fist focusable perregion when region is selected hotkeys.addNamed("region:focus", (e) => { e.preventDefault(); const c = self.annotationStore.selected; if (c && c.highlightedNode && !c.isLinkingMode) { c.highlightedNode.requestPerRegionFocus(); } }); // unselect region hotkeys.addNamed("region:unselect", () => { const c = self.annotationStore.selected; if (c && !c.isLinkingMode && !c.isDrawing) { self.annotationStore.history.forEach((obj) => { obj.unselectAll(); }); c.unselectAll(); } }); hotkeys.addNamed("region:visibility", () => { const c = self.annotationStore.selected; if (c && !c.isLinkingMode) { c.hideSelectedRegions(); } }); hotkeys.addNamed("region:lock", () => { const c = self.annotationStore.selected; if (c && !c.isLinkingMode) { c.lockSelectedRegions(); } }); hotkeys.addNamed("region:visibility-all", () => { const { selected } = self.annotationStore; selected.regionStore.toggleVisibility(); }); hotkeys.addNamed("annotation:undo", () => { const annotation = self.annotationStore.selected; // Allow undo even during drawing - the undo() method handles stopping drawing // when appropriate (e.g., when vertices <= 1 for vector regions) // This matches the behavior of the undo button which doesn't check isDrawing annotation.undo(); }); hotkeys.addNamed("annotation:redo", () => { const annotation = self.annotationStore.selected; // Allow redo even during drawing - matches the behavior of the redo button // which doesn't check isDrawing annotation.redo(); }); hotkeys.addNamed("region:exit", (e) => { e.stopImmediatePropagation(); const c = self.annotationStore.selected; const managers = ToolsManager.allInstances(); const tools = managers .map((m) => m.findSelectedTool()) .filter(Boolean) .filter((t) => t.isDrawing); if (tools.length > 0) { tools.forEach((t) => t.complete?.()); } else if (c && c.isLinkingMode) { c.stopLinkingMode(); } else if (!c.isDrawing) { c.unselectAll(); } }); hotkeys.addNamed("region:delete", () => { const c = self.annotationStore.selected; if (c) { c.deleteSelectedRegions(); } }); hotkeys.addNamed("region:cycle", () => { const c = self.annotationStore.selected; c && c.regionStore.selectNext(); }); // duplicate selected regions hotkeys.addNamed("region:duplicate", (e) => { const { selected } = self.annotationStore; const { serializedSelection } = selected || {}; if (!serializedSelection?.length) return; e.preventDefault(); const results = selected.appendResults(serializedSelection); selected.selectAreas(results); }); } function setTaskHistory(taskHistory) { self.taskHistory = taskHistory; } /** * * @param {*} taskObject * @param {*[]} taskHistory */ function assignTask(taskObject) { if (taskObject && !Utils.Checkers.isString(taskObject.data)) { taskObject = { ...taskObject, data: JSON.stringify(taskObject.data), }; } self.task = Task.create(taskObject); if (!self.taskHistory.some((x) => x.taskId === self.task.id)) { self.taskHistory.push({ taskId: self.task.id, annotationId: null, }); } } function assignConfig(config) { const cs = self.annotationStore; self.config = config; cs.initRoot(self.config); } /* eslint-disable no-unused-vars */ function showModal(message, type = "warning") { InfoModal[type](message); // InfoModal.warning("You need to label at least something!"); } /* eslint-enable no-unused-vars */ function submitDraft(c, params = {}) { return new Promise((resolve) => { const events = getEnv(self).events; if (!events.hasEvent("submitDraft")) return resolve(); const res = events.invokeFirst("submitDraft", self, c, params); if (res && res.then) res.then(resolve); else resolve(res); }); } function waitForDraftSubmission() { return new Promise((resolve) => { if (!self.annotationStore.selected.isDraftSaving) resolve(); const checkInterval = setInterval(() => { if (!self.annotationStore.selected.isDraftSaving) { clearInterval(checkInterval); resolve(); } }, 100); }); } // Set `isSubmitting` flag to block [Submit] and related buttons during request // to prevent from sending duplicating requests. // Better to return request's Promise from SDK to make this work perfect. function handleSubmittingFlag(fn, defaultMessage = "Error during submit") { if (self.isSubmitting) return; self.setFlags({ isSubmitting: true }); const res = fn(); self.commentStore.setAddedCommentThisSession(false); // Wait for request, max 5s to not make disabled forever broken button; // but block for at least 0.2s to prevent from double clicking. Promise.race([Promise.all([res, delay(200)]), delay(5000)]) .catch((err) => { showModal(err?.message || err || defaultMessage); console.error(err); }) .then(() => self.setFlags({ isSubmitting: false })); } function incrementQueuePosition(number = 1) { self.queuePosition = clamp(self.queuePosition + number, 1, self.queueTotal); } function submitAnnotation() { if (self.isSubmitting) return; const entity = self.annotationStore.selected; const event = entity.exists ? "updateAnnotation" : "submitAnnotation"; entity.beforeSend(); if (!entity.validate()) return; if (!isFF(FF_CUSTOM_SCRIPT)) { entity.sendUserGenerate(); } handleSubmittingFlag(async () => { if (isFF(FF_CUSTOM_SCRIPT)) { await self.waitForDraftSubmission(); const allowedToSave = await getEnv(self).events.invoke("beforeSaveAnnotation", self, entity, { event }); if (allowedToSave && allowedToSave.some((x) => x === false)) return; entity.sendUserGenerate(); } await getEnv(self).events.invoke(event, self, entity); self.incrementQueuePosition(); if (isFF(FF_CUSTOM_SCRIPT)) { entity.dropDraft(); } }); if (!isFF(FF_CUSTOM_SCRIPT)) { entity.dropDraft(); } } function updateAnnotation(extraData) { if (self.isSubmitting) return; const entity = self.annotationStore.selected; entity.beforeSend(); if (!entity.validate()) return; handleSubmittingFlag(async () => { if (isFF(FF_CUSTOM_SCRIPT)) { const allowedToSave = await getEnv(self).events.invoke("beforeSaveAnnotation", self, entity, { event: "updateAnnotation", }); if (allowedToSave && allowedToSave.some((x) => x === false)) return; } await getEnv(self).events.invoke("updateAnnotation", self, entity, extraData); self.incrementQueuePosition(); if (isFF(FF_CUSTOM_SCRIPT)) { entity.dropDraft(); !entity.sentUserGenerate && entity.sendUserGenerate(); } }); if (!isFF(FF_CUSTOM_SCRIPT)) { entity.dropDraft(); !entity.sentUserGenerate && entity.sendUserGenerate(); } } function skipTask(extraData) { if (self.isSubmitting) return; // Manager roles that can force-skip unskippable tasks (OW=Owner, AD=Admin, MA=Manager) const MANAGER_ROLES = ["OW", "AD", "MA"]; const task = self.task; const taskAllowSkip = task?.allow_skip !== false; const userRole = window.APP_SETTINGS?.user?.role; const hasForceSkipPermission = MANAGER_ROLES.includes(userRole); const canSkip = taskAllowSkip || hasForceSkipPermission; if (!canSkip) { console.warn("Task cannot be skipped: allow_skip is false and user lacks manager role"); return; } handleSubmittingFlag(() => { getEnv(self).events.invoke("skipTask", self, extraData); self.incrementQueuePosition(); }, "Error during skip, try again"); } function unskipTask() { if (self.isSubmitting) return; handleSubmittingFlag(() => { getEnv(self).events.invoke("unskipTask", self); }, "Error during cancel skipping task, try again"); } function acceptAnnotation() { if (self.isSubmitting) return; handleSubmittingFlag(async () => { const entity = self.annotationStore.selected; entity.beforeSend(); if (!entity.validate()) return; if (isFF(FF_CUSTOM_SCRIPT)) { const allowedToSave = await getEnv(self).events.invoke("beforeSaveAnnotation", self, entity, { event: "acceptAnnotation", }); if (allowedToSave && allowedToSave.some((x) => x === false)) return; } // changes in current sessions or saved draft should send the result along with approval const isDirty = entity.history.canUndo || entity.versions.draft; entity.dropDraft(); await getEnv(self).events.invoke("acceptAnnotation", self, { isDirty, entity }); self.incrementQueuePosition(); }, "Error during accept, try again"); } function rejectAnnotation({ comment = null }) { if (self.isSubmitting) return; handleSubmittingFlag(async () => { const entity = self.annotationStore.selected; entity.beforeSend(); if (!entity.validate()) return; if (isFF(FF_CUSTOM_SCRIPT)) { const allowedToSave = await getEnv(self).events.invoke("beforeSaveAnnotation", self, entity, { event: "rejectAnnotation", }); if (allowedToSave && allowedToSave.some((x) => x === false)) return; } const isDirty = entity.history.canUndo; entity.dropDraft(); await getEnv(self).events.invoke("rejectAnnotation", self, { isDirty, entity, comment }); self.incrementQueuePosition(-1); }, "Error during reject, try again"); } function handleCustomButton(button) { if (self.isSubmitting) return; const buttonName = button.name; handleSubmittingFlag(async () => { const entity = self.annotationStore.selected; entity.beforeSend(); // @todo add needsValidation or something like that as a parameter to custom buttons // if (!entity.validate()) return; const isDirty = entity.history.canUndo; await getEnv(self).events.invoke("customButton", self, buttonName, { isDirty, entity, button }); self.incrementQueuePosition(); entity.dropDraft(); }, `Error during handling ${button} button, try again`); } /** * Exchange storage url for presigned url for task */ async function presignUrlForProject(url) { // Event invocation returns array of results for all handlers. const urls = await self.events.invoke("presignUrlForProject", self, url); const presignUrl = urls?.[0]; return presignUrl; } /** * Reset annotation store */ function resetState() { // Tools are attached to the control and object tags // and need to be recreated when we st a new task ToolsManager.removeAllTools(); // Same with hotkeys Hotkey.unbindAll(); self.attachHotkeys(); const oldAnnotationStore = self.annotationStore; if (oldAnnotationStore) { oldAnnotationStore.beforeReset?.(); if (isFF(FF_LSDV_4998)) { destroySharedStore(); } detach(oldAnnotationStore); destroy(oldAnnotationStore); } self.annotationStore = AnnotationStore.create({ annotations: [] }); self.initialized = false; } function resetAnnotationStore() { const oldAnnotationStore = self.annotationStore; if (oldAnnotationStore) { oldAnnotationStore.beforeReset?.(); oldAnnotationStore.resetAnnotations?.(); } } /** * Function to initialize annotation store * Given annotations and predictions * `completions` is a fallback for old projects; they'll be saved as `annotations` anyway */ function initializeStore({ annotations = [], completions = [], predictions = [], annotationHistory }) { const as = self.annotationStore; // some hacks to properly clear react and mobx structures as.afterReset?.(); if (!as.initialized) { as.initRoot(self.config); if (isFF(FF_LSDV_4620_3_ML) && !appControls?.isRendered()) { appControls?.render(); } } // goal here is to deserialize everything fast and select only first annotation // no extra processes during eserialization and further processes triggered during select if (self.simpleInit) { window.STORE_INIT_OK = false; // add predictions and annotations to the store; // `hidden` will stop them from calling any rendering helpers; // correct annotation will be selected at the end and everything will be called inside. predictions.forEach((p) => { const obj = as.addPrediction(p); const results = p.result.map((r) => ({ ...r, origin: "prediction" })); obj.deserializeResults(results, { hidden: true }); }); [...completions, ...annotations].forEach((c) => { const obj = as.addAnnotation(c); obj.deserializeResults(c.draft || c.result, { hidden: true }); }); window.STORE_INIT_OK = true; // simple logging to detect if simple init is used on users' machines console.log("LSF: deserialization is finished"); // next line might be unclear after removing FF_SIMPLE_INIT // reversing the list caused problems before when task is reloaded and list is reversed again. // AnnotationsCarousel has its own ordering anyway, so we just keep technical order // as simple as possible. const current = as.annotations.at(-1); const currentPrediction = !current && as.predictions.at(-1); if (current) { as.selectAnnotation(current.id); // looks like we still need it anyway, but it's fast and harmless, // and we only call it once on already visible annotation current.reinitHistory(); } else if (currentPrediction) { as.selectPrediction(currentPrediction.id); } // annotation history is set when annotation is selected, // so no need to set it here } else { (predictions ?? []).forEach((p) => { const obj = as.addPrediction(p); as.selectPrediction(obj.id); obj.deserializeResults( p.result.map((r) => ({ ...r, origin: "prediction", })), ); }); [...(completions ?? []), ...(annotations ?? [])]?.forEach((c) => { const obj = as.addAnnotation(c); as.selectAnnotation(obj.id); obj.deserializeResults(c.draft || c.result); obj.reinitHistory(); }); const current = as.annotations.at(-1); if (current) current.setInitialValues(); self.setHistory(annotationHistory); } if (!self.initialized) { self.initialized = true; getEnv(self).events.invoke("storageInitialized", self); } } function setHistory(history = []) { const as = self.annotationStore; as.clearHistory(); // always check that history is for correct and submitted annotation if (!history.length || !as.selected?.pk) return; if (Number(as.selected.pk) !== Number(history[0].annotation_id)) return; (history ?? []).forEach((item) => { const obj = as.addHistory(item); obj.deserializeResults(item.result ?? [], { hidden: true }); }); } const setAutoAnnotation = (value) => { self._autoAnnotation = value; localStorage.setItem("autoAnnotation", value); }; const setAutoAcceptSuggestions = (value) => { self._autoAcceptSuggestions = value; localStorage.setItem("autoAcceptSuggestions", value); }; const loadSuggestions = flow(function* (request, dataParser) { const requestId = guidGenerator(); self.suggestionsRequest = requestId; self.setFlags({ awaitingSuggestions: true }); try { const response = yield request; if (requestId === self.suggestionsRequest) { self.annotationStore.selected.setSuggestions(dataParser(response)); self.setFlags({ awaitingSuggestions: false }); } } catch (_e) { self.setFlags({ awaitingSuggestions: false }); // @todo handle errors + situation when task is changed } }); function addAnnotationToTaskHistory(annotationId) { const taskIndex = self.taskHistory.findIndex(({ taskId }) => taskId === self.task.id); if (taskIndex >= 0) { self.taskHistory[taskIndex].annotationId = annotationId; } } async function postponeTask() { const annotation = self.annotationStore.selected; // save draft before postponing; this can be new draft with FF_DEV_4174 off // or annotation created from prediction await annotation.saveDraft({ was_postponed: true }); await getEnv(self).events.invoke("nextTask"); self.incrementQueuePosition(); } function nextTask() { if (self.canGoNextTask) { const { taskId, annotationId } = self.taskHistory[self.taskHistory.findIndex((x) => x.taskId === self.task.id) + 1]; getEnv(self).events.invoke("nextTask", taskId, annotationId); self.incrementQueuePosition(); } } function prevTask(_e, shouldGoBack = false) { const length = shouldGoBack ? self.taskHistory.length - 1 : self.taskHistory.findIndex((x) => x.taskId === self.task.id) - 1; if (self.canGoPrevTask || shouldGoBack) { const { taskId, annotationId } = self.taskHistory[length]; getEnv(self).events.invoke("prevTask", taskId, annotationId); self.incrementQueuePosition(-1); } } function setUsers(users) { self.users.replace(users); } // @deprecated use `enrichUsers` instead (as mergeUsers will not update existing users and can lose previous data) function mergeUsers(users) { self.setUsers(uniqBy([...getSnapshot(self.users), ...users], "id")); } function enrichUsers(users) { const oldUsers = getSnapshot(self.users); const oldUsersMap = {}; oldUsers.forEach((user) => { oldUsersMap[user.id] = user; }); const newUsers = users.map((user) => { return { ...oldUsersMap[user.id], ...user }; }); self.setUsers(uniqBy([...newUsers, ...oldUsers], "id")); } return { setFlags, addInterface, hasInterface, toggleInterface, afterCreate, assignTask, assignConfig, resetState, resetAnnotationStore, initializeStore, setHistory, attachHotkeys, skipTask, unskipTask, setTaskHistory, submitDraft, waitForDraftSubmission, submitAnnotation, updateAnnotation, acceptAnnotation, rejectAnnotation, handleCustomButton, presignUrlForProject, setUsers, mergeUsers, enrichUsers, showModal, toggleComments, toggleSettings, toggleDescription, setAutoAnnotation, setAutoAcceptSuggestions, loadSuggestions, addAnnotationToTaskHistory, nextTask, prevTask, postponeTask, incrementQueuePosition, beforeDestroy() { ToolsManager.removeAllTools(); appControls = null; }, setAppControls, clearApp, renderApp, selfDestroy() { const children = []; walk(self, (node) => { if (!isRoot(node) && getParent(node) === self) children.push(node); }); let node; while ((node = children.shift())) { try { destroy(node); } catch (e) { console.log("Problem: ", e); } } }, }; });