/** @global LSF * / /** * @typedef {{ * hiddenColumns?: { * labeling?: string[], * explore?: string[], * }, * visibleColumns?: { * labeling?: string[], * explore?: string[], * } * }} TableConfig */ /** * @typedef {{ * root: HTMLElement, * polling: boolean, * apiGateway: string | URL, * apiEndpoints: import("../utils/api-proxy").Endpoints, * apiMockDisabled: boolean, * apiHeaders?: Dict, * settings: Dict, * labelStudio: Dict, * env: "development" | "production", * mode: "labelstream" | "explorer", * table: TableConfig, * links: Dict, * showPreviews: boolean, * projectId?: number, * datasetId?: number, * interfaces: Dict, * instruments: Dict, * toolbar?: string, * spinner?: import("react").ReactNode * apiTransform?: Record * tabControls?: { add?: boolean, delete?: boolean, edit?: boolean, duplicate?: boolean }, * }} DMConfig */ import { inject, observer } from "mobx-react"; import { destroy } from "mobx-state-tree"; import { unmountComponentAtNode } from "react-dom"; import camelCase from "lodash/camelCase"; import { instruments } from "../components/DataManager/Toolbar/instruments"; import { APIProxy } from "../utils/api-proxy"; import { FF_LSDV_4620_3_ML, isFF } from "../utils/feature-flags"; import { objectToMap } from "../utils/helpers"; import { serializeJsonForUrl, deserializeJsonFromUrl } from "@humansignal/core"; import { isDefined } from "../utils/utils"; import { APIConfig } from "./api-config"; import { createApp } from "./app-create"; import { LSFWrapper } from "./lsf-sdk"; import { taskToLSFormat } from "./lsf-utils"; const DEFAULT_TOOLBAR = "actions columns filters ordering label-button loading-possum error-box | refresh import-button export-button density-toggle grid-size view-toggle"; const prepareInstruments = (instruments) => { const result = Object.entries(instruments).map(([name, builder]) => [name, builder({ inject, observer })]); return objectToMap(Object.fromEntries(result)); }; export class DataManager { /** @type {HTMLElement} */ root = null; /** @type {APIProxy} */ api = null; /** @type {import("./lsf-sdk").LSFWrapper} */ lsf = null; /** @type {Dict} */ settings = {}; /** @type {import("../stores/AppStore").AppStore} */ store = null; /** @type {Dict} */ labelStudioOptions = {}; /** @type {"development" | "production"} */ env = "development"; /** @type {"explorer" | "labelstream"} */ mode = "explorer"; /** @type {TableConfig} */ tableConfig = {}; /** @type {Dict} */ links = { import: "/import", export: "/export", settings: "./settings", }; /** * @private * @type {Map>} */ callbacks = new Map(); /** * @private * @type {Map>} */ actions = new Map(); /** @type {Number} */ apiVersion = 1; /** @type {boolean} */ showPreviews = false; /** @type {boolean} */ polling = true; /** @type {boolean} */ started = false; instruments = new Map(); /** * @type {DMConfig.tabControls} */ tabControls = { add: true, delete: true, edit: true, duplicate: true, }; /** @type {"dm" | "labelops"} */ type = "dm"; /** @type {string} */ role = null; /** * Constructor * @param {DMConfig} config */ constructor(config) { this.root = config.root; this.project = config.project; this.projectId = config.projectId ?? this?.project?.id; this.dataset = config.dataset; this.datasetId = config.datasetId; this.settings = config.settings; this.labelStudioOptions = config.labelStudio; this.env = config.env ?? process.env.NODE_ENV ?? this.env; this.mode = config.mode ?? this.mode; this.tableConfig = config.table ?? {}; this.apiVersion = config?.apiVersion ?? 1; this.links = Object.assign(this.links, config.links ?? {}); this.showPreviews = config.showPreviews ?? false; this.polling = config.polling; this.toolbar = config.toolbar ?? DEFAULT_TOOLBAR; this.spinner = config.spinner; this.spinnerSize = config.spinnerSize; this.instruments = prepareInstruments(config.instruments ?? {}); this.apiTransform = config.apiTransform ?? {}; this.preload = config.preload ?? {}; this.role = config.role ?? null; this.interfaces = objectToMap({ tabs: true, toolbar: true, import: true, export: true, labelButton: true, backButton: true, labelingHeader: true, groundTruth: false, instruction: false, autoAnnotation: false, ...config.interfaces, }); this.api = new APIProxy( this.apiConfig({ apiGateway: config.apiGateway, apiEndpoints: config.apiEndpoints, apiMockDisabled: config.apiMockDisabled, apiSharedParams: config.apiSharedParams, apiHeaders: config.apiHeaders, }), ); Object.assign(this.tabControls, config.tabControls ?? {}); this.updateActions(config.actions); this.type = config.type ?? "dm"; this.initApp(); } get isExplorer() { return this.mode === "labeling"; } get isLabelStream() { return this.mode === "labelstream"; } get projectId() { return (this._projectId = this._projectId ?? this.root?.dataset?.projectId); } set projectId(value) { this._projectId = value; } apiConfig({ apiGateway, apiEndpoints, apiMockDisabled, apiSharedParams, apiHeaders }) { const config = Object.assign({}, APIConfig); config.gateway = apiGateway ?? config.gateway; config.mockDisabled = apiMockDisabled; config.commonHeaders = apiHeaders; Object.assign(config.endpoints, apiEndpoints ?? {}); const sharedParams = {}; if (!isNaN(this.projectId)) { sharedParams.project = this.projectId; } if (!isNaN(this.datasetId)) { sharedParams.dataset = this.datasetId; } Object.assign(config, { sharedParams: { ...sharedParams, ...(apiSharedParams ?? {}), }, }); return config; } /** * @param {import("../stores/Action.js").Action} action */ addAction(action, callback) { const { id } = action; if (!id) throw new Error("Action must provide a unique ID"); this.actions.set(id, { action, callback }); const actions = Array.from(this.actions.values()).map(({ action }) => action); this.store?.setActions(actions); } removeAction(id) { this.actions.delete(id); this.store.removeAction(id); } getAction(id) { return this.actions.get(id)?.callback; } installActions() { this.actions.forEach(({ action, callback }) => { this.addAction(action, callback); }); } updateActions(actions) { if (!Array.isArray(actions)) return; actions.forEach(([action, callback]) => { if (!isDefined(action.id)) { throw new Error("Every action must provide a unique ID"); } this.addAction(action, callback); }); } registerInstrument(name, initializer) { if (instruments[name]) { return console.warn(`Can't override native instrument ${name}`); } this.instruments.set( name, initializer({ store: this.store, observer, inject, }), ); this.store.updateInstruments(); } /** * Assign an event handler * @param {string} eventName * @param {Function} callback */ on(eventName, callback) { if (this.lsf && eventName.startsWith("lsf:")) { const evt = camelCase(eventName.replace(/^lsf:/, "")); this.lsf?.lsfInstance?.on(evt, callback); } const events = this.getEventCallbacks(eventName); events.add(callback); this.callbacks.set(eventName, events); } /** * Remove an event handler * If no callback provided, all assigned callbacks will be removed * @param {string} eventName * @param {Function?} callback */ off(eventName, callback) { if (this.lsf && eventName.startsWith("lsf:")) { const evt = camelCase(eventName.replace(/^lsf:/, "")); this.lsf?.lsfInstance?.off(evt, callback); } const events = this.getEventCallbacks(eventName); if (callback) { events.delete(callback); } else { events.clear(); } } removeAllListeners() { const lsfEvents = Array.from(this.callbacks.keys()).filter((evt) => evt.startsWith("lsf:")); lsfEvents.forEach((evt) => { const callbacks = Array.from(this.getEventCallbacks(evt)); const eventName = camelCase(evt.replace(/^lsf:/, "")); callbacks.forEach((clb) => this.lsf?.lsfInstance?.off(eventName, clb)); }); this.callbacks.clear(); } /** * Check if an event has at least one handler * @param {string} eventName Name of the event to check */ hasHandler(eventName) { return this.getEventCallbacks(eventName).size > 0; } /** * Check if interface is enabled * @param {string} name Name of the interface */ interfaceEnabled(name) { return this.store.interfaceEnabled(name); } /** * * @param {"explorer" | "labelstream"} mode */ setMode(mode) { const modeChanged = mode !== this.mode; this.mode = mode; this.store.setMode(mode); if (modeChanged) this.invoke("modeChanged", this.mode); } /** * Invoke handlers assigned to an event * @param {string} eventName * @param {any[]} args */ async invoke(eventName, ...args) { if (eventName.startsWith("lsf:")) return; this.getEventCallbacks(eventName).forEach((callback) => callback.apply(this, args)); } /** * Get callbacks set for a particular event * @param {string} eventName */ getEventCallbacks(eventName) { return this.callbacks.get(eventName) ?? new Set(); } /** @private */ async initApp() { this.store = await createApp(this.root, this); this.invoke("ready", [this]); } initLSF(element) { if (this.lsf) return; this.lsf = new LSFWrapper(this, element, { ...this.labelStudioOptions, task: this.store.taskStore.selected, preload: this.preload, // annotation: this.store.annotationStore.selected, isLabelStream: this.mode === "labelstream", }); } /** * Initialize LSF or use already initialized instance. * Render LSF interface and load task for labeling. * @param {HTMLElement} element Root element LSF will be rendered into * @param {import("../stores/Tasks").TaskModel} task */ async startLabeling() { if (!this.lsf) return; const [task, annotation] = [this.store.taskStore.selected, this.store.annotationStore.selected]; const isLabelStream = this.mode === "labelstream"; const taskExists = isDefined(this.lsf.task) && isDefined(task); const taskSelected = this.lsf.task?.id === task?.id; // do nothing if the task is already selected if (taskExists && taskSelected) { return; } if (!isLabelStream && (!taskSelected || isDefined(annotation))) { const annotationID = annotation?.id ?? task.lastAnnotation?.id; // this.lsf.loadTask(task.id, annotationID); this.lsf.selectTask(task, annotationID); } } destroyLSF() { this.invoke("beforeLsfDestroy", this, this.lsf?.lsfInstance); this.lsf?.destroy(); this.lsf = undefined; } destroy(detachCallbacks = true) { if (isFF(FF_LSDV_4620_3_ML)) { this.destroyLSF(); } unmountComponentAtNode(this.root); if (this.store) { destroy(this.store); } if (detachCallbacks) { this.callbacks.forEach((callbacks) => callbacks.clear()); this.callbacks.clear(); } } reload() { this.destroy(false); this.initApp(); this.installActions(); } async apiCall(...args) { return this.store.apiCall(...args); } getInstrument(name) { return instruments[name] ?? this.instruments.get(name) ?? null; } hasInterface(name) { return this.interfaces.get(name) === true; } get toolbarInstruments() { const sections = this.toolbar.split("|").map((s) => s.trim()); const instrumentsList = sections.map((section) => { return section.split(" ").filter((instrument) => { const nativeInstrument = !!instruments[instrument]; const customInstrument = !!this.instruments.has(instrument); if (!nativeInstrument && !customInstrument) { console.warn(`Unknwown instrument detected: ${instrument}. Did you forget to register it?`); } return nativeInstrument || customInstrument; }); }); return instrumentsList; } static urlJSON = { serializeJsonForUrl, deserializeJsonFromUrl }; static taskToLSFormat = taskToLSFormat; }