diff --git a/.gitignore b/.gitignore index 59ae04b3f6..81d56beb17 100644 --- a/.gitignore +++ b/.gitignore @@ -40,7 +40,8 @@ pids lib-cov # Coverage directory used by tools like istanbul -coverage +/coverage +/html # nyc test coverage .nyc_output diff --git a/companion/lib/Controls/ActionRecorder.ts b/companion/lib/Controls/ActionRecorder.ts index d2c7d65417..55864688bc 100644 --- a/companion/lib/Controls/ActionRecorder.ts +++ b/companion/lib/Controls/ActionRecorder.ts @@ -6,12 +6,13 @@ import LogController from '../Log/Controller.js' import { EventEmitter } from 'events' import type { Registry } from '../Registry.js' import type { - RecordActionTmp, + RecordActionEntityModel, RecordSessionInfo, RecordSessionListInfo, } from '@companion-app/shared/Model/ActionRecorderModel.js' import type { ClientSocket } from '../UI/Handler.js' import type { ActionSetId } from '@companion-app/shared/Model/ActionModel.js' +import { EntityModelType, SomeSocketEntityLocation } from '@companion-app/shared/Model/EntityModel.js' const SessionListRoom = 'action-recorder:session-list' function SessionRoom(id: string): string { @@ -356,10 +357,11 @@ export class ActionRecorder extends EventEmitter { const session = this.#currentSession if (session.connectionIds.includes(connectionId)) { - const newAction: RecordActionTmp = { + const newAction: RecordActionEntityModel = { + type: EntityModelType.Action, id: nanoid(), - instance: connectionId, - action: actionId, + connectionId: connectionId, + definitionId: actionId, options: options, delay: (session.actionDelay ?? 0) + delay, @@ -395,14 +397,16 @@ export class ActionRecorder extends EventEmitter { if (!control) throw new Error(`Unknown control: ${controlId}`) if (mode === 'append') { - if (control.supportsActions) { - if (!control.actionAppend(stepId, setId, this.#currentSession.actions, null)) throw new Error('Unknown set') + if (control.supportsEntities) { + if (!control.entities.entityAdd({ stepId, setId }, null, ...this.#currentSession.actions)) + throw new Error('Unknown set') } else { throw new Error('Not supported by control') } } else { - if (control.supportsActions) { - if (!control.actionReplaceAll(stepId, setId, this.#currentSession.actions)) throw new Error('Unknown set') + if (control.supportsEntities) { + const listId: SomeSocketEntityLocation = { stepId, setId } + if (!control.entities.entityReplaceAll(listId, this.#currentSession.actions)) throw new Error('Unknown set') } else { throw new Error('Not supported by control') } diff --git a/companion/lib/Controls/ActionRunner.ts b/companion/lib/Controls/ActionRunner.ts index b8f750fc11..b8c035d49c 100644 --- a/companion/lib/Controls/ActionRunner.ts +++ b/companion/lib/Controls/ActionRunner.ts @@ -1,8 +1,8 @@ import { CoreBase } from '../Core/Base.js' import type { Registry } from '../Registry.js' import type { RunActionExtras } from '../Instance/Wrapper.js' -import { ActionInstance } from '@companion-app/shared/Model/ActionModel.js' import { nanoid } from 'nanoid' +import { ActionEntityModel, EntityModelType, SomeEntityModel } from '@companion-app/shared/Model/EntityModel.js' /** * Class to handle execution of actions. @@ -32,13 +32,13 @@ export class ActionRunner extends CoreBase { /** * Run a single action */ - async #runAction(action: ActionInstance, extras: RunActionExtras): Promise { + async #runAction(action: ActionEntityModel, extras: RunActionExtras): Promise { this.logger.silly('Running action', action) - if (action.instance === 'internal') { + if (action.connectionId === 'internal') { await this.internalModule.executeAction(action, extras) } else { - const instance = this.instance.moduleHost.getChild(action.instance) + const instance = this.instance.moduleHost.getChild(action.connectionId) if (instance) { await instance.actionRun(action, extras) } else { @@ -51,11 +51,13 @@ export class ActionRunner extends CoreBase { * Run multiple actions */ async runMultipleActions( - actions0: ActionInstance[], + actions0: SomeEntityModel[], extras: RunActionExtras, executeSequential = false ): Promise { - const actions = actions0.filter((act) => !act.disabled) + const actions = actions0.filter( + (act): act is ActionEntityModel => act.type === EntityModelType.Action && !act.disabled + ) if (actions.length === 0) return if (extras.abortDelayed.aborted) return @@ -66,7 +68,7 @@ export class ActionRunner extends CoreBase { for (const action of actions) { if (extras.abortDelayed.aborted) break await this.#runAction(action, extras).catch((e) => { - this.logger.silly(`Error executing action for ${action.instance}: ${e.message ?? e}`) + this.logger.silly(`Error executing action for ${action.connectionId}: ${e.message ?? e}`) }) } } else { @@ -78,7 +80,7 @@ export class ActionRunner extends CoreBase { if (waitAction) { // Perform the wait action await this.#runAction(waitAction, extras).catch((e) => { - this.logger.silly(`Error executing action for ${waitAction.instance}: ${e.message ?? e}`) + this.logger.silly(`Error executing action for ${waitAction.connectionId}: ${e.message ?? e}`) }) } @@ -89,7 +91,7 @@ export class ActionRunner extends CoreBase { await Promise.all( actions.map(async (action) => this.#runAction(action, extras).catch((e) => { - this.logger.silly(`Error executing action for ${action.instance}: ${e.message ?? e}`) + this.logger.silly(`Error executing action for ${action.connectionId}: ${e.message ?? e}`) }) ) ) @@ -98,8 +100,8 @@ export class ActionRunner extends CoreBase { } } - #splitActionsAroundWaits(actions: ActionInstance[]): GroupedActionInstances[] { - const groupedActions: GroupedActionInstances[] = [ + #splitActionsAroundWaits(actions: ActionEntityModel[]): GroupedActionEntityModels[] { + const groupedActions: GroupedActionEntityModels[] = [ { waitAction: undefined, actions: [], @@ -107,7 +109,7 @@ export class ActionRunner extends CoreBase { ] for (const action of actions) { - if (action.action === 'wait') { + if (action.definitionId === 'wait') { groupedActions.push({ waitAction: action, actions: [], @@ -121,9 +123,9 @@ export class ActionRunner extends CoreBase { } } -interface GroupedActionInstances { - waitAction: ActionInstance | undefined - actions: ActionInstance[] +interface GroupedActionEntityModels { + waitAction: ActionEntityModel | undefined + actions: ActionEntityModel[] } export class ControlActionRunner { @@ -144,7 +146,7 @@ export class ControlActionRunner { } async runActions( - actions: ActionInstance[], + actions: SomeEntityModel[], extras: Omit ): Promise { const controller = new AbortController() diff --git a/companion/lib/Controls/ControlBase.ts b/companion/lib/Controls/ControlBase.ts index 04e1775931..aa16a3246d 100644 --- a/companion/lib/Controls/ControlBase.ts +++ b/companion/lib/Controls/ControlBase.ts @@ -196,11 +196,6 @@ export abstract class ControlBase { */ abstract renameVariables(labelFrom: string, labelTo: string): void - /** - * Prune any items on controls which belong to an unknown connectionId - */ - abstract verifyConnectionIds(knownConnectionIds: Set): void - /** * Execute a press of a control * @param pressed Whether the control is pressed diff --git a/companion/lib/Controls/ControlTypes/Button/Base.ts b/companion/lib/Controls/ControlTypes/Button/Base.ts index 6153ad02c3..7273dbb65e 100644 --- a/companion/lib/Controls/ControlTypes/Button/Base.ts +++ b/companion/lib/Controls/ControlTypes/Button/Base.ts @@ -1,21 +1,15 @@ import { ControlBase } from '../../ControlBase.js' import { GetButtonBitmapSize } from '../../../Resources/Util.js' import { cloneDeep } from 'lodash-es' -import { FragmentFeedbacks } from '../../Fragments/FragmentFeedbacks.js' -import { FragmentActions } from '../../Fragments/FragmentActions.js' -import type { - ControlWithFeedbacks, - ControlWithOptions, - ControlWithPushed, - ControlWithStyle, -} from '../../IControlFragments.js' +import type { ControlWithOptions, ControlWithPushed, ControlWithStyle } from '../../IControlFragments.js' import { ReferencesVisitors } from '../../../Resources/Visitors/ReferencesVisitors.js' import type { ButtonOptionsBase, ButtonStatus } from '@companion-app/shared/Model/ButtonModel.js' -import type { ActionInstance } from '@companion-app/shared/Model/ActionModel.js' import type { DrawStyleButtonModel } from '@companion-app/shared/Model/StyleModel.js' import type { CompanionVariableValues } from '@companion-module/base' import type { ControlDependencies } from '../../ControlDependencies.js' import { ControlActionRunner } from '../../ActionRunner.js' +import { ControlEntityListPoolButton } from '../../Entities/EntityListPoolButton.js' +import { EntityModelType } from '@companion-app/shared/Model/EntityModel.js' /** * Abstract class for a editable button control. @@ -39,10 +33,10 @@ import { ControlActionRunner } from '../../ActionRunner.js' */ export abstract class ButtonControlBase> extends ControlBase - implements ControlWithStyle, ControlWithFeedbacks, ControlWithOptions, ControlWithPushed + implements ControlWithStyle, ControlWithOptions, ControlWithPushed { readonly supportsStyle = true - readonly supportsFeedbacks = true + readonly supportsEntities = true readonly supportsOptions = true readonly supportsPushed = true @@ -51,11 +45,6 @@ export abstract class ButtonControlBase | null = null - /** - * Steps on this button - */ - protected steps: Record = {} + readonly entities: ControlEntityListPoolButton protected readonly actionRunner: ControlActionRunner @@ -88,14 +74,16 @@ export abstract class ButtonControlBase { // Find all the connections referenced by the button - const connectionIds = new Set() - for (const step of Object.values(this.steps)) { - for (const action of step.getAllActions()) { - if (action.disabled) continue - connectionIds.add(action.connectionId) - } - } + const connectionIds = this.entities.getAllEnabledConnectionIds() // Figure out the combined status let status: ButtonStatus = 'good' @@ -159,18 +141,14 @@ export abstract class ButtonControlBase { + this.entities.postProcessImport().catch((e) => { this.logger.silly(`postProcessImport for ${this.controlId} failed: ${e.message}`) }) @@ -324,21 +274,15 @@ export abstract class ButtonControlBase 0) { // Apply the diff - Object.assign(this.feedbacks.baseStyle, diff) + Object.assign(this.entities.baseStyle, diff) if ('show_topbar' in diff) { // Some feedbacks will need to redraw - this.feedbacks.resubscribeAllFeedbacks() + this.entities.resubscribeEntities(EntityModelType.Feedback) } this.commitChange() @@ -409,22 +353,4 @@ export abstract class ButtonControlBase): void { - const changedFeedbacks = this.feedbacks.verifyConnectionIds(knownConnectionIds) - - let changedSteps = false - for (const step of Object.values(this.steps)) { - const changed = step.verifyConnectionIds(knownConnectionIds) - changedSteps = changedSteps || changed - } - - if (changedFeedbacks || changedSteps) { - this.commitChange(changedFeedbacks) - } - } } diff --git a/companion/lib/Controls/ControlTypes/Button/Normal.ts b/companion/lib/Controls/ControlTypes/Button/Normal.ts index ead6703771..ea10c6d71c 100644 --- a/companion/lib/Controls/ControlTypes/Button/Normal.ts +++ b/companion/lib/Controls/ControlTypes/Button/Normal.ts @@ -1,29 +1,14 @@ import { ButtonControlBase } from './Base.js' import { cloneDeep } from 'lodash-es' -import { FragmentActions } from '../../Fragments/FragmentActions.js' -import { GetStepIds } from '@companion-app/shared/Controls.js' import { VisitorReferencesCollector } from '../../../Resources/Visitors/ReferencesCollector.js' -import type { - ControlWithActionSets, - ControlWithActions, - ControlWithSteps, - ControlWithoutEvents, -} from '../../IControlFragments.js' +import type { ControlWithActionSets, ControlWithActions, ControlWithoutEvents } from '../../IControlFragments.js' import { ReferencesVisitors } from '../../../Resources/Visitors/ReferencesVisitors.js' -import type { - NormalButtonModel, - NormalButtonOptions, - NormalButtonSteps, -} from '@companion-app/shared/Model/ButtonModel.js' -import type { - ActionInstance, - ActionOwner, - ActionSetId, - ActionSetsModel, - ActionStepOptions, -} from '@companion-app/shared/Model/ActionModel.js' +import type { NormalButtonModel, NormalButtonOptions } from '@companion-app/shared/Model/ButtonModel.js' +import type { ActionSetId } from '@companion-app/shared/Model/ActionModel.js' import type { DrawStyleButtonModel } from '@companion-app/shared/Model/StyleModel.js' import type { ControlDependencies } from '../../ControlDependencies.js' +import { EntityModelType } from '@companion-app/shared/Model/EntityModel.js' +import type { ControlActionSetAndStepsManager } from '../../Entities/ControlActionSetAndStepsManager.js' /** * Class for the stepped button control. @@ -47,32 +32,23 @@ import type { ControlDependencies } from '../../ControlDependencies.js' */ export class ControlButtonNormal extends ButtonControlBase - implements ControlWithSteps, ControlWithActions, ControlWithoutEvents, ControlWithActionSets + implements ControlWithActions, ControlWithoutEvents, ControlWithActionSets { readonly type = 'button' readonly supportsActions = true - readonly supportsSteps = true readonly supportsEvents = false readonly supportsActionSets = true - /** - * The defaults options for a step - */ - static DefaultStepOptions: ActionStepOptions = { - runWhileHeld: [], // array of set ids - } - - /** - * The id of the currently selected (next to be executed) step - */ - #current_step_id: string = '0' - /** * Button hold state for each surface */ #surfaceHoldState = new Map() + get actionSets(): ControlActionSetAndStepsManager { + return this.entities + } + constructor(deps: ControlDependencies, controlId: string, storage: NormalButtonModel | null, isImport: boolean) { super(deps, controlId, `Controls/Button/Normal/${controlId}`) @@ -81,10 +57,6 @@ export class ControlButtonNormal rotaryActions: false, stepAutoProgress: true, } - this.steps = { - 0: this.#getNewStepValue(null, null), - } - this.#current_step_id = '0' if (!storage) { // New control @@ -96,17 +68,8 @@ export class ControlButtonNormal if (storage.type !== 'button') throw new Error(`Invalid type given to ControlButtonStep: "${storage.type}"`) this.options = Object.assign(this.options, storage.options || {}) - this.feedbacks.baseStyle = Object.assign(this.feedbacks.baseStyle, storage.style || {}) - this.feedbacks.loadStorage(storage.feedbacks || [], isImport, isImport) - - if (storage.steps) { - this.steps = {} - for (const [id, stepObj] of Object.entries(storage.steps)) { - this.steps[id] = this.#getNewStepValue(stepObj.action_sets, stepObj.options) - } - } - - this.#current_step_id = GetStepIds(this.steps)[0] + this.entities.setupRotaryActionSets(!!this.options.rotaryActions, true) + this.entities.loadStorage(storage, true, isImport) // Ensure control is stored before setup if (isImport) setImmediate(() => this.postProcessImport()) @@ -139,277 +102,6 @@ export class ControlButtonNormal } } - /** - * Add an action to this control - */ - actionAdd(stepId: string, setId: ActionSetId, actionItem: ActionInstance, ownerId: ActionOwner | null): boolean { - const step = this.steps[stepId] - if (step) { - return step.actionAdd(setId, actionItem, ownerId) - } else { - return false - } - } - - /** - * Append some actions to this button - * @param stepId - * @param setId the action_set id to update - * @param newActions actions to append - */ - actionAppend(stepId: string, setId: ActionSetId, newActions: ActionInstance[], ownerId: ActionOwner | null): boolean { - const step = this.steps[stepId] - if (step) { - return step.actionAppend(setId, newActions, ownerId) - } else { - return false - } - } - - /** - * Duplicate an action on this control - */ - actionDuplicate(stepId: string, setId: ActionSetId, id: string): string | null { - const step = this.steps[stepId] - if (step) { - return step.actionDuplicate(setId, id) - } else { - return null - } - } - - /** - * Enable or disable an action - */ - actionEnabled(stepId: string, setId: ActionSetId, id: string, enabled: boolean): boolean { - const step = this.steps[stepId] - if (step) { - return step.actionEnabled(setId, id, enabled) - } else { - return false - } - } - - /** - * Set action headline - */ - actionHeadline(stepId: string, setId: ActionSetId, id: string, headline: string): boolean { - const step = this.steps[stepId] - if (step) { - return step.actionHeadline(setId, id, headline) - } else { - return false - } - } - - /** - * Learn the options for an action, by asking the instance for the current values - */ - async actionLearn(stepId: string, setId: ActionSetId, id: string): Promise { - const step = this.steps[stepId] - if (step) { - return step.actionLearn(setId, id) - } else { - return false - } - } - - /** - * Remove an action from this control - */ - actionRemove(stepId: string, setId: ActionSetId, id: string): boolean { - const step = this.steps[stepId] - if (step) { - return step.actionRemove(setId, id) - } else { - return false - } - } - - /** - * Reorder an action in the list or move between sets - */ - actionMoveTo( - dragStepId: string, - dragSetId: ActionSetId, - dragActionId: string, - hoverStepId: string, - hoverSetId: ActionSetId, - hoverOwnerId: ActionOwner | null, - hoverIndex: number - ): boolean { - const oldStep = this.steps[dragStepId] - if (!oldStep) return false - - const oldItem = oldStep.findParentAndIndex(dragSetId, dragActionId) - if (!oldItem) return false - - if ( - dragStepId === hoverStepId && - dragSetId === hoverSetId && - oldItem.parent.ownerId?.parentActionId === hoverOwnerId?.parentActionId && - oldItem.parent.ownerId?.childGroup === hoverOwnerId?.childGroup - ) { - oldItem.parent.moveAction(oldItem.index, hoverIndex) - - this.commitChange(false) - - return true - } else { - const newStep = this.steps[hoverStepId] - if (!newStep) return false - - const newSet = newStep.getActionSet(hoverSetId) - if (!newSet) return false - - const newParent = hoverOwnerId ? newSet?.findById(hoverOwnerId.parentActionId) : null - if (hoverOwnerId && !newParent) return false - - // Ensure the new parent is not a child of the action being moved - if (hoverOwnerId && oldItem.item.findChildById(hoverOwnerId.parentActionId)) return false - - // Check if the new parent can hold the action being moved - if (newParent && !newParent.canAcceptChild(hoverOwnerId!.childGroup, oldItem.item)) return false - - const poppedAction = oldItem.parent.popAction(oldItem.index) - if (!poppedAction) return false - - if (newParent) { - newParent.pushChild(poppedAction, hoverOwnerId!.childGroup, hoverIndex) - } else { - newSet.pushAction(poppedAction, hoverIndex) - } - - this.commitChange(false) - - return true - } - } - - /** - * Remove an action from this control - */ - actionReplace(newProps: Pick, skipNotifyModule = false): boolean { - for (const step of Object.values(this.steps)) { - if (step.actionReplace(newProps, skipNotifyModule)) return true - } - return false - } - - /** - * Replace all the actions in a set - */ - actionReplaceAll(stepId: string, setId: ActionSetId, newActions: ActionInstance[]): boolean { - const step = this.steps[stepId] - if (step) { - return step.actionReplaceAll(setId, newActions) - } else { - return false - } - } - - /** - * Set the connection of an action - */ - actionSetConnection(stepId: string, setId: ActionSetId, id: string, connectionId: string): boolean { - const step = this.steps[stepId] - if (step) { - return step.actionSetConnection(setId, id, connectionId) - } else { - return false - } - } - - /** - * Set an option of an action - */ - actionSetOption(stepId: string, setId: ActionSetId, id: string, key: string, value: any): boolean { - const step = this.steps[stepId] - if (step) { - return step.actionSetOption(setId, id, key, value) - } else { - return false - } - } - - /** - * Add an action set to this control - */ - actionSetAdd(stepId: string): boolean { - const step = this.steps[stepId] - if (step) { - step.actionSetAdd() - - return true - } - - return false - } - - /** - * Remove an action-set from this control - */ - actionSetRemove(stepId: string, setId0: ActionSetId): boolean { - const setId = Number(setId0) - - // Ensure valid - if (isNaN(setId)) return false - - const step = this.steps[stepId] - if (!step) return false - - return step.actionSetRemove(setId) - } - - /** - * Rename an action-sets - */ - actionSetRename(stepId: string, oldSetId0: ActionSetId, newSetId0: ActionSetId): boolean { - const step = this.steps[stepId] - if (step) { - const newSetId = Number(newSetId0) - const oldSetId = Number(oldSetId0) - - // Only valid when both are numbers - if (isNaN(newSetId) || isNaN(oldSetId)) return false - - if (!step.actionSetRename(oldSetId, newSetId)) return false - - this.commitChange(false) - - return true - } - - return false - } - - actionSetRunWhileHeld(stepId: string, setId0: ActionSetId, runWhileHeld: boolean): boolean { - const step = this.steps[stepId] - if (step) { - // Ensure it is a number - const setId = Number(setId0) - - // Only valid when step is a number - if (isNaN(setId)) return false - - // Ensure set exists - if (!step.getActionSet(setId)) return false - - const runWhileHeldIndex = step.options.runWhileHeld.indexOf(setId) - if (runWhileHeld && runWhileHeldIndex === -1) { - step.options.runWhileHeld.push(setId) - } else if (!runWhileHeld && runWhileHeldIndex !== -1) { - step.options.runWhileHeld.splice(runWhileHeldIndex, 1) - } - - this.commitChange(false) - - return true - } - - return false - } - /** * Prepare this control for deletion */ @@ -419,15 +111,6 @@ export class ControlButtonNormal super.destroy() } - /** - * Get the index of the current (next to execute) step - * @returns The index of current step - */ - getActiveStepIndex(): number { - const out = GetStepIds(this.steps).indexOf(this.#current_step_id) - return out !== -1 ? out : 0 - } - /** * Get the complete style object of a button */ @@ -435,63 +118,23 @@ export class ControlButtonNormal const style = super.getDrawStyle() if (!style) return style - if (GetStepIds(this.steps).length > 1) { - style.step_cycle = this.getActiveStepIndex() + 1 + if (this.entities.getStepIds().length > 1) { + style.step_cycle = this.entities.getActiveStepIndex() + 1 } return style } - #getNewStepValue(existingActions: ActionSetsModel | null, existingOptions: ActionStepOptions | null) { - const action_sets: ActionSetsModel = existingActions || { - down: [], - up: [], - rotate_left: undefined, - rotate_right: undefined, - } - - const options = existingOptions || cloneDeep(ControlButtonNormal.DefaultStepOptions) - - action_sets.down = action_sets.down || [] - action_sets.up = action_sets.up || [] - - if (this.options.rotaryActions) { - action_sets.rotate_left = action_sets.rotate_left || [] - action_sets.rotate_right = action_sets.rotate_right || [] - } - - const actions = new FragmentActions( - this.deps.instance.definitions, - this.deps.internalModule, - this.deps.instance.moduleHost, - this.controlId, - this.commitChange.bind(this) - ) - - actions.options = options - actions.loadStorage(action_sets, true, !!existingActions) - - return actions - } - /** * Collect the instance ids and labels referenced by this control * @param foundConnectionIds - instance ids being referenced * @param foundConnectionLabels - instance labels being referenced */ collectReferencedConnections(foundConnectionIds: Set, foundConnectionLabels: Set): void { - const allFeedbacks = this.feedbacks.getAllFeedbacks() - const allActions = [] - - for (const step of Object.values(this.steps)) { - allActions.push(...step.getAllActions()) - } + const allEntities = this.entities.getAllEntities() - for (const feedback of allFeedbacks) { - foundConnectionIds.add(feedback.connectionId) - } - for (const action of allActions) { - foundConnectionIds.add(action.connectionId) + for (const entity of allEntities) { + foundConnectionIds.add(entity.connectionId) } const visitor = new VisitorReferencesCollector(foundConnectionIds, foundConnectionLabels) @@ -499,11 +142,9 @@ export class ControlButtonNormal ReferencesVisitors.visitControlReferences( this.deps.internalModule, visitor, - this.feedbacks.baseStyle, + this.entities.baseStyle, [], - [], - allActions, - allFeedbacks, + allEntities, [] ) } @@ -512,7 +153,7 @@ export class ControlButtonNormal * Inform the control that it has been moved, and anything relying on its location must be invalidated */ triggerLocationHasChanged(): void { - this.feedbacks.resubscribeAllFeedbacks('internal') + this.entities.resubscribeEntities(EntityModelType.Feedback, 'internal') } /** @@ -521,9 +162,7 @@ export class ControlButtonNormal optionsSetField(key: string, value: any): boolean { // Check if rotary_actions should be added/remove if (key === 'rotaryActions') { - for (const step of Object.values(this.steps)) { - step.setupRotaryActionSets(!!value, true) - } + this.entities.setupRotaryActionSets(!!value, true) } return super.optionsSetField(key, value) @@ -536,10 +175,10 @@ export class ControlButtonNormal * @param force Trigger actions even if already in the state */ pressControl(pressed: boolean, surfaceId: string | undefined, force: boolean): void { - const [this_step_id, next_step_id] = this.#validateCurrentStepId() + const [thisStepId, nextStepId] = this.entities.validateCurrentStepIdAndGetNext() let pressedDuration = 0 - let pressedStep = this_step_id + let pressedStep = thisStepId let holdState: SurfaceHoldState | undefined = undefined if (surfaceId) { // Calculate the press duration, or track when the press started @@ -548,7 +187,7 @@ export class ControlButtonNormal holdState = { pressed: Date.now(), - step: this_step_id, + step: thisStepId, timers: [], } this.#surfaceHoldState.set(surfaceId, holdState) @@ -569,47 +208,43 @@ export class ControlButtonNormal if (changed || force) { // progress to the next step, if there is one, and the step hasnt already been changed if ( - this_step_id !== null && - next_step_id !== null && + thisStepId !== null && + nextStepId !== null && this.options.stepAutoProgress && !pressed && - (pressedStep === undefined || this_step_id === pressedStep) + (pressedStep === undefined || thisStepId === pressedStep) ) { // update what the new step will be - this.#current_step_id = next_step_id - - this.sendRuntimePropsChange() + this.entities.stepSelectCurrent(nextStepId) } // Make sure to execute for the step that was active when the press started - const step = pressedStep && this.steps[pressedStep] + const step = pressedStep ? this.entities.getStepActions(pressedStep) : null if (step) { - let action_set_id: ActionSetId = pressed ? 'down' : 'up' + let actionSetId: ActionSetId = pressed ? 'down' : 'up' const location = this.deps.page.getLocationOfControlId(this.controlId) if (!pressed && pressedDuration) { // find the correct set to execute on up - const setIds = step - .getActionSetIds() + const setIds = Object.keys(step) .map((id) => Number(id)) .filter((id) => !isNaN(id) && id < pressedDuration) if (setIds.length) { - action_set_id = Math.max(...setIds) + actionSetId = Math.max(...setIds) } } - const runActionSet = (set_id: ActionSetId): void => { - const actions = step.getActionSet(set_id) - if (actions) { - this.logger.silly('found actions') + const runActionSet = (setId: ActionSetId): void => { + const actions = step.sets.get(setId) + if (!actions) return - this.actionRunner.runActions(actions.asActionInstances(), { - surfaceId, - location, - }) - } + this.logger.silly(`found ${actions.length} actions`) + this.actionRunner.runActions(actions, { + surfaceId, + location, + }) } if (pressed && holdState && holdState.timers.length === 0) { @@ -630,8 +265,8 @@ export class ControlButtonNormal } // Run the actions if it wasn't already run from being held - if (typeof action_set_id !== 'number' || !step.options.runWhileHeld.includes(action_set_id)) { - runActionSet(action_set_id) + if (typeof actionSetId !== 'number' || !step.options.runWhileHeld.includes(actionSetId)) { + runActionSet(actionSetId) } } } @@ -643,214 +278,15 @@ export class ControlButtonNormal * @param surfaceId The surface that initiated this rotate */ rotateControl(direction: boolean, surfaceId: string | undefined): void { - const [this_step_id] = this.#validateCurrentStepId() - - const step = this_step_id && this.steps[this_step_id] - if (step) { - const action_set_id = direction ? 'rotate_right' : 'rotate_left' - - const actions = step.getActionSet(action_set_id) - if (actions) { - this.logger.silly('found actions') - - const location = this.deps.page.getLocationOfControlId(this.controlId) - - this.actionRunner.runActions(actions.asActionInstances(), { - surfaceId, - location, - }) - } - } - } - - /** - * Add a step to this control - * @returns Id of new step - */ - stepAdd(): string { - const existingKeys = GetStepIds(this.steps) - .map((k) => Number(k)) - .filter((k) => !isNaN(k)) - if (existingKeys.length === 0) { - // add the default '0' set - this.steps['0'] = this.#getNewStepValue(null, null) - - this.commitChange(true) - - return '0' - } else { - // add one after the last - const max = Math.max(...existingKeys) - - const stepId = `${max + 1}` - this.steps[stepId] = this.#getNewStepValue(null, null) - - this.commitChange(true) - - return stepId - } - } - - /** - * Progress through the action-sets - * @param amount Number of steps to progress - */ - stepAdvanceDelta(amount: number): boolean { - if (amount && typeof amount === 'number') { - const all_steps = GetStepIds(this.steps) - if (all_steps.length > 0) { - const current = all_steps.indexOf(this.#current_step_id) - - let newIndex = (current === -1 ? 0 : current) + amount - while (newIndex < 0) newIndex += all_steps.length - newIndex = newIndex % all_steps.length - - const newStepId = all_steps[newIndex] - return this.stepSelectCurrent(newStepId) - } - } - - return false - } - - /** - * Duplicate a step on this control - * @param stepId the id of the step to duplicate - */ - stepDuplicate(stepId: string): boolean { - const existingKeys = GetStepIds(this.steps) - .map((k) => Number(k)) - .filter((k) => !isNaN(k)) + const actions = this.entities.getActionsToExecuteForSet(direction ? 'rotate_right' : 'rotate_left') - const stepToCopy = this.steps[stepId] - if (!stepToCopy) return false + const location = this.deps.page.getLocationOfControlId(this.controlId) - const newStep = this.#getNewStepValue(cloneDeep(stepToCopy.asActionStepModel()), cloneDeep(stepToCopy.options)) - - // add one after the last - const max = Math.max(...existingKeys) - - const newStepId = `${max + 1}` - this.steps[newStepId] = newStep - - // Treat it as an import, to make any ids unique - newStep.postProcessImport().catch((e) => { - this.logger.silly(`stepDuplicate failed postProcessImport for ${this.controlId} failed: ${e.message}`) + this.logger.silly(`found ${actions.length} actions`) + this.actionRunner.runActions(actions, { + surfaceId, + location, }) - - // Ensure the ui knows which step is current - this.sendRuntimePropsChange() - - // Save the change, and perform a draw - this.commitChange(true) - - return true - } - - /** - * Set the current (next to execute) action-set by index - * @param index The step index to make the next - */ - stepMakeCurrent(index: number): boolean { - if (typeof index === 'number') { - const stepId = GetStepIds(this.steps)[index - 1] - if (stepId !== undefined) { - return this.stepSelectCurrent(stepId) - } - } - - return false - } - - /** - * Remove an action-set from this control - * @param stepId the id of the action-set - */ - stepRemove(stepId: string): boolean { - const oldKeys = GetStepIds(this.steps) - - if (oldKeys.length > 1) { - if (this.steps[stepId]) { - this.steps[stepId].destroy() - delete this.steps[stepId] - - // Update the current step - const oldIndex = oldKeys.indexOf(stepId) - let newIndex = oldIndex + 1 - if (newIndex >= oldKeys.length) { - newIndex = 0 - } - if (newIndex !== oldIndex) { - this.#current_step_id = oldKeys[newIndex] - - this.sendRuntimePropsChange() - } - - // Save the change, and perform a draw - this.commitChange(true) - - return true - } - } - - return false - } - - /** - * Set the current (next to execute) action-set by id - * @param stepId The step id to make the next - */ - stepSelectCurrent(stepId: string): boolean { - if (this.steps[stepId]) { - // Ensure it isn't currently pressed - // this.setPushed(false) - - this.#current_step_id = stepId - - this.sendRuntimePropsChange() - - this.triggerRedraw() - - return true - } - - return false - } - - /** - * Swap two action-sets - * @param stepId1 One of the action-sets - * @param stepId2 The other action-set - */ - stepSwap(stepId1: string, stepId2: string): boolean { - if (this.steps[stepId1] && this.steps[stepId2]) { - const tmp = this.steps[stepId1] - this.steps[stepId1] = this.steps[stepId2] - this.steps[stepId2] = tmp - - this.commitChange(false) - - return true - } - - return false - } - - /** - * Rename step - * @param stepId the id of the action-set - * @param newName the new name of the step - */ - stepRename(stepId: string, newName: string | undefined): boolean { - if (this.steps[stepId]) { - if (newName !== undefined) { - this.steps[stepId].rename(newName) - } - this.commitChange(true) - return true - } - - return false } /** @@ -859,20 +295,12 @@ export class ControlButtonNormal * @param clone - Whether to return a cloned object */ override toJSON(clone = true): NormalButtonModel { - const stepsJson: NormalButtonSteps = {} - for (const [id, step] of Object.entries(this.steps)) { - stepsJson[id] = { - action_sets: step.asActionStepModel(), - options: step.options, - } - } - const obj: NormalButtonModel = { type: this.type, - style: this.feedbacks.baseStyle, + style: this.entities.baseStyle, options: this.options, - feedbacks: this.feedbacks.getAllFeedbackInstances(), - steps: stepsJson, + feedbacks: this.entities.getFeedbackEntities(), + steps: this.entities.asNormalButtonSteps(), } return clone ? cloneDeep(obj) : obj @@ -883,25 +311,7 @@ export class ControlButtonNormal */ override toRuntimeJSON() { return { - current_step_id: this.#current_step_id, - } - } - - #validateCurrentStepId(): [null, null] | [string, string] { - const this_step_raw = this.#current_step_id - const stepIds = GetStepIds(this.steps) - if (stepIds.length > 0) { - // verify 'this_step_raw' is valid - const this_step_index = stepIds.findIndex((s) => s == this_step_raw) || 0 - const this_step_id = stepIds[this_step_index] - - // figure out the new step - const next_index = this_step_index + 1 >= stepIds.length ? 0 : this_step_index + 1 - const next_step_id = stepIds[next_index] - - return [this_step_id, next_step_id] - } else { - return [null, null] + current_step_id: this.entities.currentStepId, } } } diff --git a/companion/lib/Controls/ControlTypes/PageDown.ts b/companion/lib/Controls/ControlTypes/PageDown.ts index 8950f51bdf..81d8ee02f7 100644 --- a/companion/lib/Controls/ControlTypes/PageDown.ts +++ b/companion/lib/Controls/ControlTypes/PageDown.ts @@ -4,10 +4,8 @@ import type { ControlWithoutActionSets, ControlWithoutActions, ControlWithoutEvents, - ControlWithoutFeedbacks, ControlWithoutOptions, ControlWithoutPushed, - ControlWithoutSteps, ControlWithoutStyle, } from '../IControlFragments.js' import type { DrawStyleModel } from '@companion-app/shared/Model/StyleModel.js' @@ -37,8 +35,6 @@ export class ControlButtonPageDown extends ControlBase implements ControlWithoutActions, - ControlWithoutFeedbacks, - ControlWithoutSteps, ControlWithoutStyle, ControlWithoutEvents, ControlWithoutActionSets, @@ -48,8 +44,7 @@ export class ControlButtonPageDown readonly type = 'pagedown' readonly supportsActions = false - readonly supportsSteps = false - readonly supportsFeedbacks = false + readonly supportsEntities = false readonly supportsStyle = false readonly supportsEvents = false readonly supportsActionSets = false @@ -133,7 +128,4 @@ export class ControlButtonPageDown renameVariables(_labelFrom: string, _labelTo: string) { // Nothing to do } - verifyConnectionIds(_knownConnectionIds: Set) { - // Nothing to do - } } diff --git a/companion/lib/Controls/ControlTypes/PageNumber.ts b/companion/lib/Controls/ControlTypes/PageNumber.ts index d354e3448f..77aee1840b 100644 --- a/companion/lib/Controls/ControlTypes/PageNumber.ts +++ b/companion/lib/Controls/ControlTypes/PageNumber.ts @@ -3,10 +3,8 @@ import type { ControlWithoutActionSets, ControlWithoutActions, ControlWithoutEvents, - ControlWithoutFeedbacks, ControlWithoutOptions, ControlWithoutPushed, - ControlWithoutSteps, ControlWithoutStyle, } from '../IControlFragments.js' import type { DrawStyleModel } from '@companion-app/shared/Model/StyleModel.js' @@ -37,8 +35,6 @@ export class ControlButtonPageNumber extends ControlBase implements ControlWithoutActions, - ControlWithoutFeedbacks, - ControlWithoutSteps, ControlWithoutStyle, ControlWithoutEvents, ControlWithoutActionSets, @@ -48,8 +44,7 @@ export class ControlButtonPageNumber readonly type = 'pagenum' readonly supportsActions = false - readonly supportsSteps = false - readonly supportsFeedbacks = false + readonly supportsEntities = false readonly supportsStyle = false readonly supportsEvents = false readonly supportsActionSets = false @@ -134,7 +129,4 @@ export class ControlButtonPageNumber renameVariables(_labelFrom: string, _labelTo: string) { // Nothing to do } - verifyConnectionIds(_knownConnectionIds: Set) { - // Nothing to do - } } diff --git a/companion/lib/Controls/ControlTypes/PageUp.ts b/companion/lib/Controls/ControlTypes/PageUp.ts index 28225d11cc..f3c75f5b38 100644 --- a/companion/lib/Controls/ControlTypes/PageUp.ts +++ b/companion/lib/Controls/ControlTypes/PageUp.ts @@ -4,10 +4,8 @@ import type { ControlWithoutActionSets, ControlWithoutActions, ControlWithoutEvents, - ControlWithoutFeedbacks, ControlWithoutOptions, ControlWithoutPushed, - ControlWithoutSteps, ControlWithoutStyle, } from '../IControlFragments.js' import type { DrawStyleModel } from '@companion-app/shared/Model/StyleModel.js' @@ -37,8 +35,6 @@ export class ControlButtonPageUp extends ControlBase implements ControlWithoutActions, - ControlWithoutFeedbacks, - ControlWithoutSteps, ControlWithoutStyle, ControlWithoutEvents, ControlWithoutActionSets, @@ -48,8 +44,7 @@ export class ControlButtonPageUp readonly type = 'pageup' readonly supportsActions = false - readonly supportsSteps = false - readonly supportsFeedbacks = false + readonly supportsEntities = false readonly supportsStyle = false readonly supportsEvents = false readonly supportsActionSets = false @@ -133,7 +128,4 @@ export class ControlButtonPageUp renameVariables(_labelFrom: string, _labelTo: string) { // Nothing to do } - verifyConnectionIds(_knownConnectionIds: Set) { - // Nothing to do - } } diff --git a/companion/lib/Controls/ControlTypes/Triggers/Trigger.ts b/companion/lib/Controls/ControlTypes/Triggers/Trigger.ts index 4032e148c6..a786f5ac92 100644 --- a/companion/lib/Controls/ControlTypes/Triggers/Trigger.ts +++ b/companion/lib/Controls/ControlTypes/Triggers/Trigger.ts @@ -1,6 +1,4 @@ import { ControlBase } from '../../ControlBase.js' -import { FragmentActions } from '../../Fragments/FragmentActions.js' -import { FragmentFeedbacks } from '../../Fragments/FragmentFeedbacks.js' import { TriggersListRoom } from '../../Controller.js' import { cloneDeep } from 'lodash-es' import jsonPatch from 'fast-json-patch' @@ -15,19 +13,18 @@ import type { TriggerEvents } from '../../TriggerEvents.js' import type { ControlWithActions, ControlWithEvents, - ControlWithFeedbacks, ControlWithOptions, ControlWithoutActionSets, ControlWithoutPushed, - ControlWithoutSteps, ControlWithoutStyle, } from '../../IControlFragments.js' import { ReferencesVisitors } from '../../../Resources/Visitors/ReferencesVisitors.js' import type { ClientTriggerData, TriggerModel, TriggerOptions } from '@companion-app/shared/Model/TriggerModel.js' import type { EventInstance } from '@companion-app/shared/Model/EventModel.js' -import type { ActionInstance, ActionOwner, ActionSetId } from '@companion-app/shared/Model/ActionModel.js' import type { ControlDependencies } from '../../ControlDependencies.js' import { ControlActionRunner } from '../../ActionRunner.js' +import { ControlEntityListPoolTrigger } from '../../Entities/EntityListPoolTrigger.js' +import { EntityModelType } from '@companion-app/shared/Model/EntityModel.js' import { TriggerExecutionSource } from './TriggerExecutionSource.js' /** @@ -55,8 +52,6 @@ export class ControlTrigger implements ControlWithActions, ControlWithEvents, - ControlWithFeedbacks, - ControlWithoutSteps, ControlWithoutStyle, ControlWithoutActionSets, ControlWithOptions, @@ -66,8 +61,7 @@ export class ControlTrigger readonly supportsActions = true readonly supportsEvents = true - readonly supportsSteps = false - readonly supportsFeedbacks = true + readonly supportsEntities = true readonly supportsStyle = false readonly supportsActionSets = false readonly supportsOptions = true @@ -134,8 +128,7 @@ export class ControlTrigger readonly #actionRunner: ControlActionRunner - readonly actions: FragmentActions - readonly feedbacks: FragmentFeedbacks + readonly entities: ControlEntityListPoolTrigger // TODO - should this be private? /** * @param registry - the application core @@ -155,22 +148,14 @@ export class ControlTrigger this.#actionRunner = new ControlActionRunner(deps.actionRunner, this.controlId, this.triggerRedraw.bind(this)) - this.actions = new FragmentActions( - deps.instance.definitions, - deps.internalModule, - deps.instance.moduleHost, + this.entities = new ControlEntityListPoolTrigger({ controlId, - this.commitChange.bind(this) - ) - this.feedbacks = new FragmentFeedbacks( - deps.instance.definitions, - deps.internalModule, - deps.instance.moduleHost, - controlId, - this.commitChange.bind(this), - this.triggerRedraw.bind(this), - true - ) + commitChange: this.commitChange.bind(this), + triggerRedraw: this.triggerRedraw.bind(this), + instanceDefinitions: deps.instance.definitions, + internalModule: deps.internalModule, + moduleHost: deps.instance.moduleHost, + }) this.#eventBus = eventBus this.#timerEvents = new TriggersEventTimer(eventBus, controlId, this.executeActions.bind(this)) @@ -191,8 +176,7 @@ export class ControlTrigger if (storage.type !== 'trigger') throw new Error(`Invalid type given to ControlTriggerInterval: "${storage.type}"`) this.options = storage.options || this.options - this.actions.loadStorage(storage.action_sets || {}, true, isImport) - this.feedbacks.loadStorage(storage.condition || [], true, isImport) + this.entities.loadStorage(storage, true, isImport) this.events = storage.events || this.events if (isImport) this.postProcessImport() @@ -208,140 +192,11 @@ export class ControlTrigger this.#actionRunner.abortAll() } - /** - * Add an action to this control - */ - actionAdd(_stepId: string, _setId: ActionSetId, actionItem: ActionInstance, ownerId: ActionOwner | null): boolean { - return this.actions.actionAdd(0, actionItem, ownerId) - } - - /** - * Append some actions to this button - */ - actionAppend( - _stepId: string, - _setId: ActionSetId, - newActions: ActionInstance[], - ownerId: ActionOwner | null - ): boolean { - return this.actions.actionAppend(0, newActions, ownerId) - } - - /** - * Learn the options for an action, by asking the instance for the current values - */ - async actionLearn(_stepId: string, _setId: ActionSetId, id: string): Promise { - return this.actions.actionLearn(0, id) - } - - /** - * Enable or disable an action - */ - actionEnabled(_stepId: string, _setId: ActionSetId, id: string, enabled: boolean): boolean { - return this.actions.actionEnabled(0, id, enabled) - } - - /** - * Set action headline - */ - actionHeadline(_stepId: string, _setId: ActionSetId, id: string, headline: string): boolean { - return this.actions.actionHeadline(0, id, headline) - } - - /** - * Remove an action from this control - */ - actionRemove(_stepId: string, _setId: ActionSetId, id: string): boolean { - return this.actions.actionRemove(0, id) - } - - /** - * Duplicate an action on this control - */ - actionDuplicate(_stepId: string, _setId: ActionSetId, id: string): string | null { - return this.actions.actionDuplicate(0, id) - } - - /** - * Remove an action from this control - */ - actionReplace(newProps: Pick, skipNotifyModule = false): boolean { - return this.actions.actionReplace(newProps, skipNotifyModule) - } - - /** - * Replace all the actions in a set - */ - actionReplaceAll(_stepId: string, _setId: ActionSetId, newActions: ActionInstance[]): boolean { - return this.actions.actionReplaceAll(0, newActions) - } - - /** - * Set the connection of an action - */ - actionSetConnection(_stepId: string, _setId: ActionSetId, id: string, connectionId: string): boolean { - return this.actions.actionSetConnection(0, id, connectionId) - } - - /** - * Set an option of an action - */ - actionSetOption(_stepId: string, _setId: ActionSetId, id: string, key: string, value: any): boolean { - return this.actions.actionSetOption(0, id, key, value) - } - - /** - * Reorder an action in the list or move between sets - * @param _dragStepId - * @param _dragSetId the action_set id to remove from - * @param dragActionId the id of the action to move - * @param _dropStepId - * @param _dropSetId the target action_set of the action - * @param dropIndex the target index of the action - */ - actionMoveTo( - _dragStepId: string, - _dragSetId: ActionSetId, - dragActionId: string, - _hoverStepId: string, - _hoverSetId: ActionSetId, - hoverOwnerId: ActionOwner | null, - hoverIndex: number - ): boolean { - const oldItem = this.actions.findParentAndIndex(0, dragActionId) - if (!oldItem) return false - - const set = this.actions.getActionSet(0) - if (!set) return false - - const newParent = hoverOwnerId ? set?.findById(hoverOwnerId.parentActionId) : null - if (hoverOwnerId && !newParent) return false - - // Ensure the new parent is not a child of the action being moved - if (hoverOwnerId && oldItem.item.findChildById(hoverOwnerId.parentActionId)) return false - - // Check if the new parent can hold the action being moved - if (newParent && !newParent.canAcceptChild(hoverOwnerId!.childGroup, oldItem.item)) return false - - const poppedAction = oldItem.parent.popAction(oldItem.index) - if (!poppedAction) return false - - if (newParent) { - newParent.pushChild(poppedAction, hoverOwnerId!.childGroup, hoverIndex) - } else { - set.pushAction(poppedAction, hoverIndex) - } - - this.commitChange(false) - - return true - } - /** * Remove any tracked state for a connection */ clearConnectionState(connectionId: string): void { - this.feedbacks.clearConnectionState(connectionId) + this.entities.clearConnectionState(connectionId) } /** @@ -357,7 +212,7 @@ export class ControlTrigger // Ensure the condition passes when it is not part of the event if (source !== TriggerExecutionSource.ConditionChange) { - const conditionPasses = this.feedbacks.checkValueAsBoolean() + const conditionPasses = this.entities.checkConditionValue() if (!conditionPasses) return } @@ -367,28 +222,17 @@ export class ControlTrigger this.#sendTriggerJsonChange() } - const actions = this.actions.getActionSet(0) - if (actions) { - this.logger.silly('found actions') + const actions = this.entities.getActionEntities() + this.logger.silly('found actions') - this.#actionRunner - .runActions(actions.asActionInstances(), { - surfaceId: this.controlId, - location: undefined, - }) - .catch((e) => { - this.logger.error(`Failed to run actions: ${e.message}`) - }) - } else { - this.logger.warn('No action set found') - } - } - - /** - * Get all the actions on this control - */ - getFlattenedActionInstances(): ActionInstance[] { - return this.actions.getFlattenedActionInstances() + this.#actionRunner + .runActions(actions, { + surfaceId: this.controlId, + location: undefined, + }) + .catch((e) => { + this.logger.error(`Failed to run actions: ${e.message}`) + }) } /** @@ -397,14 +241,10 @@ export class ControlTrigger * @param foundConnectionLabels - instance labels being referenced */ collectReferencedConnections(foundConnectionIds: Set, foundConnectionLabels: Set) { - const allFeedbacks = this.feedbacks.getAllFeedbacks() - const allActions = this.actions.getAllActions() + const allEntities = this.entities.getAllEntities() - for (const feedback of allFeedbacks) { - foundConnectionIds.add(feedback.connectionId) - } - for (const action of allActions) { - foundConnectionIds.add(action.connectionId) + for (const entities of allEntities) { + foundConnectionIds.add(entities.connectionId) } const visitor = new VisitorReferencesCollector(foundConnectionIds, foundConnectionLabels) @@ -414,9 +254,7 @@ export class ControlTrigger visitor, undefined, [], - [], - allActions, - allFeedbacks, + allEntities, this.events ) } @@ -425,7 +263,7 @@ export class ControlTrigger * Inform the control that it has been moved, and anything relying on its location must be invalidated */ triggerLocationHasChanged(): void { - this.feedbacks.resubscribeAllFeedbacks('internal') + this.entities.resubscribeEntities(EntityModelType.Feedback, 'internal') } /** @@ -437,8 +275,8 @@ export class ControlTrigger const obj: TriggerModel = { type: this.type, options: this.options, - action_sets: this.actions.asActionStepModel(), - condition: this.feedbacks.getAllFeedbackInstances(), + actions: this.entities.getActionEntities(), + condition: this.entities.getFeedbackEntities(), events: this.events, } return clone ? cloneDeep(obj) : obj @@ -507,11 +345,10 @@ export class ControlTrigger * Remove any actions and feedbacks referencing a specified connectionId */ forgetConnection(connectionId: string): void { - const changedFeedbacks = this.feedbacks.forgetConnection(connectionId) - const changedActions = this.actions.forgetConnection(connectionId) + const changed = this.entities.forgetConnection(connectionId) - if (changedFeedbacks || changedActions) { - this.commitChange(changedFeedbacks) + if (changed) { + this.commitChange(true) } } @@ -537,8 +374,7 @@ export class ControlTrigger * @access public */ renameVariables(labelFrom: string, labelTo: string): void { - const allFeedbacks = this.feedbacks.getAllFeedbacks() - const allActions = this.actions.getAllActions() + const allEntities = this.entities.getAllEntities() // Fix up references const changed = ReferencesVisitors.fixupControlReferences( @@ -546,9 +382,7 @@ export class ControlTrigger { connectionLabels: { [labelFrom]: labelTo } }, undefined, [], - [], - allActions, - allFeedbacks, + allEntities, this.events, true ) @@ -680,8 +514,7 @@ export class ControlTrigger postProcessImport(): void { const ps = [] - ps.push(this.feedbacks.postProcessImport()) - ps.push(this.actions.postProcessImport()) + ps.push(this.entities.postProcessImport()) Promise.all(ps).catch((e) => { this.logger.silly(`postProcessImport for ${this.controlId} failed: ${e.message}`) @@ -691,19 +524,6 @@ export class ControlTrigger this.sendRuntimePropsChange() } - /** - * Prune all actions/feedbacks referencing unknown instances - * Doesn't do any cleanup, as it is assumed that the instance has not been running - */ - verifyConnectionIds(knownConnectionIds: Set): void { - const changedActions = this.actions.verifyConnectionIds(knownConnectionIds) - const changedFeedbacks = this.feedbacks.verifyConnectionIds(knownConnectionIds) - - if (changedFeedbacks || changedActions) { - this.commitChange(changedFeedbacks) - } - } - /** * Emit a change to the runtime properties of this control. * This is for any properties that the ui may want about this control which are not persisted in toJSON() @@ -747,8 +567,7 @@ export class ControlTrigger this.#eventBus.emit('trigger_enabled', this.controlId, false) - this.actions.destroy() - this.feedbacks.destroy() + this.entities.destroy() super.destroy() @@ -767,7 +586,7 @@ export class ControlTrigger triggerRedraw = debounceFn( () => { try { - const newStatus = this.feedbacks.checkValueAsBoolean() + const newStatus = this.entities.checkConditionValue() const runOnTrue = this.events.some((event) => event.enabled && event.type === 'condition_true') const runOnFalse = this.events.some((event) => event.enabled && event.type === 'condition_false') if ( diff --git a/companion/lib/Controls/Controller.ts b/companion/lib/Controls/Controller.ts index b0503a36a1..452144cda7 100644 --- a/companion/lib/Controls/Controller.ts +++ b/companion/lib/Controls/Controller.ts @@ -24,6 +24,8 @@ import type { ClientSocket } from '../UI/Handler.js' import type { ControlLocation } from '@companion-app/shared/Model/Common.js' import { EventEmitter } from 'events' import type { ControlCommonEvents, ControlDependencies } from './ControlDependencies.js' +import { EntityModelType, SomeEntityModel } from '@companion-app/shared/Model/EntityModel.js' +import { assertNever } from '@companion-app/shared/Util.js' import { TriggerExecutionSource } from './ControlTypes/Triggers/TriggerExecutionSource.js' export const TriggersListRoom = 'triggers:list' @@ -143,7 +145,7 @@ export class ControlsController extends CoreBase { */ clearConnectionState(connectionId: string): void { for (const control of this.#controls.values()) { - if (control.supportsActions || control.supportsFeedbacks) { + if (control.supportsEntities) { control.clearConnectionState(connectionId) } } @@ -324,164 +326,148 @@ export class ControlsController extends CoreBase { } }) - client.onPromise('controls:feedback:add', (controlId, parentId, connectionId, feedbackId) => { - const control = this.getControl(controlId) - if (!control) return false + client.onPromise( + 'controls:entity:add', + (controlId, entityLocation, ownerId, connectionId, entityTypeLabel, entityDefinition) => { + const control = this.getControl(controlId) + if (!control) return false - if (control.supportsFeedbacks) { - const feedbackItem = this.instance.definitions.createFeedbackItem( - connectionId, - feedbackId, - control.feedbacks.isBooleanOnly - ) - if (feedbackItem) { - return control.feedbacks.feedbackAdd(feedbackItem, parentId) - } else { - return false + if (!control.supportsEntities) throw new Error(`Control "${controlId}" does not support entities`) + + let newEntity: SomeEntityModel | null = null + switch (entityTypeLabel) { + case EntityModelType.Action: + newEntity = this.instance.definitions.createActionItem(connectionId, entityDefinition) + break + case EntityModelType.Feedback: + newEntity = this.instance.definitions.createFeedbackItem(connectionId, entityDefinition, false) // TODO booleanOnly? + break + default: + assertNever(entityTypeLabel) + return false } - } else { - throw new Error(`Control "${controlId}" does not support feedbacks`) + + if (!newEntity) return false + + return control.entities.entityAdd(entityLocation, ownerId, newEntity) } - }) + ) - client.onPromise('controls:feedback:learn', async (controlId, id) => { + client.onPromise('controls:entity:learn', async (controlId, entityLocation, id) => { const control = this.getControl(controlId) if (!control) return false - if (control.supportsFeedbacks) { - if (this.#activeLearnRequests.has(id)) throw new Error('Learn is already running') - try { - this.#setIsLearning(id, true) - - control.feedbacks - .feedbackLearn(id) - .catch((e) => { - this.logger.error(`Learn failed: ${e}`) - }) - .then(() => { - this.#setIsLearning(id, false) - }) - - return true - } catch (e) { - this.#setIsLearning(id, false) - throw e - } - } else { - throw new Error(`Control "${controlId}" does not support feedbacks`) + if (!control.supportsEntities) throw new Error(`Control "${controlId}" does not support entities`) + + if (this.#activeLearnRequests.has(id)) throw new Error('Learn is already running') + try { + this.#setIsLearning(id, true) + + control.entities + .entityLearn(entityLocation, id) + .catch((e) => { + this.logger.error(`Learn failed: ${e}`) + }) + .then(() => { + this.#setIsLearning(id, false) + }) + + return true + } catch (e) { + this.#setIsLearning(id, false) + throw e } }) - client.onPromise('controls:feedback:enabled', (controlId, id, enabled) => { + client.onPromise('controls:entity:enabled', (controlId, entityLocation, id, enabled) => { const control = this.getControl(controlId) if (!control) return false - if (control.supportsFeedbacks) { - return control.feedbacks.feedbackEnabled(id, enabled) - } else { - throw new Error(`Control "${controlId}" does not support feedbacks`) - } + if (!control.supportsEntities) throw new Error(`Control "${controlId}" does not support entities`) + + return control.entities.entityEnabled(entityLocation, id, enabled) }) - client.onPromise('controls:feedback:set-headline', (controlId, id, headline) => { + client.onPromise('controls:entity:set-headline', (controlId, entityLocation, id, headline) => { const control = this.getControl(controlId) if (!control) return false - if (control.supportsFeedbacks) { - return control.feedbacks.feedbackHeadline(id, headline) - } else { - throw new Error(`Control "${controlId}" does not support feedbacks`) - } + if (!control.supportsEntities) throw new Error(`Control "${controlId}" does not support entities`) + + return control.entities.entityHeadline(entityLocation, id, headline) }) - client.onPromise('controls:feedback:remove', (controlId, id) => { + client.onPromise('controls:entity:remove', (controlId, entityLocation, id) => { const control = this.getControl(controlId) if (!control) return false - if (control.supportsFeedbacks) { - return control.feedbacks.feedbackRemove(id) - } else { - throw new Error(`Control "${controlId}" does not support feedbacks`) - } + if (!control.supportsEntities) throw new Error(`Control "${controlId}" does not support entities`) + + return control.entities.entityRemove(entityLocation, id) }) - client.onPromise('controls:feedback:duplicate', (controlId, id) => { + client.onPromise('controls:entity:duplicate', (controlId, entityLocation, id) => { const control = this.getControl(controlId) if (!control) return false - if (control.supportsFeedbacks) { - return control.feedbacks.feedbackDuplicate(id) - } else { - throw new Error(`Control "${controlId}" does not support feedbacks`) - } + if (!control.supportsEntities) throw new Error(`Control "${controlId}" does not support entities`) + + return control.entities.entityDuplicate(entityLocation, id) }) - client.onPromise('controls:feedback:set-option', (controlId, id, key, value) => { + client.onPromise('controls:entity:set-option', (controlId, entityLocation, id, key, value) => { const control = this.getControl(controlId) if (!control) return false - if (control.supportsFeedbacks) { - return control.feedbacks.feedbackSetOptions(id, key, value) - } else { - throw new Error(`Control "${controlId}" does not support feedbacks`) - } + if (!control.supportsEntities) throw new Error(`Control "${controlId}" does not support entities`) + + return control.entities.entrySetOptions(entityLocation, id, key, value) }) - client.onPromise('controls:feedback:set-connection', (controlId, feedbackId, connectionId) => { + client.onPromise('controls:entity:set-connection', (controlId, entityLocation, id, connectionId) => { const control = this.getControl(controlId) if (!control) return false - if (control.supportsFeedbacks) { - return control.feedbacks.feedbackSetConnection(feedbackId, connectionId) - } else { - throw new Error( - `Trying to set connection of feedback ${feedbackId} to ${connectionId} but control ${controlId} does not support feedbacks` - ) - } + if (!control.supportsEntities) throw new Error(`Control "${controlId}" does not support entities`) + + return control.entities.entitySetConnection(entityLocation, id, connectionId) }) - client.onPromise('controls:feedback:set-inverted', (controlId, id, isInverted) => { + client.onPromise('controls:entity:set-inverted', (controlId, entityLocation, id, isInverted) => { const control = this.getControl(controlId) if (!control) return false - if (control.supportsFeedbacks) { - return control.feedbacks.feedbackSetInverted(id, isInverted) - } else { - throw new Error(`Control "${controlId}" does not support feedbacks`) - } + if (!control.supportsEntities) throw new Error(`Control "${controlId}" does not support entities`) + + return control.entities.entitySetInverted(entityLocation, id, isInverted) }) - client.onPromise('controls:feedback:move', (controlId, moveFeedbackId, newParentId, newIndex) => { - const control = this.getControl(controlId) - if (!control) return false + client.onPromise( + 'controls:entity:move', + (controlId, moveEntityLocation, moveEntityId, newOwnerId, newEntityLocation, newIndex) => { + const control = this.getControl(controlId) + if (!control) return false - if (moveFeedbackId === newParentId) throw new Error('Cannot move feedback to itself') + if (!control.supportsEntities) throw new Error(`Control "${controlId}" does not support entities`) - if (control.supportsFeedbacks) { - return control.feedbacks.feedbackMoveTo(moveFeedbackId, newParentId, newIndex) - } else { - throw new Error(`Control "${controlId}" does not support feedbacks`) + return control.entities.entityMoveTo(moveEntityLocation, moveEntityId, newOwnerId, newEntityLocation, newIndex) } - }) - client.onPromise('controls:feedback:set-style-selection', (controlId, id, selected) => { + ) + client.onPromise('controls:entity:set-style-selection', (controlId, entityLocation, id, selected) => { const control = this.getControl(controlId) if (!control) return false - if (control.supportsFeedbacks) { - return control.feedbacks.feedbackSetStyleSelection(id, selected) - } else { - throw new Error(`Control "${controlId}" does not support feedbacks`) - } + if (!control.supportsEntities) throw new Error(`Control "${controlId}" does not support entities`) + + return control.entities.entitySetStyleSelection(entityLocation, id, selected) }) - client.onPromise('controls:feedback:set-style-value', (controlId, id, key, value) => { + client.onPromise('controls:entity:set-style-value', (controlId, entityLocation, id, key, value) => { const control = this.getControl(controlId) if (!control) return false - if (control.supportsFeedbacks) { - return control.feedbacks.feedbackSetStyleValue(id, key, value) - } else { - throw new Error(`Control "${controlId}" does not support feedbacks`) - } + if (!control.supportsEntities) throw new Error(`Control "${controlId}" does not support entities`) + + return control.entities.entitySetStyleValue(entityLocation, id, key, value) }) client.onPromise('controls:hot-press', (location, direction, surfaceId) => { @@ -503,142 +489,12 @@ export class ControlsController extends CoreBase { this.rotateControl(controlId, direction, surfaceId ? `hot:${surfaceId}` : undefined) }) - client.onPromise('controls:action:add', (controlId, stepId, setId, parentId, connectionId, actionId) => { - const control = this.getControl(controlId) - if (!control) return false - - if (control.supportsActions) { - const actionItem = this.instance.definitions.createActionItem(connectionId, actionId) - if (actionItem) { - return control.actionAdd(stepId, setId, actionItem, parentId) - } else { - return false - } - } else { - throw new Error(`Control "${controlId}" does not support actions`) - } - }) - - client.onPromise('controls:action:learn', async (controlId, stepId, setId, id) => { - const control = this.getControl(controlId) - if (!control) return false - - if (control.supportsActions) { - if (this.#activeLearnRequests.has(id)) throw new Error('Learn is already running') - try { - this.#setIsLearning(id, true) - - control - .actionLearn(stepId, setId, id) - .catch((e) => { - this.logger.error(`Learn failed: ${e}`) - }) - .then(() => { - this.#setIsLearning(id, false) - }) - - return true - } catch (e) { - this.#setIsLearning(id, false) - throw e - } - } else { - throw new Error(`Control "${controlId}" does not support actions`) - } - }) - - client.onPromise('controls:action:enabled', (controlId, stepId, setId, id, enabled) => { - const control = this.getControl(controlId) - if (!control) return false - - if (control.supportsActions) { - return control.actionEnabled(stepId, setId, id, enabled) - } else { - throw new Error(`Control "${controlId}" does not support actions`) - } - }) - - client.onPromise('controls:action:set-headline', (controlId, stepId, setId, id, headline) => { - const control = this.getControl(controlId) - if (!control) return false - - if (control.supportsActions) { - return control.actionHeadline(stepId, setId, id, headline) - } else { - throw new Error(`Control "${controlId}" does not support actions`) - } - }) - - client.onPromise('controls:action:remove', (controlId, stepId, setId, id) => { - const control = this.getControl(controlId) - if (!control) return false - - if (control.supportsActions) { - return control.actionRemove(stepId, setId, id) - } else { - throw new Error(`Control "${controlId}" does not support actions`) - } - }) - - client.onPromise('controls:action:duplicate', (controlId, stepId, setId, id) => { - const control = this.getControl(controlId) - if (!control) return null - - if (control.supportsActions) { - return control.actionDuplicate(stepId, setId, id) - } else { - throw new Error(`Control "${controlId}" does not support actions`) - } - }) - - client.onPromise('controls:action:set-connection', (controlId, stepId, setId, id, connectionId) => { - const control = this.getControl(controlId) - if (!control) return false - - if (control.supportsActions) { - return control.actionSetConnection(stepId, setId, id, connectionId) - } else { - throw new Error(`Control "${controlId}" does not support actions`) - } - }) - - client.onPromise('controls:action:set-option', (controlId, stepId, setId, id, key, value) => { - const control = this.getControl(controlId) - if (!control) return false - - if (control.supportsActions) { - return control.actionSetOption(stepId, setId, id, key, value) - } else { - throw new Error(`Control "${controlId}" does not support actions`) - } - }) - client.onPromise( - 'controls:action:move', - (controlId, dragStepId, dragSetId, dragActionId, hoverStepId, hoverSetId, hoverParentId, hoverIndex) => { - const control = this.getControl(controlId) - if (!control) return false - - if (control.supportsActions) { - return control.actionMoveTo( - dragStepId, - dragSetId, - dragActionId, - hoverStepId, - hoverSetId, - hoverParentId, - hoverIndex - ) - } else { - throw new Error(`Control "${controlId}" does not support actions`) - } - } - ) client.onPromise('controls:action-set:add', (controlId, stepId) => { const control = this.getControl(controlId) if (!control) return false if (control.supportsActionSets) { - return control.actionSetAdd(stepId) + return control.actionSets.actionSetAdd(stepId) } else { throw new Error(`Control "${controlId}" does not support this operation`) } @@ -648,7 +504,7 @@ export class ControlsController extends CoreBase { if (!control) return false if (control.supportsActionSets) { - return control.actionSetRemove(stepId, setId) + return control.actionSets.actionSetRemove(stepId, setId) } else { throw new Error(`Control "${controlId}" does not support this operation`) } @@ -659,7 +515,7 @@ export class ControlsController extends CoreBase { if (!control) return false if (control.supportsActionSets) { - return control.actionSetRename(stepId, oldSetId, newSetId) + return control.actionSets.actionSetRename(stepId, oldSetId, newSetId) } else { throw new Error(`Control "${controlId}" does not support this operation`) } @@ -670,7 +526,7 @@ export class ControlsController extends CoreBase { if (!control) return false if (control.supportsActionSets) { - return control.actionSetRunWhileHeld(stepId, setId, runWhileHeld) + return control.actionSets.actionSetRunWhileHeld(stepId, setId, runWhileHeld) } else { throw new Error(`Control "${controlId}" does not support this operation`) } @@ -680,8 +536,8 @@ export class ControlsController extends CoreBase { const control = this.getControl(controlId) if (!control) return false - if (control.supportsSteps) { - return control.stepAdd() + if (control.supportsActionSets) { + return control.actionSets.stepAdd() } else { throw new Error(`Control "${controlId}" does not support steps`) } @@ -690,8 +546,8 @@ export class ControlsController extends CoreBase { const control = this.getControl(controlId) if (!control) return false - if (control.supportsSteps) { - return control.stepDuplicate(stepId) + if (control.supportsActionSets) { + return control.actionSets.stepDuplicate(stepId) } else { throw new Error(`Control "${controlId}" does not support steps`) } @@ -700,8 +556,8 @@ export class ControlsController extends CoreBase { const control = this.getControl(controlId) if (!control) return false - if (control.supportsSteps) { - return control.stepRemove(stepId) + if (control.supportsActionSets) { + return control.actionSets.stepRemove(stepId) } else { throw new Error(`Control "${controlId}" does not support steps`) } @@ -711,8 +567,8 @@ export class ControlsController extends CoreBase { const control = this.getControl(controlId) if (!control) return false - if (control.supportsSteps) { - return control.stepSwap(stepId1, stepId2) + if (control.supportsActionSets) { + return control.actionSets.stepSwap(stepId1, stepId2) } else { throw new Error(`Control "${controlId}" does not support steps`) } @@ -722,8 +578,8 @@ export class ControlsController extends CoreBase { const control = this.getControl(controlId) if (!control) return false - if (control.supportsSteps) { - return control.stepSelectCurrent(stepId) + if (control.supportsActionSets) { + return control.actionSets.stepSelectCurrent(stepId) } else { throw new Error(`Control "${controlId}" does not support steps`) } @@ -733,8 +589,8 @@ export class ControlsController extends CoreBase { const control = this.getControl(controlId) if (!control) return false - if (control.supportsSteps) { - return control.stepRename(stepId, newName) + if (control.supportsActionSets) { + return control.actionSets.stepRename(stepId, newName) } else { throw new Error(`Control "${controlId}" does not support steps`) } @@ -994,7 +850,7 @@ export class ControlsController extends CoreBase { */ forgetConnection(connectionId: string): void { for (const control of this.#controls.values()) { - if (control.supportsActions || control.supportsFeedbacks) { + if (control.supportsEntities) { control.forgetConnection(connectionId) } } @@ -1255,8 +1111,8 @@ export class ControlsController extends CoreBase { // Pass values to controls for (const [controlId, newValues] of Object.entries(values)) { const control = this.getControl(controlId) - if (control && control.supportsFeedbacks) { - control.feedbacks.updateFeedbackValues(connectionId, newValues) + if (control && control.supportsEntities) { + control.entities.updateFeedbackValues(connectionId, newValues) } } } @@ -1290,7 +1146,8 @@ export class ControlsController extends CoreBase { knownConnectionIds.add('internal') for (const control of this.#controls.values()) { - control.verifyConnectionIds(knownConnectionIds) + if (!control.supportsEntities) continue + control.entities.verifyConnectionIds(knownConnectionIds) } } } diff --git a/companion/lib/Controls/Entities/ControlActionSetAndStepsManager.ts b/companion/lib/Controls/Entities/ControlActionSetAndStepsManager.ts new file mode 100644 index 0000000000..b111090d50 --- /dev/null +++ b/companion/lib/Controls/Entities/ControlActionSetAndStepsManager.ts @@ -0,0 +1,79 @@ +import type { ActionSetId } from '@companion-app/shared/Model/ActionModel.js' + +export interface ControlActionSetAndStepsManager { + /** + * Add an action set to this control + */ + actionSetAdd(stepId: string): boolean + + /** + * Remove an action-set from this control + */ + actionSetRemove(stepId: string, setId: ActionSetId): boolean + + /** + * Rename an action-sets + */ + actionSetRename(stepId: string, oldSetId: ActionSetId, newSetId: ActionSetId): boolean + + /** + * Set whether an action-set should run while the button is held + */ + actionSetRunWhileHeld(stepId: string, setId: ActionSetId, runWhileHeld: boolean): boolean + + /** + * Get the index of the current (next to execute) step + * @returns The index of current step + */ + getActiveStepIndex(): number + + /** + * Add a step to this control + * @returns Id of new step + */ + stepAdd(): string + + /** + * Progress through the action-sets + * @param amount Number of steps to progress + */ + stepAdvanceDelta(amount: number): boolean + + /** + * Duplicate a step on this control + * @param stepId the id of the step to duplicate + */ + stepDuplicate(stepId: string): boolean + + /** + * Set the current (next to execute) action-set by index + * @param index The step index to make the next + */ + stepMakeCurrent(index: number): boolean + + /** + * Remove an action-set from this control + * @param stepId the id of the action-set + */ + stepRemove(stepId: string): boolean + + /** + * Set the current (next to execute) action-set by id + * @param stepId The step id to make the next + */ + stepSelectCurrent(stepId: string): boolean + + /** + * Swap two action-sets + * @param stepId1 One of the action-sets + * @param stepId2 The other action-set + */ + stepSwap(stepId1: string, stepId2: string): boolean + + /** + * Rename step + * @param stepId the id of the action-set + * @param newName The new name of the step + */ + stepRename(stepId: string, newName: string): boolean +} diff --git a/companion/lib/Controls/Entities/EntityInstance.ts b/companion/lib/Controls/Entities/EntityInstance.ts new file mode 100644 index 0000000000..24eeb4f7e7 --- /dev/null +++ b/companion/lib/Controls/Entities/EntityInstance.ts @@ -0,0 +1,720 @@ +import LogController, { Logger } from '../../Log/Controller.js' +import { + EntityModelType, + EntitySupportedChildGroupDefinition, + FeedbackEntityModel, + SomeEntityModel, + SomeReplaceableEntityModel, +} from '@companion-app/shared/Model/EntityModel.js' +import { cloneDeep, isEqual } from 'lodash-es' +import { nanoid } from 'nanoid' +import { ControlEntityList } from './EntityList.js' +import type { FeedbackStyleBuilder } from './FeedbackStyleBuilder.js' +import type { ButtonStyleProperties } from '@companion-app/shared/Model/StyleModel.js' +import type { CompanionButtonStyleProps } from '@companion-module/base' +import type { InternalVisitor } from '../../Internal/Types.js' +import { visitEntityModel } from '../../Resources/Visitors/EntityInstanceVisitor.js' +import type { ClientEntityDefinition } from '@companion-app/shared/Model/EntityDefinitionModel.js' +import type { InstanceDefinitionsForEntity, InternalControllerForEntity, ModuleHostForEntity } from './Types.js' + +export class ControlEntityInstance { + /** + * The logger + */ + readonly #logger: Logger + + readonly #instanceDefinitions: InstanceDefinitionsForEntity + readonly #internalModule: InternalControllerForEntity + readonly #moduleHost: ModuleHostForEntity + + /** + * Id of the control this belongs to + */ + readonly #controlId: string + + readonly #data: Omit + + /** + * Value of the feedback when it was last executed + */ + #cachedFeedbackValue: any = undefined + + #children = new Map() + + /** + * Get the id of this action instance + */ + get id(): string { + return this.#data.id + } + + get disabled(): boolean { + return !!this.#data.disabled + } + + get definitionId(): string { + return this.#data.definitionId + } + + get type(): EntityModelType { + return this.#data.type + } + + /** + * Get the id of the connection this action belongs to + */ + get connectionId(): string { + return this.#data.connectionId + } + + /** + * Get a reference to the options for this action + * Note: This must not be a copy, but the raw object + */ + get rawOptions() { + return this.#data.options + } + + get feedbackValue(): any { + return this.#cachedFeedbackValue + } + + /** + * @param instanceDefinitions + * @param internalModule + * @param moduleHost + * @param controlId - id of the control + * @param data + * @param isCloned Whether this is a cloned instance and should generate new ids + */ + constructor( + instanceDefinitions: InstanceDefinitionsForEntity, + internalModule: InternalControllerForEntity, + moduleHost: ModuleHostForEntity, + controlId: string, + data: SomeEntityModel, + isCloned: boolean + ) { + this.#logger = LogController.createLogger(`Controls/Fragments/EntityInstance/${controlId}/${data.id}`) + + this.#instanceDefinitions = instanceDefinitions + this.#internalModule = internalModule + this.#moduleHost = moduleHost + this.#controlId = controlId + + { + const newData = cloneDeep(data) + delete newData.children + if (!newData.options) newData.options = {} + this.#data = newData + } + + if (isCloned) { + this.#data.id = nanoid() + } + + if (data.connectionId === 'internal') { + const supportedChildGroups = this.getSupportedChildGroupDefinitions() + for (const groupDefinition of supportedChildGroups) { + try { + const childGroup = this.#getOrCreateChildGroupFromDefinition(groupDefinition) + childGroup.loadStorage(data.children?.[groupDefinition.groupId] ?? [], true, isCloned) + } catch (e: any) { + this.#logger.error(`Error loading child entity group: ${e.message}`) + } + } + } + } + + #getOrCreateChildGroupFromDefinition(listDefinition: EntitySupportedChildGroupDefinition): ControlEntityList { + const existing = this.#children.get(listDefinition.groupId) + if (existing) return existing + + const childGroup = new ControlEntityList( + this.#instanceDefinitions, + this.#internalModule, + this.#moduleHost, + this.#controlId, + { parentId: this.id, childGroup: listDefinition.groupId }, + listDefinition + ) + this.#children.set(listDefinition.groupId, childGroup) + + return childGroup + } + + #getOrCreateChildGroup(groupId: string): ControlEntityList { + const existing = this.#children.get(groupId) + if (existing) return existing + + // Check what names are allowed + const supportedChildGroups = this.getSupportedChildGroupDefinitions() + const listDefinition = supportedChildGroups.find((g) => g.groupId === groupId) + if (!listDefinition) throw new Error('Entity cannot accept children in this group.') + + return this.#getOrCreateChildGroupFromDefinition(listDefinition) + } + + getSupportedChildGroupDefinitions(): EntitySupportedChildGroupDefinition[] { + if (this.connectionId !== 'internal') return [] + + const entityDefinition = this.#instanceDefinitions.getEntityDefinition( + this.#data.type, + this.#data.connectionId, + this.#data.definitionId + ) + return entityDefinition?.supportsChildGroups ?? [] + } + + /** + * Inform the instance of a removed/disabled entity + */ + cleanup() { + // Inform relevant module + if (this.#data.connectionId === 'internal') { + this.#internalModule.entityDelete(this.asEntityModel()) + } else { + this.#moduleHost.connectionEntityDelete(this.asEntityModel(), this.#controlId).catch((e) => { + this.#logger.silly(`entityDelete to connection "${this.connectionId}" failed: ${e.message} ${e.stack}`) + }) + } + + // Remove from cached feedback values + this.#cachedFeedbackValue = undefined + + for (const childGroup of this.#children.values()) { + childGroup.cleanup() + } + } + + /** + * Inform the instance of an updated entity + * @param recursive whether to call recursively + * @param onlyType If set, only re-subscribe entities of this type + * @param onlyConnectionId If set, only re-subscribe entities for this connection + */ + subscribe(recursive: boolean, onlyType?: EntityModelType, onlyConnectionId?: string): void { + if (this.#data.disabled) return + + if ( + (!onlyConnectionId || this.#data.connectionId === onlyConnectionId) && + (!onlyType || this.#data.type === onlyType) + ) { + if (this.#data.connectionId === 'internal') { + this.#internalModule.entityUpdate(this.asEntityModel(), this.#controlId) + } else { + this.#moduleHost.connectionEntityUpdate(this.asEntityModel(), this.#controlId).catch((e) => { + this.#logger.silly(`entityUpdate to connection "${this.connectionId}" failed: ${e.message} ${e.stack}`) + }) + } + } + + if (recursive) { + for (const childGroup of this.#children.values()) { + childGroup.subscribe(recursive, onlyType, onlyConnectionId) + } + } + } + + /** + * Set whether this entity is enabled + */ + setEnabled(enabled: boolean): void { + this.#data.disabled = !enabled + + // Remove from cached feedback values + this.#cachedFeedbackValue = undefined + + // Inform relevant module + if (!this.#data.disabled) { + this.subscribe(true) + } else { + this.cleanup() + } + } + + /** + * Set the headline for this entity + */ + setHeadline(headline: string): void { + this.#data.headline = headline + + // Don't need to resubscribe + } + + /** + * Set the connection of this entity + */ + setConnectionId(connectionId: string | number): void { + // TODO - why can this be a number? + connectionId = String(connectionId) + + // first unsubscribe action from old connection + this.cleanup() + // next change connectionId + this.#data.connectionId = connectionId + // last subscribe to new connection + this.subscribe(false) + } + + /** + * Set whether this feedback is inverted + */ + setInverted(isInverted: boolean): void { + if (this.#data.type !== EntityModelType.Feedback) return + + const thisData = this.#data as FeedbackEntityModel + + // TODO - verify this is a boolean feedback + + thisData.isInverted = isInverted + + // Don't need to resubscribe + // Don't need to clear cached value + } + + /** + * Set the options for this entity + */ + setOptions(options: Record): void { + this.#data.options = options + + // Remove from cached feedback values + this.#cachedFeedbackValue = undefined + + // Inform relevant module + this.subscribe(false) + } + + /** + * Learn the options for an entity, by asking the connection for the current values + */ + async learnOptions(): Promise { + const newOptions = await this.#moduleHost.connectionEntityLearnOptions(this.asEntityModel(), this.#controlId) + if (newOptions) { + this.setOptions(newOptions) + + return true + } + + return false + } + + /** + * Set an option for this entity + */ + setOption(key: string, value: any): void { + this.#data.options[key] = value + + // Remove from cached feedback values + this.#cachedFeedbackValue = undefined + + // Inform relevant module + this.subscribe(false) + } + + getEntityDefinition(): ClientEntityDefinition | undefined { + return this.#instanceDefinitions.getEntityDefinition( + this.#data.type, + this.#data.connectionId, + this.#data.definitionId + ) + } + + /** + * Update an style property for a boolean feedback + * @param key the key/name of the property + * @param value the new value + * @returns success + */ + setStyleValue(key: string, value: any): boolean { + if (this.#data.type !== EntityModelType.Feedback) return false + + const feedbackData = this.#data as FeedbackEntityModel + + if (key === 'png64' && value !== null) { + if (!value.match(/data:.*?image\/png/)) { + return false + } + + value = value.replace(/^.*base64,/, '') + } + + const definition = this.getEntityDefinition() + if (!definition || definition.entityType !== EntityModelType.Feedback || definition.feedbackType !== 'boolean') + return false + + if (!feedbackData.style) feedbackData.style = {} + feedbackData.style[key as keyof ButtonStyleProperties] = value + + return true + } + + /** + * Update the selected style properties for a boolean feedback + * @param selected the properties to be selected + * @param baseStyle Style of the button without feedbacks applied + * @returns success + * @access public + */ + setStyleSelection(selected: string[], baseStyle: ButtonStyleProperties): boolean { + if (this.#data.type !== EntityModelType.Feedback) return false + + const feedbackData = this.#data as FeedbackEntityModel + + const definition = this.getEntityDefinition() + if (!definition || definition.entityType !== EntityModelType.Feedback || definition.feedbackType !== 'boolean') + return false + + const defaultStyle: Partial = definition.feedbackStyle || {} + const oldStyle: Record = feedbackData.style || {} + const newStyle: Record = {} + + for (const key0 of selected) { + const key = key0 as keyof ButtonStyleProperties + if (key in oldStyle) { + // preserve existing value + newStyle[key] = oldStyle[key] + } else { + // copy button value as a default + newStyle[key] = defaultStyle[key] !== undefined ? defaultStyle[key] : baseStyle[key] + + // png needs to be set to something harmless + if (key === 'png64' && !newStyle[key]) { + newStyle[key] = null + } + } + + if (key === 'text') { + // also preserve textExpression + newStyle['textExpression'] = + oldStyle['textExpression'] ?? + /*defaultStyle['textExpression'] !== undefined + ? defaultStyle['textExpression'] + : */ baseStyle['textExpression'] + } + } + feedbackData.style = newStyle + + return true + } + + /** + * Find a child entity by id + */ + findChildById(id: string): ControlEntityInstance | undefined { + for (const childGroup of this.#children.values()) { + const result = childGroup.findById(id) + if (result) return result + } + return undefined + } + + /** + * Find the index of a child action, and the parent list + */ + findParentAndIndex( + id: string + ): { parent: ControlEntityList; index: number; item: ControlEntityInstance } | undefined { + for (const childGroup of this.#children.values()) { + const result = childGroup.findParentAndIndex(id) + if (result) return result + } + return undefined + } + + /** + * Add a child entity to this entity + */ + addChild(groupId: string, entityModel: SomeEntityModel): ControlEntityInstance { + const childGroup = this.#getOrCreateChildGroup(groupId) + return childGroup.addEntity(entityModel) + } + + /** + * Remove a child entity + */ + removeChild(id: string): boolean { + for (const childGroup of this.#children.values()) { + if (childGroup.removeEntity(id)) return true + } + return false + } + + /** + * Duplicate a child entity + */ + duplicateChild(id: string): ControlEntityInstance | undefined { + for (const childGroup of this.#children.values()) { + const newAction = childGroup.duplicateEntity(id) + if (newAction) return newAction + } + return undefined + } + + // // /** + // // * Reorder a action in the list + // // */ + // // moveChild(groupId: string, oldIndex: number, newIndex: number): void { + // // const actionGroup = this.#children.get(groupId) + // // if (!actionGroup) return + + // // return actionGroup.moveAction(oldIndex, newIndex) + // // } + + // // /** + // // * Pop a child action from the list + // // * Note: this is used when moving a action to a different parent. Lifecycle is not managed + // // */ + // // popChild(index: number): FragmentActionInstance | undefined { + // // return this.#children.popAction(index) + // // } + + /** + * Push a child entity to the list + * Note: this is used when moving an entity from a different parent. Lifecycle is not managed + */ + pushChild(entity: ControlEntityInstance, groupId: string, index: number): void { + const actionGroup = this.#getOrCreateChildGroup(groupId) + return actionGroup.pushEntity(entity, index) + } + + /** + * Check if this list can accept a provided entity + */ + canAcceptChild(groupId: string, entity: ControlEntityInstance): boolean { + const childGroup = this.#getOrCreateChildGroup(groupId) + return childGroup.canAcceptEntity(entity) + } + + /** + * Recursively get all the child entities + */ + getAllChildren(): ControlEntityInstance[] { + if (this.connectionId !== 'internal') return [] + + const entities: ControlEntityInstance[] = [] + + for (const childGroup of this.#children.values()) { + entities.push(...childGroup.getAllEntities()) + } + + return entities + } + + /** + * Cleanup and forget any children belonging to the given connection + */ + forgetChildrenForConnection(connectionId: string): boolean { + let changed = false + for (const childGroup of this.#children.values()) { + if (childGroup.forgetForConnection(connectionId)) { + changed = true + } + } + return changed + } + + /** + * Prune all entities referencing unknown conncetions + * Doesn't do any cleanup, as it is assumed that the connection has not been running + */ + verifyChildConnectionIds(knownConnectionIds: Set): boolean { + let changed = false + for (const childGroup of this.#children.values()) { + if (childGroup.verifyConnectionIds(knownConnectionIds)) { + changed = true + } + } + return changed + } + + /** + * If this control was imported to a running system, do some data cleanup/validation + */ + postProcessImport(): Promise[] { + const ps: Promise[] = [] + + if (this.#data.connectionId === 'internal') { + const newProps = this.#internalModule.entityUpgrade(this.asEntityModel(), this.#controlId) + if (newProps) { + this.replaceProps(newProps, false) + } + setImmediate(() => { + this.#internalModule.entityUpdate(this.asEntityModel(), this.#controlId) + }) + } else { + ps.push(this.#moduleHost.connectionEntityUpdate(this.asEntityModel(), this.#controlId)) + } + + for (const childGroup of this.#children.values()) { + ps.push(...childGroup.postProcessImport()) + } + + return ps + } + + /** + * Replace portions of the action with an updated version + */ + replaceProps(newProps: SomeReplaceableEntityModel, skipNotifyModule = false): void { + this.#data.definitionId = newProps.definitionId + this.#data.options = newProps.options + + if (this.#data.type === EntityModelType.Feedback) { + const feedbackData = this.#data as FeedbackEntityModel + const newPropsData = newProps as FeedbackEntityModel + feedbackData.isInverted = !!newPropsData.isInverted + feedbackData.style = Object.keys(feedbackData.style || {}).length > 0 ? feedbackData.style : newPropsData.style + } + + delete this.#data.upgradeIndex + + if (!skipNotifyModule) { + this.subscribe(false) + } + } + + /** + * Visit any references in the current action + */ + visitReferences(visitor: InternalVisitor): void { + visitEntityModel(visitor, this.#data) + } + + asEntityModel(deep = true): SomeEntityModel { + const data: SomeEntityModel = { ...this.#data } + + if (deep && this.connectionId === 'internal') { + data.children = {} + + for (const [groupId, childGroup] of this.#children) { + data.children[groupId] = childGroup.getDirectEntities().map((ent) => ent.asEntityModel(true)) + } + } + + return data + } + + /** + * Clear cached values for any feedback belonging to the given connection + * @returns Whether a value was changed + */ + clearCachedValueForConnectionId(connectionId: string): boolean { + let changed = false + + if (this.#data.connectionId === connectionId) { + this.#cachedFeedbackValue = undefined + + changed = true + } + + for (const childGroup of this.#children.values()) { + if (childGroup.clearCachedValueForConnectionId(connectionId)) { + changed = true + } + } + + return changed + } + + /** + * Get the value of this feedback as a boolean + */ + getBooleanFeedbackValue(): boolean { + if (this.#data.disabled) return false + + if (this.#data.type !== EntityModelType.Feedback) return false + + const definition = this.getEntityDefinition() + + // Special case to handle the internal 'logic' operators, which need to be executed live + if (this.connectionId === 'internal' && this.#data.definitionId.startsWith('logic_')) { + // Future: This could probably be made a bit more generic by checking `definition.supportsChildFeedbacks` + const childValues = this.#children.get('children')?.getChildBooleanFeedbackValues() ?? [] + + return this.#internalModule.executeLogicFeedback(this.asEntityModel() as FeedbackEntityModel, childValues) + } + + if (!definition || definition.entityType !== EntityModelType.Feedback || definition.feedbackType !== 'boolean') + return false + + if (typeof this.#cachedFeedbackValue === 'boolean') { + const feedbackData = this.#data as FeedbackEntityModel + if (definition.showInvert && feedbackData.isInverted) return !this.#cachedFeedbackValue + + return this.#cachedFeedbackValue + } else { + // An invalid value is falsey, it probably means that the feedback has no value + return false + } + } + + /** + * Apply the unparsed style for the feedbacks + * Note: Does not clone the style + */ + buildFeedbackStyle(styleBuilder: FeedbackStyleBuilder): void { + if (this.disabled) return + + const feedback = this.#data as FeedbackEntityModel + if (feedback.type !== EntityModelType.Feedback) return + + const definition = this.getEntityDefinition() + if (!definition || definition.entityType !== EntityModelType.Feedback) return + + if (definition.feedbackType === 'boolean') { + if (this.getBooleanFeedbackValue()) styleBuilder.applySimpleStyle(feedback.style) + } else if (definition.feedbackType === 'advanced') { + // Special case to handle the internal 'logic' operators, which need to be done differently + if (this.connectionId === 'internal' && this.definitionId === 'logic_conditionalise_advanced') { + if (this.getBooleanFeedbackValue()) { + for (const child of this.#children.get('feedbacks')?.getDirectEntities() || []) { + child.buildFeedbackStyle(styleBuilder) + } + } + } else { + styleBuilder.applyComplexStyle(this.#cachedFeedbackValue) + } + } + } + + /** + * Update the feedbacks on the button with new values + * @param connectionId The instance the feedbacks are for + * @param newValues The new feedback values + */ + updateFeedbackValues(connectionId: string, newValues: Record): boolean { + let changed = false + + if ( + this.type === EntityModelType.Feedback && + this.#data.connectionId === connectionId && + this.#data.id in newValues + ) { + const newValue = newValues[this.#data.id] + if (!isEqual(newValue, this.#cachedFeedbackValue)) { + this.#cachedFeedbackValue = newValue + changed = true + } + } + + for (const childGroup of this.#children.values()) { + if (childGroup.updateFeedbackValues(connectionId, newValues)) changed = true + } + + return changed + } + /** + * Get all the connection ids that are enabled + */ + getAllEnabledConnectionIds(connectionIds: Set): void { + if (this.disabled) return + + connectionIds.add(this.connectionId) + + for (const childGroup of this.#children.values()) { + childGroup.getAllEnabledConnectionIds(connectionIds) + } + } +} diff --git a/companion/lib/Controls/Entities/EntityList.ts b/companion/lib/Controls/Entities/EntityList.ts new file mode 100644 index 0000000000..c37e8f9618 --- /dev/null +++ b/companion/lib/Controls/Entities/EntityList.ts @@ -0,0 +1,402 @@ +import { + EntityModelType, + EntityOwner, + EntitySupportedChildGroupDefinition, + SomeEntityModel, +} from '@companion-app/shared/Model/EntityModel.js' +import { ControlEntityInstance } from './EntityInstance.js' +import type { FeedbackStyleBuilder } from './FeedbackStyleBuilder.js' +import { clamp } from '../../Resources/Util.js' +import type { InstanceDefinitionsForEntity, InternalControllerForEntity, ModuleHostForEntity } from './Types.js' + +export type ControlEntityListDefinition = Pick + +export class ControlEntityList { + readonly #instanceDefinitions: InstanceDefinitionsForEntity + readonly #internalModule: InternalControllerForEntity + readonly #moduleHost: ModuleHostForEntity + + /** + * Id of the control this belongs to + */ + readonly #controlId: string + + readonly #ownerId: EntityOwner | null + + readonly #listDefinition: ControlEntityListDefinition + + #entities: ControlEntityInstance[] = [] + + get ownerId(): EntityOwner | null { + return this.#ownerId + } + + constructor( + instanceDefinitions: InstanceDefinitionsForEntity, + internalModule: InternalControllerForEntity, + moduleHost: ModuleHostForEntity, + controlId: string, + ownerId: EntityOwner | null, + listDefinition: ControlEntityListDefinition + ) { + this.#instanceDefinitions = instanceDefinitions + this.#internalModule = internalModule + this.#moduleHost = moduleHost + this.#controlId = controlId + this.#ownerId = ownerId + this.#listDefinition = listDefinition + } + + /** + * Recursively get all the entities + */ + getAllEntities(): ControlEntityInstance[] { + return this.#entities.flatMap((e) => [e, ...e.getAllChildren()]) + } + + /** + * Get the entities directly contained in this list + */ + getDirectEntities(): ControlEntityInstance[] { + return this.#entities + } + + /** + * Initialise from storage + * @param entities + * @param skipSubscribe Whether to skip calling subscribe for the new entities + * @param isCloned Whether this is a cloned instance + */ + loadStorage(entities: SomeEntityModel[], skipSubscribe: boolean, isCloned: boolean): void { + // Inform modules of entity cleanup + for (const entity of this.#entities) { + entity.cleanup() + } + // TODO - validate that the entities are of the correct type + + this.#entities = + entities?.map( + (entity) => + new ControlEntityInstance( + this.#instanceDefinitions, + this.#internalModule, + this.#moduleHost, + this.#controlId, + entity, + !!isCloned + ) + ) || [] + + if (!skipSubscribe) { + this.subscribe(true) + } + } + + /** + * Inform the instance of any removed/disabled entities + * @access public + */ + cleanup() { + for (const entity of this.#entities) { + entity.cleanup() + } + } + + /** + * Inform the instance of an updated entity + * @param recursive whether to call recursively + * @param onlyType If set, only re-subscribe entities of this type + * @param onlyConnectionId If set, only re-subscribe entities for this connection + */ + subscribe(recursive: boolean, onlyType?: EntityModelType, onlyConnectionId?: string): void { + for (const entity of this.#entities) { + entity.subscribe(recursive, onlyType, onlyConnectionId) + } + } + + /** + * Find a child entity by id + */ + findById(id: string): ControlEntityInstance | undefined { + for (const entity of this.#entities) { + if (entity.id === id) return entity + + const found = entity.findChildById(id) + if (found) return found + } + + return undefined + } + + /** + * Find the index of a child entity, and the parent list + */ + findParentAndIndex( + id: string + ): { parent: ControlEntityList; index: number; item: ControlEntityInstance } | undefined { + const index = this.#entities.findIndex((fb) => fb.id === id) + if (index !== -1) { + return { parent: this, index, item: this.#entities[index] } + } + + for (const entity of this.#entities) { + const found = entity.findParentAndIndex(id) + if (found) return found + } + + return undefined + } + + /** + * Add a child entity to this entity + * @param entityModel + * @param isCloned Whether this is a cloned instance + */ + addEntity(entityModel: SomeEntityModel, isCloned?: boolean): ControlEntityInstance { + const newEntity = new ControlEntityInstance( + this.#instanceDefinitions, + this.#internalModule, + this.#moduleHost, + this.#controlId, + entityModel, + !!isCloned + ) + + // TODO - should this log and return instead of throw? + if (!this.canAcceptEntity(newEntity)) throw new Error('EntityList cannot accept this type of entity') + + this.#entities.push(newEntity) + + return newEntity + } + + /** + * Remove a child entity + */ + removeEntity(id: string): boolean { + const index = this.#entities.findIndex((entity) => entity.id === id) + if (index !== -1) { + const entity = this.#entities[index] + this.#entities.splice(index, 1) + + entity.cleanup() + + return true + } + + for (const entity of this.#entities) { + if (entity.removeChild(id)) return true + } + + return false + } + + /** + * Reorder an entity directly in in the list + */ + moveEntity(oldIndex: number, newIndex: number): void { + oldIndex = clamp(oldIndex, 0, this.#entities.length) + newIndex = clamp(newIndex, 0, this.#entities.length) + this.#entities.splice(newIndex, 0, ...this.#entities.splice(oldIndex, 1)) + } + + /** + * Pop an entity from the list + * Note: this is used when moving an entity to a different parent. Lifecycle is not managed + */ + popEntity(index: number): ControlEntityInstance | undefined { + const entity = this.#entities[index] + if (!entity) return undefined + + this.#entities.splice(index, 1) + + return entity + } + + /** + * Push an entity to the list + * Note: this is used when moving an entity from a different parent. Lifecycle is not managed + */ + pushEntity(entity: ControlEntityInstance, index: number): void { + if (!this.canAcceptEntity(entity)) throw new Error('EntityList cannot accept this type of entity') + + index = clamp(index, 0, this.#entities.length) + + this.#entities.splice(index, 0, entity) + } + + /** + * Check if this list can accept a specified entity + */ + canAcceptEntity(entity: ControlEntityInstance): boolean { + if (this.#listDefinition.type !== entity.type) return false + + // If a feedback list, check that the feedback is of the correct type + if (this.#listDefinition.type === EntityModelType.Feedback) { + const feedbackDefinition = entity.getEntityDefinition() + if (this.#listDefinition.booleanFeedbacksOnly && feedbackDefinition?.feedbackType !== 'boolean') return false + } + + return true + } + + /** + * Duplicate an entity + */ + duplicateEntity(id: string): ControlEntityInstance | undefined { + const entityIndex = this.#entities.findIndex((entity) => entity.id === id) + if (entityIndex !== -1) { + const entityModel = this.#entities[entityIndex].asEntityModel(true) + const newEntity = new ControlEntityInstance( + this.#instanceDefinitions, + this.#internalModule, + this.#moduleHost, + this.#controlId, + entityModel, + true + ) + + this.#entities.splice(entityIndex + 1, 0, newEntity) + + newEntity.subscribe(true) + + return newEntity + } + + for (const entity of this.#entities) { + const newAction = entity.duplicateChild(id) + if (newAction) return newAction + } + + return undefined + } + + /** + * Cleanup and forget any children belonging to the given connection + */ + forgetForConnection(connectionId: string): boolean { + let changed = false + + this.#entities = this.#entities.filter((entity) => { + if (entity.connectionId === connectionId) { + changed = true + + entity.cleanup() + + return false + } else { + changed = entity.forgetChildrenForConnection(connectionId) || changed + return true + } + }) + + return changed + } + + /** + * Prune all entities referencing unknown conncetions + * Doesn't do any cleanup, as it is assumed that the connection has not been running + */ + verifyConnectionIds(knownConnectionIds: Set): boolean { + // Clean out actions + const entitiesLength = this.#entities.length + this.#entities = this.#entities.filter((entity) => !!entity && knownConnectionIds.has(entity.connectionId)) + let changed = this.#entities.length !== entitiesLength + + for (const entity of this.#entities) { + if (entity.verifyChildConnectionIds(knownConnectionIds)) { + changed = true + } + } + + return changed + } + + /** + * If this control was imported to a running system, do some data cleanup/validation + */ + postProcessImport(): Promise[] { + return this.#entities.flatMap((entity) => entity.postProcessImport()) + } + + clearCachedValueForConnectionId(connectionId: string): boolean { + let changed = false + for (const entity of this.#entities) { + if (entity.clearCachedValueForConnectionId(connectionId)) changed = true + } + + return changed + } + + /** + * Get the value of this feedback as a boolean + */ + getBooleanFeedbackValue(): boolean { + if (this.#listDefinition.type !== EntityModelType.Feedback || !this.#listDefinition.booleanFeedbacksOnly) + throw new Error('ControlEntityList is not boolean feedbacks') + + let result = true + + for (const entity of this.#entities) { + if (entity.disabled) continue + + result = result && entity.getBooleanFeedbackValue() + } + + return result + } + + getChildBooleanFeedbackValues(): boolean[] { + if (this.#listDefinition.type !== EntityModelType.Feedback || !this.#listDefinition.booleanFeedbacksOnly) + throw new Error('ControlEntityList is not boolean feedbacks') + + const values: boolean[] = [] + + for (const entity of this.#entities) { + if (entity.disabled) continue + + values.push(entity.getBooleanFeedbackValue()) + } + + return values + } + + /** + * Get the unparsed style for the feedbacks + * Note: Does not clone the style + */ + buildFeedbackStyle(styleBuilder: FeedbackStyleBuilder): void { + if (this.#listDefinition.type !== EntityModelType.Feedback || this.#listDefinition.booleanFeedbacksOnly) + throw new Error('ControlEntityList is not style feedbacks') + + // Note: We don't need to consider children of the feedbacks here, as that can only be from boolean feedbacks which are handled by the `getBooleanValue` + + for (const entity of this.#entities) { + entity.buildFeedbackStyle(styleBuilder) + } + } + + /** + * Update the feedbacks on the button with new values + * @param connectionId The instance the feedbacks are for + * @param newValues The new feedback values + */ + updateFeedbackValues(connectionId: string, newValues: Record): boolean { + let changed = false + + for (const entity of this.#entities) { + if (entity.updateFeedbackValues(connectionId, newValues)) changed = true + } + + return changed + } + + /** + * Get all the connection ids that are enabled + */ + getAllEnabledConnectionIds(connectionIds: Set): void { + for (const entity of this.#entities) { + entity.getAllEnabledConnectionIds(connectionIds) + } + } +} diff --git a/companion/lib/Controls/Entities/EntityListPoolBase.ts b/companion/lib/Controls/Entities/EntityListPoolBase.ts new file mode 100644 index 0000000000..043dd968e0 --- /dev/null +++ b/companion/lib/Controls/Entities/EntityListPoolBase.ts @@ -0,0 +1,512 @@ +import LogController, { Logger } from '../../Log/Controller.js' +import { + EntityModelType, + EntityOwner, + SomeEntityModel, + SomeReplaceableEntityModel, + type SomeSocketEntityLocation, +} from '@companion-app/shared/Model/EntityModel.js' +import type { ControlEntityInstance } from './EntityInstance.js' +import { ControlEntityList, ControlEntityListDefinition } from './EntityList.js' +import type { ModuleHost } from '../../Instance/Host.js' +import type { InternalController } from '../../Internal/Controller.js' +import { isEqual } from 'lodash-es' +import type { ButtonStyleProperties } from '@companion-app/shared/Model/StyleModel.js' +import type { InstanceDefinitionsForEntity } from './Types.js' + +export interface ControlEntityListPoolProps { + instanceDefinitions: InstanceDefinitionsForEntity + internalModule: InternalController + moduleHost: ModuleHost + controlId: string + commitChange: (redraw?: boolean) => void + triggerRedraw: () => void +} + +export abstract class ControlEntityListPoolBase { + /** + * The logger + */ + protected readonly logger: Logger + + readonly #instanceDefinitions: InstanceDefinitionsForEntity + readonly #internalModule: InternalController + readonly #moduleHost: ModuleHost + + protected readonly controlId: string + + /** + * Commit changes to the database and disk + */ + protected readonly commitChange: (redraw?: boolean) => void + + /** + * Trigger a redraw/invalidation of the control + */ + protected readonly triggerRedraw: () => void + + abstract get baseStyle(): ButtonStyleProperties + + protected constructor(props: ControlEntityListPoolProps) { + this.logger = LogController.createLogger(`Controls/Fragments/EnittyPool/${props.controlId}`) + + this.controlId = props.controlId + this.commitChange = props.commitChange + this.triggerRedraw = props.triggerRedraw + + this.#instanceDefinitions = props.instanceDefinitions + this.#internalModule = props.internalModule + this.#moduleHost = props.moduleHost + } + + protected createEntityList(listDefinition: ControlEntityListDefinition) { + return new ControlEntityList( + this.#instanceDefinitions, + this.#internalModule, + this.#moduleHost, + this.controlId, + null, + listDefinition + ) + } + + /** + * Remove any tracked state for a connection + */ + clearConnectionState(connectionId: string): void { + let changed = false + for (const list of this.getAllEntityLists()) { + if (list.clearCachedValueForConnectionId(connectionId)) changed = true + } + if (changed) this.triggerRedraw() + } + + /** + * Prepare this control for deletion + * @access public + */ + destroy(): void { + for (const list of this.getAllEntityLists()) { + list.cleanup() + } + } + + protected abstract getEntityList(listId: SomeSocketEntityLocation): ControlEntityList | undefined + protected abstract getAllEntityLists(): ControlEntityList[] + + /** + * Recursively get all the entities + */ + getAllEntities(): ControlEntityInstance[] { + return this.getAllEntityLists().flatMap((entityList) => entityList.getAllEntities()) + } + + /** + * + * @param listId + * @returns + */ + getAllEntitiesInList(listId: SomeSocketEntityLocation, recursive = false): ControlEntityInstance[] { + const list = this.getEntityList(listId) + if (!list) return [] + + if (recursive) return list.getAllEntities() + return list.getDirectEntities() + } + + /** + * Re-trigger 'subscribe' for all entities + * This should be used when something has changed which will require all feedbacks to be re-run + * @param onlyType If set, only re-subscribe entities of this type + * @param onlyConnectionId If set, only re-subscribe entities for this connection + */ + resubscribeEntities(onlyType?: EntityModelType, onlyConnectionId?: string): void { + for (const list of this.getAllEntityLists()) { + list.subscribe(true, onlyType, onlyConnectionId) + } + } + + /** + * Add an entity to this control + * @param entityModel the item to add + * @param ownerId the ids of parent entity that this entity should be added as a child of + */ + entityAdd( + listId: SomeSocketEntityLocation, + ownerId: EntityOwner | null, + ...entityModels: SomeEntityModel[] + ): boolean { + if (entityModels.length === 0) return false + + const entityList = this.getEntityList(listId) + if (!entityList) return false + + let newEntities: ControlEntityInstance[] + if (ownerId) { + const parent = entityList.findById(ownerId.parentId) + if (!parent) throw new Error(`Failed to find parent entity ${ownerId.parentId} when adding child entity`) + + newEntities = entityModels.map((entity) => parent.addChild(ownerId.childGroup, entity)) + } else { + newEntities = entityModels.map((entity) => entityList.addEntity(entity)) + } + + // Inform relevant module + for (const entity of newEntities) { + entity.subscribe(true) + } + + this.commitChange() + + return true + } + + /** + * Duplicate an feedback on this control + */ + entityDuplicate(listId: SomeSocketEntityLocation, id: string): boolean { + const entityList = this.getEntityList(listId) + if (!entityList) return false + + const entity = entityList.duplicateEntity(id) + if (!entity) return false + + this.commitChange(false) + + return true + } + + /** + * Enable or disable an entity + */ + entityEnabled(listId: SomeSocketEntityLocation, id: string, enabled: boolean): boolean { + const entityList = this.getEntityList(listId) + if (!entityList) return false + + const entity = entityList.findById(id) + if (!entity) return false + + entity.setEnabled(enabled) + + this.commitChange() + + return true + } + + /** + * Set headline for the entity + */ + entityHeadline(listId: SomeSocketEntityLocation, id: string, headline: string): boolean { + const entityList = this.getEntityList(listId) + if (!entityList) return false + + const entity = entityList.findById(id) + if (!entity) return false + + entity.setHeadline(headline) + + this.commitChange() + + return true + } + + /** + * Learn the options for a feedback, by asking the instance for the current values + */ + async entityLearn(listId: SomeSocketEntityLocation, id: string): Promise { + const entityList = this.getEntityList(listId) + if (!entityList) return false + + const entity = entityList.findById(id) + if (!entity) return false + + const changed = await entity.learnOptions() + if (!changed) return false + + // Time has passed due to the `await` + // So the entity may not still exist, meaning we should find it again to be sure + const feedbackAfter = entityList.findById(id) + if (!feedbackAfter) return false + + this.commitChange(true) + return true + } + + /** + * Remove an entity from this control + */ + entityRemove(listId: SomeSocketEntityLocation, id: string): boolean { + const entityList = this.getEntityList(listId) + if (!entityList) return false + + if (entityList.removeEntity(id)) { + this.commitChange() + + return true + } else { + return false + } + } + + /** + * Move an entity within the hierarchy + * @param moveListId the id of the list to move the entity from + * @param moveEntityId the id of the entity to move + * @param newOwnerId the target new owner of the entity + * @param newListId the id of the list to move the entity to + * @param newIndex the target index of the entity + */ + entityMoveTo( + moveListId: SomeSocketEntityLocation, + moveEntityId: string, + newOwnerId: EntityOwner | null, + newListId: SomeSocketEntityLocation, + newIndex: number + ): boolean { + if (newOwnerId && moveEntityId === newOwnerId.parentId) return false + + const oldInfo = this.getEntityList(moveListId)?.findParentAndIndex(moveEntityId) + if (!oldInfo) return false + + if ( + isEqual(moveListId, newListId) && + oldInfo.parent.ownerId?.parentId === newOwnerId?.parentId && + oldInfo.parent.ownerId?.childGroup === newOwnerId?.childGroup + ) { + oldInfo.parent.moveEntity(oldInfo.index, newIndex) + } else { + const newEntityList = this.getEntityList(newListId) + if (!newEntityList) return false + + const newParent = newOwnerId ? newEntityList.findById(newOwnerId.parentId) : null + if (newOwnerId && !newParent) return false + + // Ensure the new parent is not a child of the entity being moved + if (newOwnerId && oldInfo.item.findChildById(newOwnerId.parentId)) return false + + // Check if the new parent can hold the feedback being moved + if (newParent && !newParent.canAcceptChild(newOwnerId!.childGroup, oldInfo.item)) return false + + const poppedFeedback = oldInfo.parent.popEntity(oldInfo.index) + if (!poppedFeedback) return false + + if (newParent) { + newParent.pushChild(poppedFeedback, newOwnerId!.childGroup, newIndex) + } else { + newEntityList.pushEntity(poppedFeedback, newIndex) + } + } + + this.commitChange() + + return true + } + + /** + * Replace a feedback with an updated version + */ + entityReplace(newProps: SomeReplaceableEntityModel, skipNotifyModule = false): boolean { + for (const childGroup of this.getAllEntityLists()) { + const entity = childGroup.findById(newProps.id) + if (!entity) continue + + // Ignore if the types do not match + if (entity.type !== newProps.type) return false + + entity.replaceProps(newProps, skipNotifyModule) + + this.commitChange(true) + + return true + } + + return false + } + + /** + * Replace all the entities in a list + * @param lsitId the list to update + * @param newEntities entities to populate + */ + entityReplaceAll(listId: SomeSocketEntityLocation, entities: SomeEntityModel[]): boolean { + const entityList = this.getEntityList(listId) + if (!entityList) return false + + entityList.loadStorage(entities, false, false) + + this.commitChange(true) + + return true + } + + /** + * Update an option for an entity + * @param id the id of the entity + * @param key the key/name of the property + * @param value the new value + */ + entrySetOptions(listId: SomeSocketEntityLocation, id: string, key: string, value: any): boolean { + const entityList = this.getEntityList(listId) + if (!entityList) return false + + const entity = entityList.findById(id) + if (!entity) return false + + entity.setOption(key, value) + + this.commitChange() + + return true + } + + /** + * Set a new connection instance for an entity + * @param id the id of the entity + * @param connectionId the id of the new connection + */ + entitySetConnection(listId: SomeSocketEntityLocation, id: string, connectionId: string | number): boolean { + const entityList = this.getEntityList(listId) + if (!entityList) return false + + const entity = entityList.findById(id) + if (!entity) return false + + entity.setConnectionId(connectionId) + + this.commitChange() + + return true + } + + /** + * Set whether a boolean feedback should be inverted + * @param id the id of the entity + * @param isInverted the new value + */ + entitySetInverted(listId: SomeSocketEntityLocation, id: string, isInverted: boolean): boolean { + const entityList = this.getEntityList(listId) + if (!entityList) return false + + const entity = entityList.findById(id) + if (!entity) return false + + entity.setInverted(!!isInverted) + + this.commitChange() + + return true + } + + /** + * Update the selected style properties for a boolean feedback + * @param id the id of the entity + * @param selected the properties to be selected + */ + entitySetStyleSelection(listId: SomeSocketEntityLocation, id: string, selected: string[]): boolean { + const entityList = this.getEntityList(listId) + if (!entityList) return false + + const entity = entityList.findById(id) + if (!entity) return false + + // if (this.#booleanOnly) throw new Error('FragmentFeedbacks not setup to use styles') + + if (entity.setStyleSelection(selected, this.baseStyle)) { + this.commitChange() + + return true + } + + return false + } + + /** + * Update an style property for a boolean feedback + * @param id the id of the entity + * @param key the key/name of the property + * @param value the new value + */ + entitySetStyleValue(listId: SomeSocketEntityLocation, id: string, key: string, value: any): boolean { + const entityList = this.getEntityList(listId) + if (!entityList) return false + + const entity = entityList.findById(id) + if (!entity) return false + + // if (this.#booleanOnly) throw new Error('FragmentFeedbacks not setup to use styles') + + if (entity.setStyleValue(key, value)) { + this.commitChange() + + return true + } + + return false + } + + /** + * Remove any entities referencing a specified connectionId + */ + forgetConnection(connectionId: string): boolean { + let changed = false + for (const list of this.getAllEntityLists()) { + if (list.forgetForConnection(connectionId)) changed = true + } + return changed + } + + /** + * Prune all actions/feedbacks referencing unknown conncetions + * Doesn't do any cleanup, as it is assumed that the connection has not been running + */ + verifyConnectionIds(knownConnectionIds: Set): void { + let changed = false + + for (const list of this.getAllEntityLists()) { + if (list.verifyConnectionIds(knownConnectionIds)) changed = true + } + + if (changed) { + this.commitChange(true) + } + } + + /** + * If this control was imported to a running system, do some data cleanup/validation + */ + async postProcessImport(): Promise { + await Promise.all(this.getAllEntityLists().map((list) => list.postProcessImport())).catch((e) => { + this.logger.silly(`postProcessImport for ${this.controlId} failed: ${e.message}`) + }) + } + + /** + * Update the feedbacks on the button with new values + * @param connectionId The instance the feedbacks are for + * @param newValues The new feedback values + */ + updateFeedbackValues(connectionId: string, newValues: Record): void { + let changed = false + + for (const list of this.getAllEntityLists()) { + if (list.updateFeedbackValues(connectionId, newValues)) changed = true + } + + if (changed) { + this.triggerRedraw() + } + } + + /** + * Get all the connectionIds for actions and feedbacks which are active + */ + getAllEnabledConnectionIds(): Set { + const connectionIds = new Set() + + for (const list of this.getAllEntityLists()) { + list.getAllEnabledConnectionIds(connectionIds) + } + + return connectionIds + } +} diff --git a/companion/lib/Controls/Entities/EntityListPoolButton.ts b/companion/lib/Controls/Entities/EntityListPoolButton.ts new file mode 100644 index 0000000000..300be96e7d --- /dev/null +++ b/companion/lib/Controls/Entities/EntityListPoolButton.ts @@ -0,0 +1,616 @@ +import { NormalButtonModel, NormalButtonSteps } from '@companion-app/shared/Model/ButtonModel.js' +import { + EntityModelType, + SomeEntityModel, + type SomeSocketEntityLocation, +} from '@companion-app/shared/Model/EntityModel.js' +import { ButtonStyleProperties, UnparsedButtonStyle } from '@companion-app/shared/Model/StyleModel.js' +import { ControlEntityList } from './EntityList.js' +import { ControlEntityListPoolBase, ControlEntityListPoolProps } from './EntityListPoolBase.js' +import { FeedbackStyleBuilder } from './FeedbackStyleBuilder.js' +import type { ActionSetId, ActionSetsModel, ActionStepOptions } from '@companion-app/shared/Model/ActionModel.js' +import type { ControlActionSetAndStepsManager } from './ControlActionSetAndStepsManager.js' +import { cloneDeep } from 'lodash-es' +import { validateActionSetId } from '@companion-app/shared/ControlId.js' + +export class ControlEntityListPoolButton extends ControlEntityListPoolBase implements ControlActionSetAndStepsManager { + /** + * The defaults options for a step + */ + static DefaultStepOptions: ActionStepOptions = { + runWhileHeld: [], // array of set ids + } + + /** + * The defaults style for a button + */ + static DefaultStyle: ButtonStyleProperties = { + text: '', + textExpression: false, + size: 'auto', + png64: null, + alignment: 'center:center', + pngalignment: 'center:center', + color: 0xffffff, + bgcolor: 0x000000, + show_topbar: 'default', + } + + readonly #feedbacks: ControlEntityList + + readonly #steps = new Map() + + readonly #sendRuntimePropsChange: () => void + + /** + * The id of the currently selected (next to be executed) step + */ + #current_step_id: string = '0' + + /** + * The base style without feedbacks applied + */ + #baseStyle: ButtonStyleProperties = cloneDeep(ControlEntityListPoolButton.DefaultStyle) + + #hasRotaryActions = false + + get currentStepId(): string { + return this.#current_step_id + } + + get baseStyle(): ButtonStyleProperties { + return this.#baseStyle + } + + constructor(props: ControlEntityListPoolProps, sendRuntimePropsChange: () => void) { + super(props) + + this.#sendRuntimePropsChange = sendRuntimePropsChange + + this.#feedbacks = new ControlEntityList( + props.instanceDefinitions, + props.internalModule, + props.moduleHost, + props.controlId, + null, + { + type: EntityModelType.Feedback, + } + ) + + this.#current_step_id = '0' + + this.#steps.set('0', this.#getNewStepValue(null, null)) + } + + loadStorage(storage: NormalButtonModel, skipSubscribe: boolean, isImport: boolean) { + this.#baseStyle = Object.assign(this.#baseStyle, storage.style || {}) + + this.#feedbacks.loadStorage(storage.feedbacks || [], skipSubscribe, isImport) + + // Future: cleanup the steps/sets + this.#steps.clear() + + for (const [id, stepObj] of Object.entries(storage.steps ?? {})) { + this.#steps.set(id, this.#getNewStepValue(stepObj.action_sets, stepObj.options)) + } + + this.#current_step_id = this.getStepIds()[0] + } + + /** + * Get direct the feedback instances + */ + getFeedbackEntities(): SomeEntityModel[] { + return this.#feedbacks.getDirectEntities().map((ent) => ent.asEntityModel(true)) + } + + // /** + // * Get direct the action instances + // */ + // getActionEntities(): SomeEntityModel[] { + // return this.#actions.getDirectEntities().map((ent) => ent.asEntityModel(true)) + // } + asNormalButtonSteps(): NormalButtonSteps { + const stepsJson: NormalButtonSteps = {} + for (const [id, step] of this.#steps) { + stepsJson[id] = { + action_sets: this.#stepAsActionSetsModel(step), + options: step.options, + } + } + + return stepsJson + } + + #stepAsActionSetsModel(step: ControlEntityListActionStep): ActionSetsModel { + const actionSets: ActionSetsModel = { + down: [], + up: [], + rotate_left: undefined, + rotate_right: undefined, + } + for (const [setId, set] of step.sets) { + actionSets[setId] = set.getDirectEntities().map((ent) => ent.asEntityModel(true)) + } + + return actionSets + } + + protected getEntityList(listId: SomeSocketEntityLocation): ControlEntityList | undefined { + if (listId === 'feedbacks') return this.#feedbacks + + if (typeof listId === 'object' && 'setId' in listId && 'stepId' in listId) { + return this.#steps.get(listId.stepId)?.sets.get(listId.setId) + } + + return undefined + } + + protected getAllEntityLists(): ControlEntityList[] { + const entityLists: ControlEntityList[] = [this.#feedbacks] + + for (const step of this.#steps.values()) { + entityLists.push(...Array.from(step.sets.values())) + } + + return entityLists + } + + /** + * Get the unparsed style for the feedbacks + * Note: Does not clone the style + */ + getUnparsedFeedbackStyle(): UnparsedButtonStyle { + const styleBuilder = new FeedbackStyleBuilder(this.#baseStyle) + this.#feedbacks.buildFeedbackStyle(styleBuilder) + return styleBuilder.style + } + + getStepIds(): string[] { + return Array.from(this.#steps.keys()).sort((a, b) => Number(a) - Number(b)) + } + + actionSetAdd(stepId: string): boolean { + const step = this.#steps.get(stepId) + if (!step) return false + + const existingKeys = Array.from(step.sets.keys()) + .map((k) => Number(k)) + .filter((k) => !isNaN(k)) + if (existingKeys.length === 0) { + // add the default '1000' set + step.sets.set(1000, this.#createActionEntityList([], false, false)) + + this.commitChange(true) + + return true + // return 1000 + } else { + // add one after the last + const max = Math.max(...existingKeys) + const newIndex = Math.floor(max / 1000) * 1000 + 1000 + + step.sets.set(newIndex, this.#createActionEntityList([], false, false)) + + this.commitChange(false) + + return true + // return newIndex + } + } + + actionSetRemove(stepId: string, setId: ActionSetId): boolean { + const step = this.#steps.get(stepId) + if (!step) return false + + // Ensure is a valid number + const setIdNumber = Number(setId) + if (isNaN(setIdNumber)) return false + + const setToRemove = step.sets.get(setIdNumber) + if (!setToRemove) return false + + // Inform modules of the change + setToRemove.cleanup() + + // Forget the step from the options + step.options.runWhileHeld = step.options.runWhileHeld.filter((id) => id !== setIdNumber) + + // Assume it exists + step.sets.delete(setIdNumber) + + // Save the change, and perform a draw + this.commitChange(true) + + return true + } + + actionSetRename(stepId: string, oldSetId: ActionSetId, newSetId: ActionSetId): boolean { + const step = this.#steps.get(stepId) + if (!step) return false + + const newSetIdNumber = Number(newSetId) + const oldSetIdNumber = Number(oldSetId) + + // Only valid when both are numbers + if (isNaN(newSetIdNumber) || isNaN(oldSetIdNumber)) return false + + // Ensure old set exists + const oldSet = step.sets.get(oldSetIdNumber) + if (!oldSet) return false + + // Ensure new set doesnt already exist + if (step.sets.has(newSetIdNumber)) return false + + // Rename the set + step.sets.set(newSetIdNumber, oldSet) + step.sets.delete(oldSetIdNumber) + + // Update the runWhileHeld options + const runWhileHeldIndex = step.options.runWhileHeld.indexOf(oldSetIdNumber) + if (runWhileHeldIndex !== -1) step.options.runWhileHeld[runWhileHeldIndex] = newSetIdNumber + + this.commitChange(false) + + return true + } + + actionSetRunWhileHeld(stepId: string, setId: ActionSetId, runWhileHeld: boolean): boolean { + const step = this.#steps.get(stepId) + if (!step) return false + + // Ensure it is a number + const setIdNumber = Number(setId) + + // Only valid when step is a number + if (isNaN(setIdNumber)) return false + + // Ensure set exists + if (!step.sets.get(setIdNumber)) return false + + const runWhileHeldIndex = step.options.runWhileHeld.indexOf(setIdNumber) + if (runWhileHeld && runWhileHeldIndex === -1) { + step.options.runWhileHeld.push(setIdNumber) + } else if (!runWhileHeld && runWhileHeldIndex !== -1) { + step.options.runWhileHeld.splice(runWhileHeldIndex, 1) + } + + this.commitChange(false) + + return true + } + + setupRotaryActionSets(ensureCreated: boolean, skipCommit?: boolean): void { + // Cache the value + this.#hasRotaryActions = ensureCreated + + for (const step of this.#steps.values()) { + if (ensureCreated) { + // ensure they exist + if (!step.sets.has('rotate_left')) step.sets.set('rotate_left', this.#createActionEntityList([], false, false)) + if (!step.sets.has('rotate_right')) + step.sets.set('rotate_right', this.#createActionEntityList([], false, false)) + } else { + // remove the sets + const rotateLeftSet = step.sets.get('rotate_left') + const rotateRightSet = step.sets.get('rotate_right') + + if (rotateLeftSet) { + rotateLeftSet.cleanup() + step.sets.delete('rotate_left') + } + if (rotateRightSet) { + rotateRightSet.cleanup() + step.sets.delete('rotate_right') + } + } + } + + if (!skipCommit) this.commitChange(true) + } + + #createActionEntityList(entities: SomeEntityModel[], skipSubscribe: boolean, isCloned: boolean): ControlEntityList { + const list = this.createEntityList({ type: EntityModelType.Action }) + list.loadStorage(entities, skipSubscribe, isCloned) + return list + } + + #getNewStepValue( + existingActions: ActionSetsModel | null, + existingOptions: ActionStepOptions | null + ): ControlEntityListActionStep { + const options = existingOptions || cloneDeep(ControlEntityListPoolButton.DefaultStepOptions) + + const downList = this.#createActionEntityList(existingActions?.down || [], true, !!existingActions) + const upList = this.#createActionEntityList(existingActions?.up || [], true, !!existingActions) + + const sets = new Map() + sets.set('down', downList) + sets.set('up', upList) + + if (this.#hasRotaryActions) { + sets.set('rotate_left', this.#createActionEntityList(existingActions?.rotate_left || [], true, !!existingActions)) + sets.set( + 'rotate_right', + this.#createActionEntityList(existingActions?.rotate_right || [], true, !!existingActions) + ) + } + + for (const setId in existingActions || {}) { + const setIdNumber = validateActionSetId(setId as ActionSetId) + if (typeof setIdNumber === 'number') { + sets.set( + setIdNumber, + this.#createActionEntityList(existingActions?.[setIdNumber] || [], true, !!existingActions) + ) + } + } + + return { + sets: sets, + options: options, + } + } + + /** + * Get the index of the current (next to execute) step + * @returns The index of current step + */ + getActiveStepIndex(): number { + const out = this.getStepIds().indexOf(this.#current_step_id) + return out !== -1 ? out : 0 + } + + /** + * Add a step to this control + * @returns Id of new step + */ + stepAdd(): string { + const existingKeys = this.getStepIds() + .map((k) => Number(k)) + .filter((k) => !isNaN(k)) + if (existingKeys.length === 0) { + // add the default '0' set + this.#steps.set('0', this.#getNewStepValue(null, null)) + + this.commitChange(true) + + return '0' + } else { + // add one after the last + const max = Math.max(...existingKeys) + + const stepId = `${max + 1}` + this.#steps.set(stepId, this.#getNewStepValue(null, null)) + + this.commitChange(true) + + return stepId + } + } + + /** + * Progress through the action-sets + * @param amount Number of steps to progress + */ + stepAdvanceDelta(amount: number): boolean { + if (amount && typeof amount === 'number') { + const all_steps = this.getStepIds() + if (all_steps.length > 0) { + const current = all_steps.indexOf(this.#current_step_id) + + let newIndex = (current === -1 ? 0 : current) + amount + while (newIndex < 0) newIndex += all_steps.length + newIndex = newIndex % all_steps.length + + const newStepId = all_steps[newIndex] + return this.stepSelectCurrent(newStepId) + } + } + + return false + } + + /** + * Duplicate a step on this control + * @param stepId the id of the step to duplicate + */ + stepDuplicate(stepId: string): boolean { + const existingKeys = this.getStepIds() + .map((k) => Number(k)) + .filter((k) => !isNaN(k)) + + const stepToCopy = this.#steps.get(stepId) + if (!stepToCopy) return false + + const newStep = this.#getNewStepValue( + cloneDeep(this.#stepAsActionSetsModel(stepToCopy)), + cloneDeep(stepToCopy.options) + ) + + // add one after the last + const max = Math.max(...existingKeys) + + const newStepId = `${max + 1}` + this.#steps.set(newStepId, newStep) + + // Treat it as an import, to make any ids unique + Promise.all(Array.from(newStep.sets.values()).map((set) => set.postProcessImport())).catch((e) => { + this.logger.silly(`stepDuplicate failed postProcessImport for ${this.controlId} failed: ${e.message}`) + }) + + // Ensure the ui knows which step is current + this.#sendRuntimePropsChange() + + // Save the change, and perform a draw + this.commitChange(true) + + return true + } + + /** + * Set the current (next to execute) action-set by index + * @param index The step index to make the next + */ + stepMakeCurrent(index: number): boolean { + if (typeof index === 'number') { + const stepId = this.getStepIds()[index - 1] + if (stepId !== undefined) { + return this.stepSelectCurrent(stepId) + } + } + + return false + } + + /** + * Remove an action-set from this control + * @param stepId the id of the action-set + */ + stepRemove(stepId: string): boolean { + const oldKeys = this.getStepIds() + + // Ensure there is at least one step + if (oldKeys.length === 1) return false + + const step = this.#steps.get(stepId) + if (!step) return false + + for (const set of step.sets.values()) { + set.cleanup() + } + this.#steps.delete(stepId) + + // Update the current step + const oldIndex = oldKeys.indexOf(stepId) + let newIndex = oldIndex + 1 + if (newIndex >= oldKeys.length) { + newIndex = 0 + } + if (newIndex !== oldIndex) { + this.#current_step_id = oldKeys[newIndex] + + this.#sendRuntimePropsChange() + } + + // Save the change, and perform a draw + this.commitChange(true) + + return true + } + + /** + * Set the current (next to execute) action-set by id + * @param stepId The step id to make the next + */ + stepSelectCurrent(stepId: string): boolean { + const step = this.#steps.get(stepId) + if (!step) return false + + // Ensure it isn't currently pressed + // this.setPushed(false) + + this.#current_step_id = stepId + + this.#sendRuntimePropsChange() + + this.triggerRedraw() + + return true + } + + /** + * Swap two action-sets + * @param stepId1 One of the action-sets + * @param stepId2 The other action-set + */ + stepSwap(stepId1: string, stepId2: string): boolean { + const step1 = this.#steps.get(stepId1) + const step2 = this.#steps.get(stepId2) + + if (!step1 || !step2) return false + + this.#steps.set(stepId1, step2) + this.#steps.set(stepId2, step1) + + this.commitChange(false) + + return true + } + + /** + * Rename step + * @param stepId the id of the action-set + * @param newName the new name of the step + */ + stepRename(stepId: string, newName: string): boolean { + const step = this.#steps.get(stepId) + if (!step) return false + + step.options.name = newName + + this.commitChange(false) + + return true + } + + validateCurrentStepIdAndGetNext(): [null, null] | [string, string] { + const this_step_raw = this.#current_step_id + const stepIds = this.getStepIds() + if (stepIds.length > 0) { + // verify 'this_step_raw' is valid + const this_step_index = stepIds.findIndex((s) => s == this_step_raw) || 0 + const this_step_id = stepIds[this_step_index] + + // figure out the new step + const next_index = this_step_index + 1 >= stepIds.length ? 0 : this_step_index + 1 + const next_step_id = stepIds[next_index] + + return [this_step_id, next_step_id] + } else { + return [null, null] + } + } + + getActionsToExecuteForSet(setId: ActionSetId): SomeEntityModel[] { + const [this_step_id] = this.validateCurrentStepIdAndGetNext() + if (!this_step_id) return [] + + const step = this.#steps.get(this_step_id) + if (!step) return [] + + const set = step.sets.get(setId) + if (!set) return [] + + return set.getDirectEntities().map((ent) => ent.asEntityModel(true)) + } + + getStepActions(stepId: string): + | { + sets: Map + options: Readonly + } + | undefined { + const step = this.#steps.get(stepId) + if (!step) return undefined + + const sets: Map = new Map() + for (const [setId, set] of step.sets) { + sets.set( + setId, + set.getDirectEntities().map((ent) => ent.asEntityModel(true)) + ) + } + + return { + sets: sets, + options: step.options, + } + } +} + +interface ControlEntityListActionStep { + readonly sets: Map + options: ActionStepOptions +} diff --git a/companion/lib/Controls/Entities/EntityListPoolTrigger.ts b/companion/lib/Controls/Entities/EntityListPoolTrigger.ts new file mode 100644 index 0000000000..12ea439d1d --- /dev/null +++ b/companion/lib/Controls/Entities/EntityListPoolTrigger.ts @@ -0,0 +1,65 @@ +import { + EntityModelType, + SomeEntityModel, + type SomeSocketEntityLocation, +} from '@companion-app/shared/Model/EntityModel.js' +import type { TriggerModel } from '@companion-app/shared/Model/TriggerModel.js' +import { ControlEntityList } from './EntityList.js' +import { ControlEntityListPoolBase, ControlEntityListPoolProps } from './EntityListPoolBase.js' +import { ButtonStyleProperties } from '@companion-app/shared/Model/StyleModel.js' + +export class ControlEntityListPoolTrigger extends ControlEntityListPoolBase { + #feedbacks: ControlEntityList + + #actions: ControlEntityList + + constructor(props: ControlEntityListPoolProps) { + super(props) + + this.#feedbacks = this.createEntityList({ + type: EntityModelType.Feedback, + booleanFeedbacksOnly: true, + }) + this.#actions = this.createEntityList({ type: EntityModelType.Action }) + } + + get baseStyle(): ButtonStyleProperties { + throw new Error('baseStyle not supported for triggers.') + } + + loadStorage(storage: TriggerModel, skipSubscribe: boolean, isImport: boolean) { + this.#feedbacks.loadStorage(storage.condition || [], skipSubscribe, isImport) + this.#feedbacks.loadStorage(storage.actions || [], skipSubscribe, isImport) + } + + /** + * Get the value from all feedbacks as a single boolean + */ + checkConditionValue(): boolean { + return this.#feedbacks.getBooleanFeedbackValue() + } + + /** + * Get direct the feedback instances + */ + getFeedbackEntities(): SomeEntityModel[] { + return this.#feedbacks.getDirectEntities().map((ent) => ent.asEntityModel(true)) + } + + /** + * Get direct the action instances + */ + getActionEntities(): SomeEntityModel[] { + return this.#actions.getDirectEntities().map((ent) => ent.asEntityModel(true)) + } + + protected getEntityList(listId: SomeSocketEntityLocation): ControlEntityList | undefined { + if (listId === 'feedbacks') return this.#feedbacks + if (listId === 'trigger_actions') return this.#feedbacks + return undefined + } + + protected getAllEntityLists(): ControlEntityList[] { + return [this.#feedbacks, this.#actions] + } +} diff --git a/companion/lib/Controls/Entities/FeedbackStyleBuilder.ts b/companion/lib/Controls/Entities/FeedbackStyleBuilder.ts new file mode 100644 index 0000000000..bec1a82ab6 --- /dev/null +++ b/companion/lib/Controls/Entities/FeedbackStyleBuilder.ts @@ -0,0 +1,58 @@ +import type { ButtonStyleProperties, UnparsedButtonStyle } from '@companion-app/shared/Model/StyleModel.js' + +/** + * A simple class to help combine multiple styles into one that can be drawn + */ +export class FeedbackStyleBuilder { + #combinedStyle: UnparsedButtonStyle + + get style(): UnparsedButtonStyle { + return this.#combinedStyle + } + + constructor(baseStyle: ButtonStyleProperties) { + this.#combinedStyle = { + ...baseStyle, + imageBuffers: [], + } + } + + /** + * Apply a simple layer of style + */ + applySimpleStyle(style: Partial | undefined) { + this.#combinedStyle = { + ...this.#combinedStyle, + ...style, + } + } + + applyComplexStyle(rawValue: any) { + if (typeof rawValue === 'object' && rawValue !== undefined) { + // Prune off some special properties + const prunedValue = { ...rawValue } + delete prunedValue.imageBuffer + delete prunedValue.imageBufferPosition + + // Ensure `textExpression` is set at the same times as `text` + delete prunedValue.textExpression + if ('text' in prunedValue) { + prunedValue.textExpression = rawValue.textExpression || false + } + + this.#combinedStyle = { + ...this.#combinedStyle, + ...prunedValue, + } + + // Push the imageBuffer into an array + if (rawValue.imageBuffer) { + this.#combinedStyle.imageBuffers.push({ + ...rawValue.imageBufferPosition, + ...rawValue.imageBufferEncoding, + buffer: rawValue.imageBuffer, + }) + } + } + } +} diff --git a/companion/lib/Controls/Entities/Types.ts b/companion/lib/Controls/Entities/Types.ts new file mode 100644 index 0000000000..419ae4a235 --- /dev/null +++ b/companion/lib/Controls/Entities/Types.ts @@ -0,0 +1,15 @@ +import type { ModuleHost } from '../../Instance/Host.js' +import type { InstanceDefinitions } from '../../Instance/Definitions.js' +import { InternalController } from '../../Internal/Controller.js' + +export type InstanceDefinitionsForEntity = Pick + +export type ModuleHostForEntity = Pick< + ModuleHost, + 'connectionEntityUpdate' | 'connectionEntityDelete' | 'connectionEntityLearnOptions' +> + +export type InternalControllerForEntity = Pick< + InternalController, + 'entityUpdate' | 'entityDelete' | 'entityUpgrade' | 'executeLogicFeedback' +> diff --git a/companion/lib/Controls/Fragments/FragmentActionInstance.ts b/companion/lib/Controls/Fragments/FragmentActionInstance.ts deleted file mode 100644 index 00153e1ac7..0000000000 --- a/companion/lib/Controls/Fragments/FragmentActionInstance.ts +++ /dev/null @@ -1,450 +0,0 @@ -import { cloneDeep } from 'lodash-es' -import { nanoid } from 'nanoid' -import LogController, { Logger } from '../../Log/Controller.js' -import { FragmentActionList } from './FragmentActionList.js' -import type { InstanceDefinitions } from '../../Instance/Definitions.js' -import type { InternalController } from '../../Internal/Controller.js' -import type { ModuleHost } from '../../Instance/Host.js' -import type { InternalVisitor } from '../../Internal/Types.js' -import type { ActionDefinition } from '@companion-app/shared/Model/ActionDefinitionModel.js' -import type { ActionInstance } from '@companion-app/shared/Model/ActionModel.js' -import { visitActionInstance } from '../../Resources/Visitors/ActionInstanceVisitor.js' - -export class FragmentActionInstance { - /** - * The logger - */ - readonly #logger: Logger - - readonly #instanceDefinitions: InstanceDefinitions - readonly #internalModule: InternalController - readonly #moduleHost: ModuleHost - - /** - * Id of the control this belongs to - */ - readonly #controlId: string - - readonly #data: Omit - - #children = new Map() - - /** - * Get the id of this action instance - */ - get id(): string { - return this.#data.id - } - - get disabled(): boolean { - return !!this.#data.disabled - } - - /** - * Get the id of the connection this action belongs to - */ - get connectionId(): string { - return this.#data.instance - } - - /** - * Get a reference to the options for this action - * Note: This must not be a copy, but the raw object - */ - get rawOptions() { - return this.#data.options - } - - /** - * @param instanceDefinitions - * @param internalModule - * @param moduleHost - * @param controlId - id of the control - * @param data - * @param isCloned Whether this is a cloned instance and should generate new ids - */ - constructor( - instanceDefinitions: InstanceDefinitions, - internalModule: InternalController, - moduleHost: ModuleHost, - controlId: string, - data: ActionInstance, - isCloned: boolean - ) { - this.#logger = LogController.createLogger(`Controls/Fragments/Actions/${controlId}`) - - this.#instanceDefinitions = instanceDefinitions - this.#internalModule = internalModule - this.#moduleHost = moduleHost - this.#controlId = controlId - - this.#data = cloneDeep(data) // TODO - cleanup unwanted properties - if (!this.#data.options) this.#data.options = {} - - if (isCloned) { - this.#data.id = nanoid() - } - - if (data.instance === 'internal' && data.children) { - for (const [groupId, actions] of Object.entries(data.children)) { - if (!actions) continue - - try { - const childGroup = this.#getOrCreateActionGroup(groupId) - childGroup.loadStorage(actions, true, isCloned) - } catch (e: any) { - this.#logger.error(`Error loading child action group: ${e.message}`) - } - } - } - } - - #getOrCreateActionGroup(groupId: string): FragmentActionList { - const existing = this.#children.get(groupId) - if (existing) return existing - - // Check what names are allowed - const definition = this.connectionId === 'internal' && this.getDefinition() - if (!definition) throw new Error('Action cannot accept children.') - - if (!definition.supportsChildActionGroups.includes(groupId)) { - throw new Error('Action cannot accept children in this group.') - } - - const childGroup = new FragmentActionList( - this.#instanceDefinitions, - this.#internalModule, - this.#moduleHost, - this.#controlId, - { parentActionId: this.id, childGroup: groupId } - ) - this.#children.set(groupId, childGroup) - - return childGroup - } - - /** - * Get this action as a `ActionInstance` - */ - asActionInstance(): ActionInstance { - const actionInstance: ActionInstance = { ...this.#data } - - if (this.connectionId === 'internal') { - actionInstance.children = {} - - for (const [groupId, actionGroup] of this.#children) { - actionInstance.children[groupId] = actionGroup.asActionInstances() - } - } - - return actionInstance - } - - /** - * Get the definition for this action - */ - getDefinition(): ActionDefinition | undefined { - return this.#instanceDefinitions.getActionDefinition(this.#data.instance, this.#data.action) - } - - /** - * Inform the instance of a removed/disabled action - */ - cleanup() { - // Inform relevant module - const connection = this.#moduleHost.getChild(this.#data.instance, true) - if (connection) { - connection.actionDelete(this.asActionInstance()).catch((e) => { - this.#logger.silly(`action_delete to connection failed: ${e.message}`) - }) - } - - for (const actionGroup of this.#children.values()) { - actionGroup.cleanup() - } - } - - /** - * Inform the instance of an updated action - * @param recursive whether to call recursively - * @param onlyConnectionId If set, only subscribe actions for this connection - */ - subscribe(recursive: boolean, onlyConnectionId?: string): void { - if (this.#data.disabled) return - - if (!onlyConnectionId || this.#data.instance === onlyConnectionId) { - if (this.#data.instance === 'internal') { - // this.#internalModule.actionUpdate(this.asActionInstance(), this.#controlId) - } else { - const connection = this.#moduleHost.getChild(this.#data.instance, true) - if (connection) { - connection.actionUpdate(this.asActionInstance(), this.#controlId).catch((e) => { - this.#logger.silly(`action_update to connection failed: ${e.message} ${e.stack}`) - }) - } - } - } - - if (recursive) { - for (const actionGroup of this.#children.values()) { - actionGroup.subscribe(recursive, onlyConnectionId) - } - } - } - - /** - * Set whether this action is enabled - */ - setEnabled(enabled: boolean): void { - this.#data.disabled = !enabled - - // Inform relevant module - if (!this.#data.disabled) { - this.subscribe(true) - } else { - this.cleanup() - } - } - - /** - * Set the headline for this action - */ - setHeadline(headline: string): void { - this.#data.headline = headline - - // Don't need to resubscribe - } - - /** - * Set the connection instance of this action - */ - setInstance(instanceId: string | number): void { - const instance = `${instanceId}` - - // first unsubscribe action from old instance - this.cleanup() - // next change instance - this.#data.instance = instance - // last subscribe to new instance - this.subscribe(true, instance) - } - - /** - * Set the options for this action - */ - setOptions(options: Record): void { - this.#data.options = options - - // Inform relevant module - this.subscribe(false) - } - - /** - * Learn the options for a action, by asking the instance for the current values - */ - async learnOptions(): Promise { - const instance = this.#moduleHost.getChild(this.connectionId) - if (!instance) return false - - const newOptions = await instance.actionLearnValues(this.asActionInstance(), this.#controlId) - if (newOptions) { - this.setOptions(newOptions) - - return true - } - - return false - } - - /** - * Set an option for this action - */ - setOption(key: string, value: any): void { - this.#data.options[key] = value - - // Inform relevant module - this.subscribe(false) - } - - /** - * Find a child action by id - */ - findChildById(id: string): FragmentActionInstance | undefined { - for (const actionGroup of this.#children.values()) { - const result = actionGroup.findById(id) - if (result) return result - } - return undefined - } - - /** - * Find the index of a child action, and the parent list - */ - findParentAndIndex( - id: string - ): { parent: FragmentActionList; index: number; item: FragmentActionInstance } | undefined { - for (const actionGroup of this.#children.values()) { - const result = actionGroup.findParentAndIndex(id) - if (result) return result - } - return undefined - } - - /** - * Add a child action to this action - */ - addChild(groupId: string, action: ActionInstance): FragmentActionInstance { - if (this.connectionId !== 'internal') { - throw new Error('Only internal actions can have children') - } - - const actionGroup = this.#getOrCreateActionGroup(groupId) - return actionGroup.addAction(action) - } - - /** - * Remove a child action - */ - removeChild(id: string): boolean { - for (const actionGroup of this.#children.values()) { - if (actionGroup.removeAction(id)) return true - } - return false - } - - /** - * Duplicate a child action - */ - duplicateChild(id: string): FragmentActionInstance | undefined { - for (const actionGroup of this.#children.values()) { - const newAction = actionGroup.duplicateAction(id) - if (newAction) return newAction - } - return undefined - } - - // /** - // * Reorder a action in the list - // */ - // moveChild(groupId: string, oldIndex: number, newIndex: number): void { - // const actionGroup = this.#children.get(groupId) - // if (!actionGroup) return - - // return actionGroup.moveAction(oldIndex, newIndex) - // } - - // /** - // * Pop a child action from the list - // * Note: this is used when moving a action to a different parent. Lifecycle is not managed - // */ - // popChild(index: number): FragmentActionInstance | undefined { - // return this.#children.popAction(index) - // } - - /** - * Push a child action to the list - * Note: this is used when moving a action from a different parent. Lifecycle is not managed - */ - pushChild(action: FragmentActionInstance, groupId: string, index: number): void { - const actionGroup = this.#getOrCreateActionGroup(groupId) - return actionGroup.pushAction(action, index) - } - - /** - * Check if this list can accept a specified child - */ - canAcceptChild(groupId: string, action: FragmentActionInstance): boolean { - const actionGroup = this.#getOrCreateActionGroup(groupId) - return actionGroup.canAcceptAction(action) - } - - /** - * Recursively get all the actions - */ - getAllChildren(): FragmentActionInstance[] { - const actions: FragmentActionInstance[] = [] - - for (const actionGroup of this.#children.values()) { - actions.push(...actionGroup.getAllActions()) - } - - return actions - } - - /** - * Cleanup and forget any children belonging to the given connection - */ - forgetChildrenForConnection(connectionId: string): boolean { - let changed = false - for (const actionGroup of this.#children.values()) { - if (actionGroup.forgetForConnection(connectionId)) { - changed = true - } - } - return changed - } - - /** - * Prune all actions/feedbacks referencing unknown conncetions - * Doesn't do any cleanup, as it is assumed that the connection has not been running - */ - verifyChildConnectionIds(knownConnectionIds: Set): boolean { - let changed = false - for (const actionGroup of this.#children.values()) { - if (actionGroup.verifyConnectionIds(knownConnectionIds)) { - changed = true - } - } - return changed - } - - /** - * If this control was imported to a running system, do some data cleanup/validation - */ - postProcessImport(): Promise[] { - const ps: Promise[] = [] - - if (this.#data.instance === 'internal') { - const newProps = this.#internalModule.actionUpgrade(this.asActionInstance(), this.#controlId) - if (newProps) { - this.replaceProps(newProps, false) - } - - // setImmediate(() => { - // this.#internalModule.actionUpdate(this.asActionInstance(), this.#controlId) - // }) - } else { - const instance = this.#moduleHost.getChild(this.connectionId, true) - if (instance) { - ps.push(instance.actionUpdate(this.asActionInstance(), this.#controlId)) - } - } - - for (const childGroup of this.#children.values()) { - ps.push(...childGroup.postProcessImport()) - } - - return ps - } - - /** - * Replace portions of the action with an updated version - */ - replaceProps(newProps: Pick, skipNotifyModule = false): void { - this.#data.action = newProps.action // || newProps.actionId - this.#data.options = newProps.options - - delete this.#data.upgradeIndex - - if (!skipNotifyModule) { - this.subscribe(false) - } - } - - /** - * Visit any references in the current action - */ - visitReferences(visitor: InternalVisitor): void { - visitActionInstance(visitor, this.#data) - } -} diff --git a/companion/lib/Controls/Fragments/FragmentActionList.ts b/companion/lib/Controls/Fragments/FragmentActionList.ts deleted file mode 100644 index b5f2596aa3..0000000000 --- a/companion/lib/Controls/Fragments/FragmentActionList.ts +++ /dev/null @@ -1,297 +0,0 @@ -import { FragmentActionInstance } from './FragmentActionInstance.js' -import { clamp } from '../../Resources/Util.js' -import type { InstanceDefinitions } from '../../Instance/Definitions.js' -import type { InternalController } from '../../Internal/Controller.js' -import type { ModuleHost } from '../../Instance/Host.js' -import type { ActionInstance, ActionOwner } from '@companion-app/shared/Model/ActionModel.js' - -export class FragmentActionList { - readonly #instanceDefinitions: InstanceDefinitions - readonly #internalModule: InternalController - readonly #moduleHost: ModuleHost - - /** - * Id of the control this belongs to - */ - readonly #controlId: string - - readonly #ownerId: ActionOwner | null - - #actions: FragmentActionInstance[] = [] - - get ownerId(): ActionOwner | null { - return this.#ownerId - } - - constructor( - instanceDefinitions: InstanceDefinitions, - internalModule: InternalController, - moduleHost: ModuleHost, - controlId: string, - ownerId: ActionOwner | null - ) { - this.#instanceDefinitions = instanceDefinitions - this.#internalModule = internalModule - this.#moduleHost = moduleHost - this.#controlId = controlId - this.#ownerId = ownerId - } - - /** - * Get all the actions - */ - getAllActions(): FragmentActionInstance[] { - return [...this.#actions, ...this.#actions.flatMap((action) => action.getAllChildren())] - } - - /** - * Get the contained actions as `ActionInstance`s - */ - asActionInstances(): ActionInstance[] { - return this.#actions.map((action) => action.asActionInstance()) - } - - /** - * Initialise from storage - * @param actions - * @param skipSubscribe Whether to skip calling subscribe for the new actions - * @param isCloned Whether this is a cloned instance - */ - loadStorage(actions: ActionInstance[], skipSubscribe: boolean, isCloned: boolean): void { - // Inform modules of action cleanup - for (const action of this.#actions) { - action.cleanup() - } - - this.#actions = - actions?.map( - (action) => - new FragmentActionInstance( - this.#instanceDefinitions, - this.#internalModule, - this.#moduleHost, - this.#controlId, - action, - !!isCloned - ) - ) || [] - - if (!skipSubscribe) { - this.subscribe(true) - } - } - - /** - * Inform the instance of any removed/disabled actions - * @access public - */ - cleanup() { - for (const action of this.#actions) { - action.cleanup() - } - } - - /** - * Inform the instance of an updated action - * @param recursive whether to call recursively - * @param onlyConnectionId If set, only subscribe actions for this connection - */ - subscribe(recursive: boolean, onlyConnectionId?: string): void { - for (const child of this.#actions) { - child.subscribe(recursive, onlyConnectionId) - } - } - - /** - * Find a child action by id - */ - findById(id: string): FragmentActionInstance | undefined { - for (const action of this.#actions) { - if (action.id === id) return action - - const found = action.findChildById(id) - if (found) return found - } - - return undefined - } - - /** - * Find the index of a child action, and the parent list - */ - findParentAndIndex( - id: string - ): { parent: FragmentActionList; index: number; item: FragmentActionInstance } | undefined { - const index = this.#actions.findIndex((fb) => fb.id === id) - if (index !== -1) { - return { parent: this, index, item: this.#actions[index] } - } - - for (const action of this.#actions) { - const found = action.findParentAndIndex(id) - if (found) return found - } - - return undefined - } - - /** - * Add a child action to this action - * @param action - * @param isCloned Whether this is a cloned instance - */ - addAction(action: ActionInstance, isCloned?: boolean): FragmentActionInstance { - const newAction = new FragmentActionInstance( - this.#instanceDefinitions, - this.#internalModule, - this.#moduleHost, - this.#controlId, - action, - !!isCloned - ) - - this.#actions.push(newAction) - - return newAction - } - - /** - * Remove a child action - */ - removeAction(id: string): boolean { - const index = this.#actions.findIndex((fb) => fb.id === id) - if (index !== -1) { - const action = this.#actions[index] - this.#actions.splice(index, 1) - - action.cleanup() - - return true - } - - for (const action of this.#actions) { - if (action.removeChild(id)) return true - } - - return false - } - - /** - * Reorder a action in the list - */ - moveAction(oldIndex: number, newIndex: number): void { - oldIndex = clamp(oldIndex, 0, this.#actions.length) - newIndex = clamp(newIndex, 0, this.#actions.length) - this.#actions.splice(newIndex, 0, ...this.#actions.splice(oldIndex, 1)) - } - - /** - * Pop a child action from the list - * Note: this is used when moving a action to a different parent. Lifecycle is not managed - */ - popAction(index: number): FragmentActionInstance | undefined { - const action = this.#actions[index] - if (!action) return undefined - - this.#actions.splice(index, 1) - - return action - } - - /** - * Push a child action to the list - * Note: this is used when moving a action from a different parent. Lifecycle is not managed - */ - pushAction(action: FragmentActionInstance, index: number): void { - index = clamp(index, 0, this.#actions.length) - - this.#actions.splice(index, 0, action) - } - - /** - * Check if this list can accept a specified child - */ - canAcceptAction(action: FragmentActionInstance): boolean { - const definition = action.getDefinition() - if (!definition) return false - - return true - } - - /** - * Duplicate a action - */ - duplicateAction(id: string): FragmentActionInstance | undefined { - const actionIndex = this.#actions.findIndex((fb) => fb.id === id) - if (actionIndex !== -1) { - const actionInstance = this.#actions[actionIndex].asActionInstance() - const newAction = new FragmentActionInstance( - this.#instanceDefinitions, - this.#internalModule, - this.#moduleHost, - this.#controlId, - actionInstance, - true - ) - - this.#actions.splice(actionIndex + 1, 0, newAction) - - newAction.subscribe(true) - - return newAction - } - - for (const action of this.#actions) { - const newAction = action.duplicateChild(id) - if (newAction) return newAction - } - - return undefined - } - - /** - * Cleanup and forget any children belonging to the given connection - */ - forgetForConnection(connectionId: string): boolean { - let changed = false - - this.#actions = this.#actions.filter((action) => { - if (action.connectionId === connectionId) { - action.cleanup() - - return false - } else { - changed = action.forgetChildrenForConnection(connectionId) - return true - } - }) - - return changed - } - - /** - * Prune all actions/actions referencing unknown conncetions - * Doesn't do any cleanup, as it is assumed that the connection has not been running - */ - verifyConnectionIds(knownConnectionIds: Set): boolean { - // Clean out actions - const actionLength = this.#actions.length - this.#actions = this.#actions.filter((action) => !!action && knownConnectionIds.has(action.connectionId)) - let changed = this.#actions.length !== actionLength - - for (const action of this.#actions) { - if (action.verifyChildConnectionIds(knownConnectionIds)) { - changed = true - } - } - - return changed - } - - /** - * If this control was imported to a running system, do some data cleanup/validation - */ - postProcessImport(): Promise[] { - return this.#actions.flatMap((action) => action.postProcessImport()) - } -} diff --git a/companion/lib/Controls/Fragments/FragmentActions.ts b/companion/lib/Controls/Fragments/FragmentActions.ts deleted file mode 100644 index 8d2d5e7bb0..0000000000 --- a/companion/lib/Controls/Fragments/FragmentActions.ts +++ /dev/null @@ -1,603 +0,0 @@ -import LogController, { Logger } from '../../Log/Controller.js' -import type { - ActionInstance, - ActionOwner, - ActionSetId, - ActionSetsModel, - ActionStepOptions, -} from '@companion-app/shared/Model/ActionModel.js' -import type { ModuleHost } from '../../Instance/Host.js' -import type { InternalController } from '../../Internal/Controller.js' -import { FragmentActionList } from './FragmentActionList.js' -import type { FragmentActionInstance } from './FragmentActionInstance.js' -import type { InstanceDefinitions } from '../../Instance/Definitions.js' -import { validateActionSetId } from '@companion-app/shared/ControlId.js' - -/** - * Helper for ControlTypes with actions - * - * @author Håkon Nessjøen - * @author Keith Rocheck - * @author William Viker - * @author Julian Waller - * @since 3.0.0 - * @copyright 2022 Bitfocus AS - * @license - * This program is free software. - * You should have received a copy of the MIT licence as well as the Bitfocus - * Individual Contributor License Agreement for Companion along with - * this program. - * - * You can be released from the requirements of the license by purchasing - * a commercial license. Buying such a license is mandatory as soon as you - * develop commercial activities involving the Companion software without - * disclosing the source code of your own applications. - */ -export class FragmentActions { - /** - * The action-sets on this button - */ - #actions: Map = new Map() - - /** - */ - options!: ActionStepOptions - - /** - * Commit changes to the database and disk - */ - readonly #commitChange: (redraw?: boolean) => void - - /** - * The logger - */ - readonly #logger: Logger - readonly #instanceDefinitions: InstanceDefinitions - readonly #internalModule: InternalController - readonly #moduleHost: ModuleHost - readonly #controlId: string - - constructor( - instanceDefinitions: InstanceDefinitions, - internalModule: InternalController, - moduleHost: ModuleHost, - controlId: string, - commitChange: (redraw?: boolean) => void - ) { - this.#logger = LogController.createLogger(`Controls/Fragments/Actions/${controlId}`) - - this.#instanceDefinitions = instanceDefinitions - this.#internalModule = internalModule - this.#moduleHost = moduleHost - - this.#actions.set(0, new FragmentActionList(instanceDefinitions, internalModule, moduleHost, controlId, null)) - - this.#controlId = controlId - this.#commitChange = commitChange - } - - /** - * Initialise from storage - * @param actions - * @param skipSubscribe Whether to skip calling subscribe for the new feedbacks - * @param isCloned Whether this is a cloned instance - */ - loadStorage(actions: ActionSetsModel, skipSubscribe?: boolean, isCloned?: boolean) { - for (const list of this.#actions.values()) { - list.cleanup() - } - - this.#actions.clear() - - for (const [key, value] of Object.entries(actions)) { - if (!value) continue - - const keySafe = validateActionSetId(key as any) - if (keySafe === undefined) { - this.#logger.error(`Invalid action set id ${key}`) - continue - } - - const newList = new FragmentActionList( - this.#instanceDefinitions, - this.#internalModule, - this.#moduleHost, - this.#controlId, - null - ) - newList.loadStorage(value, !!skipSubscribe, !!isCloned) - this.#actions.set(keySafe, newList) - } - } - - /** - * Add an action to this control - */ - actionAdd(setId: ActionSetId, actionItem: ActionInstance, ownerId: ActionOwner | null): boolean { - const actionSet = this.#actions.get(setId) - if (!actionSet) { - // cant implicitly create a set - this.#logger.silly(`Missing set ${this.#controlId}:${setId}`) - return false - } - - let newAction: FragmentActionInstance - if (ownerId) { - const parent = actionSet.findById(ownerId.parentActionId) - if (!parent) throw new Error(`Failed to find parent action ${ownerId.parentActionId} when adding child action`) - - newAction = parent.addChild(ownerId.childGroup, actionItem) - } else { - newAction = actionSet.addAction(actionItem) - } - - // Inform relevant module - newAction.subscribe(true) - - this.#commitChange(false) - return true - } - - getActionSet(setId: ActionSetId): FragmentActionList | undefined { - return this.#actions.get(setId) - } - - getActionSetIds(): Array { - return Array.from(this.#actions.keys()) - } - - setupRotaryActionSets(ensureCreated: boolean, skipCommit?: boolean): void { - if (ensureCreated) { - // ensure they exist - if (!this.#actions.has('rotate_left')) - this.#actions.set( - 'rotate_left', - new FragmentActionList( - this.#instanceDefinitions, - this.#internalModule, - this.#moduleHost, - this.#controlId, - null - ) - ) - if (!this.#actions.has('rotate_right')) - this.#actions.set( - 'rotate_right', - new FragmentActionList( - this.#instanceDefinitions, - this.#internalModule, - this.#moduleHost, - this.#controlId, - null - ) - ) - } else { - // remove the sets - const rotateLeftSet = this.#actions.get('rotate_left') - const rotateRightSet = this.#actions.get('rotate_right') - - if (rotateLeftSet) { - rotateLeftSet.cleanup() - this.#actions.delete('rotate_left') - } - if (rotateRightSet) { - rotateRightSet.cleanup() - this.#actions.delete('rotate_right') - } - } - - if (!skipCommit) this.#commitChange() - } - - actionSetAdd(): number { - const existingKeys = Array.from(this.#actions.keys()) - .map((k) => Number(k)) - .filter((k) => !isNaN(k)) - if (existingKeys.length === 0) { - // add the default '1000' set - this.#actions.set( - 1000, - new FragmentActionList(this.#instanceDefinitions, this.#internalModule, this.#moduleHost, this.#controlId, null) - ) - - this.#commitChange(true) - - return 1000 - } else { - // add one after the last - const max = Math.max(...existingKeys) - const newIndex = Math.floor(max / 1000) * 1000 + 1000 - - this.#actions.set( - newIndex, - new FragmentActionList(this.#instanceDefinitions, this.#internalModule, this.#moduleHost, this.#controlId, null) - ) - - this.#commitChange(false) - - return newIndex - } - } - - actionSetRemove(setId: number): boolean { - const setToRemove = this.#actions.get(setId) - if (!setToRemove) return false - - // Inform modules of the change - setToRemove.cleanup() - - // Forget the step from the options - this.options.runWhileHeld = this.options.runWhileHeld.filter((id) => id !== Number(setId)) - - // Assume it exists - this.#actions.delete(setId) - - // Save the change, and perform a draw - this.#commitChange(false) - - return true - } - - actionSetRename(oldSetId: number, newSetId: number): boolean { - // Ensure old set exists - const oldSet = this.#actions.get(oldSetId) - if (!oldSet) return false - - // Ensure new set doesnt already exist - if (this.#actions.has(newSetId)) return false - - this.#actions.set(newSetId, oldSet) - this.#actions.delete(oldSetId) - - const runWhileHeldIndex = this.options.runWhileHeld.indexOf(Number(oldSetId)) - if (runWhileHeldIndex !== -1) { - this.options.runWhileHeld[runWhileHeldIndex] = Number(newSetId) - } - - return true - } - - /** - * Append some actions to this button - * @param setId the action_set id to update - * @param newActions actions to append - */ - actionAppend(setId: ActionSetId, newActions: ActionInstance[], ownerId: ActionOwner | null): boolean { - const actionSet = this.#actions.get(setId) - if (!actionSet) { - // cant implicitly create a set - this.#logger.silly(`Missing set ${this.#controlId}:${setId}`) - return false - } - - if (newActions.length === 0) return true - - let newActionInstances: FragmentActionInstance[] - if (ownerId) { - const parent = actionSet.findById(ownerId.parentActionId) - if (!parent) throw new Error(`Failed to find parent action ${ownerId.parentActionId} when adding child action`) - - newActionInstances = newActions.map((actionItem) => parent.addChild(ownerId.childGroup, actionItem)) - } else { - newActionInstances = newActions.map((actionItem) => actionSet.addAction(actionItem)) - } - - for (const action of newActionInstances) { - // Inform relevant module - action.subscribe(true) - } - - this.#commitChange(false) - - return false - } - - /** - * Duplicate an action on this control - */ - actionDuplicate(setId: ActionSetId, id: string): string | null { - const actionSet = this.#actions.get(setId) - if (!actionSet) return null - - const newAction = actionSet.duplicateAction(id) - if (!newAction) return null - - this.#commitChange(false) - - return newAction.id - } - - /** - * Enable or disable an action - */ - actionEnabled(setId: ActionSetId, id: string, enabled: boolean): boolean { - const actionSet = this.#actions.get(setId) - if (!actionSet) return false - - const action = actionSet.findById(id) - if (!action) return false - - action.setEnabled(enabled) - - this.#commitChange(false) - - return true - } - - /** - * Set action headline - */ - actionHeadline(setId: ActionSetId, id: string, headline: string): boolean { - const actionSet = this.#actions.get(setId) - if (!actionSet) return false - - const action = actionSet.findById(id) - if (!action) return false - - action.setHeadline(headline) - - this.#commitChange(false) - - return true - } - - /** - * Learn the options for an action, by asking the instance for the current values - * @param setId the id of the action set - * @param id the id of the action - */ - async actionLearn(setId: ActionSetId, id: string): Promise { - const actionSet = this.#actions.get(setId) - if (!actionSet) return false - - const action = actionSet.findById(id) - if (!action) return false - - const changed = await action.learnOptions() - if (!changed) return false - - // Time has passed due to the `await` - // So the action may not still exist, meaning we should find it again to be sure - const actionAfter = actionSet.findById(id) - if (!actionAfter) return false - - this.#commitChange(true) - return true - } - - /** - * Remove an action from this control - * @param setId the id of the action set - * @param id the id of the action - */ - actionRemove(setId: ActionSetId, id: string): boolean { - const actionSet = this.#actions.get(setId) - if (!actionSet) return false - - if (!actionSet.removeAction(id)) return false - - this.#commitChange(false) - - return true - } - - /** - * Replace a action with an updated version - */ - actionReplace(newProps: Pick, skipNotifyModule = false): boolean { - for (const actionSet of this.#actions.values()) { - const action = actionSet.findById(newProps.id) - if (!action) return false - - action.replaceProps(newProps, skipNotifyModule) - - this.#commitChange(false) - - return true - } - - return false - } - - /** - * Find a child feedback by id - */ - findChildById(setId: ActionSetId, id: string): FragmentActionInstance | undefined { - return this.#actions.get(setId)?.findById(id) - } - - /** - * Find the index of a child feedback, and the parent list - */ - findParentAndIndex( - setId: ActionSetId, - id: string - ): { parent: FragmentActionList; index: number; item: FragmentActionInstance } | undefined { - return this.#actions.get(setId)?.findParentAndIndex(id) - } - - /** - * Replace all the actions in a set - * @param setId the action_set id to update - * @param newActions actions to populate - */ - actionReplaceAll(setId: ActionSetId, newActions: ActionInstance[]): boolean { - const actionSet = this.#actions.get(setId) - if (!actionSet) return false - - actionSet.loadStorage(newActions, false, false) - - this.#commitChange(false) - - return true - } - - /** - * Set the connection of an action - * @param setId the action_set id - * @param id the action id - * @param connectionId the id of the new connection - */ - actionSetConnection(setId: ActionSetId, id: string, connectionId: string): boolean { - if (connectionId == '') return false - - const actionSet = this.#actions.get(setId) - if (!actionSet) return false - - const action = actionSet.findById(id) - if (!action) return false - - action.setInstance(connectionId) - - this.#commitChange() - - return true - } - - /** - * Set an option of an action - * @param setId the action_set id - * @param id the action id - * @param key the desired option to set - * @param value the new value of the option - */ - actionSetOption(setId: ActionSetId, id: string, key: string, value: any): boolean { - const actionSet = this.#actions.get(setId) - if (!actionSet) return false - - const action = actionSet.findById(id) - if (!action) return false - - action.setOption(key, value) - - this.#commitChange(false) - - return true - } - - /** - * Prepare this control for deletion - */ - destroy(): void { - // Inform modules of action cleanup - for (const list of this.#actions.values()) { - list.cleanup() - } - - this.#actions.clear() - } - - /** - * Remove any actions referencing a specified connectionId - */ - forgetConnection(connectionId: string): boolean { - let changed = false - - for (const list of this.#actions.values()) { - if (list.forgetForConnection(connectionId)) { - changed = true - } - } - - return changed - } - - /** - * Get all the actions contained here - */ - getAllActionInstances(): ActionInstance[] { - return Array.from(this.#actions.values()).flatMap((list) => list.asActionInstances()) - } - - /** - * Get all the actions contained here - */ - getAllActions(): FragmentActionInstance[] { - return Array.from(this.#actions.values()).flatMap((list) => list.getAllActions()) - } - - asActionStepModel(): ActionSetsModel { - const actions: ActionSetsModel = { - down: undefined, - up: undefined, - rotate_left: undefined, - rotate_right: undefined, - } - - for (const [key, list] of this.#actions) { - actions[key] = list.asActionInstances() - } - - return actions - } - - /** - * Get all the action instances - * @param onlyConnectionId Optionally, only for a specific connection - * @returns {} - */ - getFlattenedActionInstances(onlyConnectionId?: string): Omit[] { - const instances: ActionInstance[] = [] - - const extractInstances = (actions: ActionInstance[]) => { - for (const action of actions) { - if (!onlyConnectionId || onlyConnectionId === action.instance) { - instances.push({ - ...action, - children: undefined, - }) - } - - if (action.children) { - for (const actions of Object.values(action.children)) { - if (!actions) continue - - extractInstances(actions) - } - } - } - } - - for (const list of this.#actions.values()) { - extractInstances(list.asActionInstances()) - } - - return instances - } - - /** - * If this control was imported to a running system, do some data cleanup/validation - */ - async postProcessImport(): Promise { - await Promise.all(Array.from(this.#actions.values()).flatMap((actionSet) => actionSet.postProcessImport())).catch( - (e) => { - this.#logger.silly(`postProcessImport for ${this.#controlId} failed: ${e.message}`) - } - ) - } - - /** - * Prune all actions/feedbacks referencing unknown connections - * Doesn't do any cleanup, as it is assumed that the connection has not been running - * @returns Whether any changes were made - */ - verifyConnectionIds(knownConnectionIds: Set): boolean { - let changed = false - - for (const list of this.#actions.values()) { - if (list.verifyConnectionIds(knownConnectionIds)) { - changed = true - } - } - - return changed - } - - /** - * Rename this control - * @param newName the new name - */ - rename(newName: string): void { - this.options.name = newName - } -} diff --git a/companion/lib/Controls/Fragments/FragmentFeedbackInstance.ts b/companion/lib/Controls/Fragments/FragmentFeedbackInstance.ts deleted file mode 100644 index 3d5df00e15..0000000000 --- a/companion/lib/Controls/Fragments/FragmentFeedbackInstance.ts +++ /dev/null @@ -1,548 +0,0 @@ -import { cloneDeep, isEqual } from 'lodash-es' -import { nanoid } from 'nanoid' -import LogController, { Logger } from '../../Log/Controller.js' -import { FragmentFeedbackList } from './FragmentFeedbackList.js' -import { visitFeedbackInstance } from '../../Resources/Visitors/FeedbackInstanceVisitor.js' -import type { InstanceDefinitions } from '../../Instance/Definitions.js' -import type { InternalController } from '../../Internal/Controller.js' -import type { ModuleHost } from '../../Instance/Host.js' -import type { FeedbackInstance } from '@companion-app/shared/Model/FeedbackModel.js' -import type { ButtonStyleProperties } from '@companion-app/shared/Model/StyleModel.js' -import type { CompanionButtonStyleProps } from '@companion-module/base' -import type { InternalVisitor } from '../../Internal/Types.js' -import type { FeedbackDefinition } from '@companion-app/shared/Model/FeedbackDefinitionModel.js' - -export class FragmentFeedbackInstance { - /** - * The logger - */ - readonly #logger: Logger - - readonly #instanceDefinitions: InstanceDefinitions - readonly #internalModule: InternalController - readonly #moduleHost: ModuleHost - - /** - * Id of the control this belongs to - */ - readonly #controlId: string - - readonly #data: Omit - - #children: FragmentFeedbackList - - /** - * Value of the feedback when it was last executed - */ - #cachedValue: any = undefined - - /** - * Get the id of this feedback instance - */ - get id(): string { - return this.#data.id - } - - get disabled(): boolean { - return !!this.#data.disabled - } - - /** - * Get the id of the connection this feedback belongs to - */ - get connectionId(): string { - return this.#data.instance_id - } - - get cachedValue(): any { - return this.#cachedValue - } - - /** - * Get a reference to the options for this feedback - * Note: This must not be a copy, but the raw object - */ - get rawOptions() { - return this.#data.options - } - - /** - * @param instanceDefinitions - * @param internalModule - * @param moduleHost - * @param controlId - id of the control - * @param data - * @param isCloned Whether this is a cloned instance and should generate new ids - */ - constructor( - instanceDefinitions: InstanceDefinitions, - internalModule: InternalController, - moduleHost: ModuleHost, - controlId: string, - data: FeedbackInstance, - isCloned: boolean - ) { - this.#logger = LogController.createLogger(`Controls/Fragments/Feedbacks/${controlId}`) - - this.#instanceDefinitions = instanceDefinitions - this.#internalModule = internalModule - this.#moduleHost = moduleHost - this.#controlId = controlId - - this.#data = cloneDeep(data) // TODO - cleanup unwanted properties - if (!this.#data.options) this.#data.options = {} - - if (isCloned) { - this.#data.id = nanoid() - } - - this.#children = new FragmentFeedbackList( - this.#instanceDefinitions, - this.#internalModule, - this.#moduleHost, - this.#controlId, - this.id, - true - ) - if (data.instance_id === 'internal' && data.children) { - this.#children.loadStorage(data.children, true, isCloned) - } - } - - /** - * Get this feedback as a `FeedbackInstance` - */ - asFeedbackInstance(): FeedbackInstance { - return { - ...this.#data, - children: this.connectionId === 'internal' ? this.#children.asFeedbackInstances() : undefined, - } - } - - /** - * Get the definition for this feedback - */ - getDefinition(): FeedbackDefinition | undefined { - return this.#instanceDefinitions.getFeedbackDefinition(this.#data.instance_id, this.#data.type) - } - - /** - * Get the value of this feedback as a boolean - */ - getBooleanValue(): boolean { - if (this.#data.disabled) return false - - const definition = this.getDefinition() - if (!definition || definition.type !== 'boolean') return false - - // Special case to handle the internal 'logic' operators, which need to be executed live - if (this.connectionId === 'internal' && this.#data.type.startsWith('logic_')) { - // Future: This could probably be made a bit more generic by checking `definition.supportsChildFeedbacks` - const childValues = this.#children.getChildBooleanValues() - - return this.#internalModule.executeLogicFeedback(this.asFeedbackInstance(), childValues) - } - - if (typeof this.#cachedValue === 'boolean') { - if (definition.showInvert && this.#data.isInverted) return !this.#cachedValue - - return this.#cachedValue - } else { - // An invalid value is falsey, it probably means that the feedback has no value - return false - } - } - - /** - * Inform the instance of a removed/disabled feedback - */ - cleanup() { - // Inform relevant module - if (this.#data.instance_id === 'internal') { - this.#internalModule.feedbackDelete(this.asFeedbackInstance()) - } else { - const connection = this.#moduleHost.getChild(this.#data.instance_id, true) - if (connection) { - connection.feedbackDelete(this.asFeedbackInstance()).catch((e) => { - this.#logger.silly(`feedback_delete to connection failed: ${e.message}`) - }) - } - } - - // Remove from cached feedback values - this.#cachedValue = undefined - - this.#children.cleanup() - } - - /** - * Inform the instance of an updated feedback - * @param recursive whether to call recursively - * @param onlyConnectionId If set, only subscribe feedbacks for this connection - */ - subscribe(recursive: boolean, onlyConnectionId?: string): void { - if (this.#data.disabled) return - - if (!onlyConnectionId || this.#data.instance_id === onlyConnectionId) { - if (this.#data.instance_id === 'internal') { - this.#internalModule.feedbackUpdate(this.asFeedbackInstance(), this.#controlId) - } else { - const connection = this.#moduleHost.getChild(this.#data.instance_id, true) - if (connection) { - connection.feedbackUpdate(this.asFeedbackInstance(), this.#controlId).catch((e) => { - this.#logger.silly(`feedback_update to connection failed: ${e.message} ${e.stack}`) - }) - } - } - } - - if (recursive) { - this.#children.subscribe(recursive, onlyConnectionId) - } - } - - /** - * Set whether this feedback is enabled - */ - setEnabled(enabled: boolean): void { - this.#data.disabled = !enabled - - // Remove from cached feedback values - this.#cachedValue = undefined - - // Inform relevant module - if (!this.#data.disabled) { - this.subscribe(true) - } else { - this.cleanup() - } - } - - /** - * Set the headline for this feedback - */ - setHeadline(headline: string): void { - this.#data.headline = headline - - // Don't need to resubscribe - // Don't need to clear cached value - } - - /** - * Set the connection instance of this feedback - */ - setInstance(instanceId: string | number): void { - const instance = `${instanceId}` - - // first unsubscribe feedback from old instance - this.cleanup() - // next change instance - this.#data.instance_id = instance - // last subscribe to new instance - this.subscribe(true, instance) - } - - /** - * Set whether this feedback is inverted - */ - setInverted(isInverted: boolean): void { - // TODO - verify this is a boolean feedback - - this.#data.isInverted = isInverted - - // Don't need to resubscribe - // Don't need to clear cached value - } - - /** - * Set the options for this feedback - */ - setOptions(options: Record): void { - this.#data.options = options - - // Remove from cached feedback values - this.#cachedValue = undefined - - // Inform relevant module - this.subscribe(false) - } - - /** - * Learn the options for a feedback, by asking the instance for the current values - */ - async learnOptions(): Promise { - const instance = this.#moduleHost.getChild(this.connectionId) - if (!instance) return false - - const newOptions = await instance.feedbackLearnValues(this.asFeedbackInstance(), this.#controlId) - if (newOptions) { - this.setOptions(newOptions) - - return true - } - - return false - } - - /** - * Set an option for this feedback - */ - setOption(key: string, value: any): void { - this.#data.options[key] = value - - // Remove from cached feedback values - this.#cachedValue = undefined - - // Inform relevant module - this.subscribe(false) - } - - /** - * Update an style property for a boolean feedback - * @param key the key/name of the property - * @param value the new value - * @returns success - */ - setStyleValue(key: string, value: any): boolean { - if (key === 'png64' && value !== null) { - if (!value.match(/data:.*?image\/png/)) { - return false - } - - value = value.replace(/^.*base64,/, '') - } - - const definition = this.getDefinition() - if (!definition || definition.type !== 'boolean') return false - - if (!this.#data.style) this.#data.style = {} - // @ts-ignore - this.#data.style[key] = value - - return true - } - - /** - * Update the selected style properties for a boolean feedback - * @param selected the properties to be selected - * @param baseStyle Style of the button without feedbacks applied - * @returns success - * @access public - */ - setStyleSelection(selected: string[], baseStyle: ButtonStyleProperties): boolean { - const definition = this.getDefinition() - if (!definition || definition.type !== 'boolean') return false - - const defaultStyle: Partial = definition.style || {} - const oldStyle: Record = this.#data.style || {} - const newStyle: Record = {} - - for (const key of selected) { - if (key in oldStyle) { - // preserve existing value - newStyle[key] = oldStyle[key] - } else { - // copy button value as a default - // @ts-ignore - newStyle[key] = defaultStyle[key] !== undefined ? defaultStyle[key] : baseStyle[key] - - // png needs to be set to something harmless - if (key === 'png64' && !newStyle[key]) { - newStyle[key] = null - } - } - - if (key === 'text') { - // also preserve textExpression - newStyle['textExpression'] = - oldStyle['textExpression'] ?? - /*defaultStyle['textExpression'] !== undefined - ? defaultStyle['textExpression'] - : */ baseStyle['textExpression'] - } - } - this.#data.style = newStyle - - return true - } - - /** - * Set the cached value of this feedback - */ - setCachedValue(value: any): boolean { - if (!isEqual(value, this.#cachedValue)) { - this.#cachedValue = value - return true - } else { - return false - } - } - - /** - * Clear cached values for any feedback belonging to the given connection - * @returns Whether a value was changed - */ - clearCachedValueForConnectionId(connectionId: string): boolean { - let changed = false - - if (this.#data.instance_id === connectionId) { - this.#cachedValue = undefined - - changed = true - } - - if (this.#children.clearCachedValueForConnectionId(connectionId)) { - changed = true - } - - return changed - } - - /** - * Find a child feedback by id - */ - findChildById(id: string): FragmentFeedbackInstance | undefined { - return this.#children.findById(id) - } - - /** - * Find the index of a child feedback, and the parent list - */ - findParentAndIndex( - id: string - ): { parent: FragmentFeedbackList; index: number; item: FragmentFeedbackInstance } | undefined { - return this.#children.findParentAndIndex(id) - } - - /** - * Add a child feedback to this feedback - */ - addChild(feedback: FeedbackInstance): FragmentFeedbackInstance { - if (this.connectionId !== 'internal') { - throw new Error('Only internal feedbacks can have children') - } - - return this.#children.addFeedback(feedback) - } - - /** - * Remove a child feedback - */ - removeChild(id: string): boolean { - return this.#children.removeFeedback(id) - } - - /** - * Duplicate a child feedback - */ - duplicateChild(id: string): FragmentFeedbackInstance | undefined { - return this.#children.duplicateFeedback(id) - } - - // /** - // * Reorder a feedback in the list - // */ - // moveChild(oldIndex: number, newIndex: number): void { - // return this.#children.moveFeedback(oldIndex, newIndex) - // } - - // /** - // * Pop a child feedback from the list - // * Note: this is used when moving a feedback to a different parent. Lifecycle is not managed - // */ - // popChild(index: number): FragmentFeedbackInstance | undefined { - // return this.#children.popFeedback(index) - // } - - /** - * Push a child feedback to the list - * Note: this is used when moving a feedback from a different parent. Lifecycle is not managed - */ - pushChild(feedback: FragmentFeedbackInstance, index: number): void { - return this.#children.pushFeedback(feedback, index) - } - - /** - * Check if this list can accept a specified child - */ - canAcceptChild(feedback: FragmentFeedbackInstance): boolean { - return this.#children.canAcceptFeedback(feedback) - } - - /** - * Recursively get all the feedbacks - */ - getAllChildren(): FragmentFeedbackInstance[] { - return this.#children.getAllFeedbacks() - } - - /** - * Cleanup and forget any children belonging to the given connection - */ - forgetChildrenForConnection(connectionId: string): boolean { - return this.#children.forgetForConnection(connectionId) - } - - /** - * Prune all actions/feedbacks referencing unknown conncetions - * Doesn't do any cleanup, as it is assumed that the connection has not been running - */ - verifyChildConnectionIds(knownConnectionIds: Set): boolean { - return this.#children.verifyConnectionIds(knownConnectionIds) - } - - /** - * If this control was imported to a running system, do some data cleanup/validation - */ - postProcessImport(): Promise[] { - const ps: Promise[] = [] - - if (this.#data.instance_id === 'internal') { - const newProps = this.#internalModule.feedbackUpgrade(this.asFeedbackInstance(), this.#controlId) - if (newProps) { - this.replaceProps(newProps, false) - } - - setImmediate(() => { - this.#internalModule.feedbackUpdate(this.asFeedbackInstance(), this.#controlId) - }) - } else { - const instance = this.#moduleHost.getChild(this.connectionId, true) - if (instance) { - ps.push(instance.feedbackUpdate(this.asFeedbackInstance(), this.#controlId)) - } - } - - ps.push(...this.#children.postProcessImport()) - - return ps - } - - /** - * Replace portions of the feedback with an updated version - */ - replaceProps( - newProps: Pick, - skipNotifyModule = false - ): void { - this.#data.type = newProps.type // || newProps.feedbackId - this.#data.options = newProps.options - this.#data.isInverted = !!newProps.isInverted - - delete this.#data.upgradeIndex - - // Preserve existing value if it is set - this.#data.style = Object.keys(this.#data.style || {}).length > 0 ? this.#data.style : newProps.style - - if (!skipNotifyModule) { - this.subscribe(false) - } - } - - /** - * Visit any references in the current feedback - */ - visitReferences(visitor: InternalVisitor): void { - visitFeedbackInstance(visitor, this.#data) - } -} diff --git a/companion/lib/Controls/Fragments/FragmentFeedbackList.ts b/companion/lib/Controls/Fragments/FragmentFeedbackList.ts deleted file mode 100644 index c2969c47cf..0000000000 --- a/companion/lib/Controls/Fragments/FragmentFeedbackList.ts +++ /dev/null @@ -1,418 +0,0 @@ -import { FragmentFeedbackInstance } from './FragmentFeedbackInstance.js' -import { clamp } from '../../Resources/Util.js' -import type { InstanceDefinitions } from '../../Instance/Definitions.js' -import type { InternalController } from '../../Internal/Controller.js' -import type { ModuleHost } from '../../Instance/Host.js' -import type { FeedbackInstance } from '@companion-app/shared/Model/FeedbackModel.js' -import type { ButtonStyleProperties, UnparsedButtonStyle } from '@companion-app/shared/Model/StyleModel.js' - -export class FragmentFeedbackList { - readonly #instanceDefinitions: InstanceDefinitions - readonly #internalModule: InternalController - readonly #moduleHost: ModuleHost - - /** - * Id of the control this belongs to - */ - readonly #controlId: string - - readonly #id: string | null - - /** - * Whether this set of feedbacks can only use boolean feedbacks - */ - readonly #booleanOnly: boolean - - #feedbacks: FragmentFeedbackInstance[] = [] - - get id(): string | null { - return this.#id - } - - constructor( - instanceDefinitions: InstanceDefinitions, - internalModule: InternalController, - moduleHost: ModuleHost, - controlId: string, - id: string | null, - booleanOnly: boolean - ) { - this.#instanceDefinitions = instanceDefinitions - this.#internalModule = internalModule - this.#moduleHost = moduleHost - this.#controlId = controlId - this.#id = id - this.#booleanOnly = booleanOnly - } - - /** - * Get all the feedbacks - */ - getAllFeedbacks(): FragmentFeedbackInstance[] { - return [...this.#feedbacks, ...this.#feedbacks.flatMap((feedback) => feedback.getAllChildren())] - } - - /** - * Get the contained feedbacks as `FeedbackInstance`s - */ - asFeedbackInstances(): FeedbackInstance[] { - return this.#feedbacks.map((feedback) => feedback.asFeedbackInstance()) - } - - /** - * Get the value of this feedback as a boolean - */ - getBooleanValue(): boolean { - if (!this.#booleanOnly) throw new Error('FragmentFeedbacks is setup to use styles') - - let result = true - - for (const feedback of this.#feedbacks) { - if (feedback.disabled) continue - - result = result && feedback.getBooleanValue() - } - - return result - } - - getChildBooleanValues(): boolean[] { - if (!this.#booleanOnly) throw new Error('FragmentFeedbacks is setup to use styles') - - const values: boolean[] = [] - - for (const feedback of this.#feedbacks) { - if (feedback.disabled) continue - - values.push(feedback.getBooleanValue()) - } - - return values - } - - /** - * Initialise from storage - * @param feedbacks - * @param skipSubscribe Whether to skip calling subscribe for the new feedbacks - * @param isCloned Whether this is a cloned instance - */ - loadStorage(feedbacks: FeedbackInstance[], skipSubscribe: boolean, isCloned: boolean): void { - // Inform modules of feedback cleanup - for (const feedback of this.#feedbacks) { - feedback.cleanup() - } - - this.#feedbacks = - feedbacks?.map( - (feedback) => - new FragmentFeedbackInstance( - this.#instanceDefinitions, - this.#internalModule, - this.#moduleHost, - this.#controlId, - feedback, - !!isCloned - ) - ) || [] - - if (!skipSubscribe) { - this.subscribe(true) - } - } - - /** - * Inform the instance of any removed/disabled feedbacks - * @access public - */ - cleanup() { - for (const feedback of this.#feedbacks) { - feedback.cleanup() - } - } - - /** - * Inform the instance of an updated feedback - * @param recursive whether to call recursively - * @param onlyConnectionId If set, only subscribe feedbacks for this connection - */ - subscribe(recursive: boolean, onlyConnectionId?: string): void { - for (const child of this.#feedbacks) { - child.subscribe(recursive, onlyConnectionId) - } - } - - /** - * Clear cached values for any feedback belonging to the given connection - * @returns Whether a value was changed - */ - clearCachedValueForConnectionId(connectionId: string): boolean { - let changed = false - - for (const feedback of this.#feedbacks) { - if (feedback.clearCachedValueForConnectionId(connectionId)) { - changed = true - } - } - - return changed - } - - /** - * Find a child feedback by id - */ - findById(id: string): FragmentFeedbackInstance | undefined { - for (const feedback of this.#feedbacks) { - if (feedback.id === id) return feedback - - const found = feedback.findChildById(id) - if (found) return found - } - - return undefined - } - - /** - * Find the index of a child feedback, and the parent list - */ - findParentAndIndex( - id: string - ): { parent: FragmentFeedbackList; index: number; item: FragmentFeedbackInstance } | undefined { - const index = this.#feedbacks.findIndex((fb) => fb.id === id) - if (index !== -1) { - return { parent: this, index, item: this.#feedbacks[index] } - } - - for (const feedback of this.#feedbacks) { - const found = feedback.findParentAndIndex(id) - if (found) return found - } - - return undefined - } - - /** - * Add a child feedback to this feedback - * @param feedback - * @param isCloned Whether this is a cloned instance - */ - addFeedback(feedback: FeedbackInstance, isCloned?: boolean): FragmentFeedbackInstance { - const newFeedback = new FragmentFeedbackInstance( - this.#instanceDefinitions, - this.#internalModule, - this.#moduleHost, - this.#controlId, - feedback, - !!isCloned - ) - - // TODO - verify that the feedback matches this.#booleanOnly? - - this.#feedbacks.push(newFeedback) - - return newFeedback - } - - /** - * Remove a child feedback - */ - removeFeedback(id: string): boolean { - const index = this.#feedbacks.findIndex((fb) => fb.id === id) - if (index !== -1) { - const feedback = this.#feedbacks[index] - this.#feedbacks.splice(index, 1) - - feedback.cleanup() - - return true - } - - for (const feedback of this.#feedbacks) { - if (feedback.removeChild(id)) return true - } - - return false - } - - /** - * Reorder a feedback in the list - */ - moveFeedback(oldIndex: number, newIndex: number): void { - oldIndex = clamp(oldIndex, 0, this.#feedbacks.length) - newIndex = clamp(newIndex, 0, this.#feedbacks.length) - this.#feedbacks.splice(newIndex, 0, ...this.#feedbacks.splice(oldIndex, 1)) - } - - /** - * Pop a child feedback from the list - * Note: this is used when moving a feedback to a different parent. Lifecycle is not managed - */ - popFeedback(index: number): FragmentFeedbackInstance | undefined { - const feedback = this.#feedbacks[index] - if (!feedback) return undefined - - this.#feedbacks.splice(index, 1) - - return feedback - } - - /** - * Push a child feedback to the list - * Note: this is used when moving a feedback from a different parent. Lifecycle is not managed - */ - pushFeedback(feedback: FragmentFeedbackInstance, index: number): void { - index = clamp(index, 0, this.#feedbacks.length) - - this.#feedbacks.splice(index, 0, feedback) - } - - /** - * Check if this list can accept a specified child - */ - canAcceptFeedback(feedback: FragmentFeedbackInstance): boolean { - if (!this.#booleanOnly) return true - - const definition = feedback.getDefinition() - if (!definition || definition.type !== 'boolean') return false - - return true - } - - /** - * Duplicate a feedback - */ - duplicateFeedback(id: string): FragmentFeedbackInstance | undefined { - const feedbackIndex = this.#feedbacks.findIndex((fb) => fb.id === id) - if (feedbackIndex !== -1) { - const feedbackInstance = this.#feedbacks[feedbackIndex].asFeedbackInstance() - const newFeedback = new FragmentFeedbackInstance( - this.#instanceDefinitions, - this.#internalModule, - this.#moduleHost, - this.#controlId, - feedbackInstance, - true - ) - - this.#feedbacks.splice(feedbackIndex + 1, 0, newFeedback) - - newFeedback.subscribe(true) - - return newFeedback - } - - for (const feedback of this.#feedbacks) { - const newFeedback = feedback.duplicateChild(id) - if (newFeedback) return newFeedback - } - - return undefined - } - - /** - * Cleanup and forget any children belonging to the given connection - */ - forgetForConnection(connectionId: string): boolean { - let changed = false - - this.#feedbacks = this.#feedbacks.filter((feedback) => { - if (feedback.connectionId === connectionId) { - feedback.cleanup() - - return false - } else { - changed = feedback.forgetChildrenForConnection(connectionId) - return true - } - }) - - return changed - } - - /** - * Prune all actions/feedbacks referencing unknown conncetions - * Doesn't do any cleanup, as it is assumed that the connection has not been running - */ - verifyConnectionIds(knownConnectionIds: Set): boolean { - // Clean out feedbacks - const feedbackLength = this.#feedbacks.length - this.#feedbacks = this.#feedbacks.filter((feedback) => !!feedback && knownConnectionIds.has(feedback.connectionId)) - let changed = this.#feedbacks.length !== feedbackLength - - for (const feedback of this.#feedbacks) { - if (feedback.verifyChildConnectionIds(knownConnectionIds)) { - changed = true - } - } - - return changed - } - - /** - * Get the unparsed style for these feedbacks - * Note: Does not clone the style - * @param baseStyle Style of the button without feedbacks applied - * @returns the unprocessed style - */ - getUnparsedStyle(baseStyle: ButtonStyleProperties): UnparsedButtonStyle { - if (this.#booleanOnly) throw new Error('FragmentFeedbacks not setup to use styles') - - let style: UnparsedButtonStyle = { - ...baseStyle, - imageBuffers: [], - } - - // Note: We don't need to consider children of the feedbacks here, as that can only be from boolean feedbacks which are handled by the `getBooleanValue` - - for (const feedback of this.#feedbacks) { - if (feedback.disabled) continue - - const definition = feedback.getDefinition() - if (definition?.type === 'boolean') { - const booleanValue = feedback.getBooleanValue() - if (booleanValue) { - style = { - ...style, - ...feedback.asFeedbackInstance().style, - } - } - } else if (definition?.type === 'advanced') { - const rawValue = feedback.cachedValue - if (typeof rawValue === 'object' && rawValue !== undefined) { - // Prune off some special properties - const prunedValue = { ...rawValue } - delete prunedValue.imageBuffer - delete prunedValue.imageBufferPosition - - // Ensure `textExpression` is set at the same times as `text` - delete prunedValue.textExpression - if ('text' in prunedValue) { - prunedValue.textExpression = rawValue.textExpression || false - } - - style = { - ...style, - ...prunedValue, - } - - // Push the imageBuffer into an array - if (rawValue.imageBuffer) { - style.imageBuffers.push({ - ...rawValue.imageBufferPosition, - ...rawValue.imageBufferEncoding, - buffer: rawValue.imageBuffer, - }) - } - } - } - } - - return style - } - - /** - * If this control was imported to a running system, do some data cleanup/validation - */ - postProcessImport(): Promise[] { - return this.#feedbacks.flatMap((feedback) => feedback.postProcessImport()) - } -} diff --git a/companion/lib/Controls/Fragments/FragmentFeedbacks.ts b/companion/lib/Controls/Fragments/FragmentFeedbacks.ts deleted file mode 100644 index d166b8d71a..0000000000 --- a/companion/lib/Controls/Fragments/FragmentFeedbacks.ts +++ /dev/null @@ -1,498 +0,0 @@ -import { cloneDeep } from 'lodash-es' -import LogController, { Logger } from '../../Log/Controller.js' -import { FragmentFeedbackInstance } from './FragmentFeedbackInstance.js' -import { FragmentFeedbackList } from './FragmentFeedbackList.js' -import type { ButtonStyleProperties, UnparsedButtonStyle } from '@companion-app/shared/Model/StyleModel.js' -import type { InstanceDefinitions } from '../../Instance/Definitions.js' -import type { InternalController } from '../../Internal/Controller.js' -import type { ModuleHost } from '../../Instance/Host.js' -import { FeedbackInstance } from '@companion-app/shared/Model/FeedbackModel.js' - -/** - * Helper for ControlTypes with feedbacks - * - * @author Håkon Nessjøen - * @author Keith Rocheck - * @author William Viker - * @author Julian Waller - * @since 3.0.0 - * @copyright 2022 Bitfocus AS - * @license - * This program is free software. - * You should have received a copy of the MIT licence as well as the Bitfocus - * Individual Contributor License Agreement for Companion along with - * this program. - * - * You can be released from the requirements of the license by purchasing - * a commercial license. Buying such a license is mandatory as soon as you - * develop commercial activities involving the Companion software without - * disclosing the source code of your own applications. - */ -export class FragmentFeedbacks { - /** - * The defaults style for a button - */ - static DefaultStyle: ButtonStyleProperties = { - text: '', - textExpression: false, - size: 'auto', - png64: null, - alignment: 'center:center', - pngalignment: 'center:center', - color: 0xffffff, - bgcolor: 0x000000, - show_topbar: 'default', - } - - /** - * The base style without feedbacks applied - */ - baseStyle: ButtonStyleProperties = cloneDeep(FragmentFeedbacks.DefaultStyle) - - /** - * Whether this set of feedbacks can only use boolean feedbacks - */ - readonly #booleanOnly: boolean - - readonly #controlId: string - - /** - * The feedbacks on this control - */ - readonly #feedbacks: FragmentFeedbackList - - /** - * Whether this set of feedbacks can only use boolean feedbacks - */ - get isBooleanOnly(): boolean { - return this.#booleanOnly - } - - /** - * Commit changes to the database and disk - */ - readonly #commitChange: (redraw?: boolean) => void - - /** - * Trigger a redraw/invalidation of the control - */ - readonly #triggerRedraw: () => void - - /** - * The logger - */ - readonly #logger: Logger - - constructor( - instanceDefinitions: InstanceDefinitions, - internalModule: InternalController, - moduleHost: ModuleHost, - controlId: string, - commitChange: (redraw?: boolean) => void, - triggerRedraw: () => void, - booleanOnly: boolean - ) { - this.#logger = LogController.createLogger(`Controls/Fragments/Feedbacks/${controlId}`) - - this.#controlId = controlId - this.#commitChange = commitChange - this.#triggerRedraw = triggerRedraw - this.#booleanOnly = booleanOnly - - this.#feedbacks = new FragmentFeedbackList( - instanceDefinitions, - internalModule, - moduleHost, - this.#controlId, - null, - this.#booleanOnly - ) - } - - /** - * Initialise from storage - * @param feedbacks - * @param skipSubscribe Whether to skip calling subscribe for the new feedbacks - * @param isCloned Whether this is a cloned instance - */ - loadStorage(feedbacks: FeedbackInstance[], skipSubscribe?: boolean, isCloned?: boolean) { - this.#feedbacks.loadStorage(feedbacks, !!skipSubscribe, !!isCloned) - } - - /** - * Get the value from all feedbacks as a single boolean - */ - checkValueAsBoolean(): boolean { - return this.#feedbacks.getBooleanValue() - } - - /** - * Remove any tracked state for a connection - */ - clearConnectionState(connectionId: string): void { - const changed = this.#feedbacks.clearCachedValueForConnectionId(connectionId) - if (changed) this.#triggerRedraw() - } - - /** - * Prepare this control for deletion - * @access public - */ - destroy(): void { - this.loadStorage([]) - } - - /** - * Add a feedback to this control - * @param feedbackItem the item to add - * @param parentId the ids of parent feedback that this feedback should be added as a child of - */ - feedbackAdd(feedbackItem: FeedbackInstance, parentId: string | null): boolean { - let newFeedback: FragmentFeedbackInstance - - if (parentId) { - const parent = this.#feedbacks.findById(parentId) - if (!parent) throw new Error(`Failed to find parent feedback ${parentId} when adding child feedback`) - - newFeedback = parent.addChild(feedbackItem) - } else { - newFeedback = this.#feedbacks.addFeedback(feedbackItem) - } - - // Inform relevant module - newFeedback.subscribe(true) - - this.#commitChange() - - return true - } - - /** - * Duplicate an feedback on this control - */ - feedbackDuplicate(id: string): boolean { - const feedback = this.#feedbacks.duplicateFeedback(id) - if (feedback) { - this.#commitChange(false) - - return true - } - - return false - } - - /** - * Enable or disable a feedback - */ - feedbackEnabled(id: string, enabled: boolean): boolean { - const feedback = this.#feedbacks.findById(id) - if (feedback) { - feedback.setEnabled(enabled) - - this.#commitChange() - - return true - } - - return false - } - - /** - * Set headline for the feedback - */ - feedbackHeadline(id: string, headline: string): boolean { - const feedback = this.#feedbacks.findById(id) - if (feedback) { - feedback.setHeadline(headline) - - this.#commitChange() - - return true - } - - return false - } - - /** - * Learn the options for a feedback, by asking the instance for the current values - */ - async feedbackLearn(id: string): Promise { - const feedback = this.#feedbacks.findById(id) - if (!feedback) return false - - const changed = await feedback.learnOptions() - if (!changed) return false - - // Time has passed due to the `await` - // So the feedback may not still exist, meaning we should find it again to be sure - const feedbackAfter = this.#feedbacks.findById(id) - if (!feedbackAfter) return false - - this.#commitChange(true) - return true - } - - /** - * Remove a feedback from this control - */ - feedbackRemove(id: string): boolean { - if (this.#feedbacks.removeFeedback(id)) { - this.#commitChange() - - return true - } else { - return false - } - } - - /** - * Move a feedback within the hierarchy - * @param moveFeedbackId the id of the feedback to move - * @param newParentId the target parentId of the feedback - * @param newIndex the target index of the feedback - */ - feedbackMoveTo(moveFeedbackId: string, newParentId: string | null, newIndex: number): boolean { - const oldItem = this.#feedbacks.findParentAndIndex(moveFeedbackId) - if (!oldItem) return false - - if (oldItem.parent.id === newParentId) { - oldItem.parent.moveFeedback(oldItem.index, newIndex) - } else { - const newParent = newParentId ? this.#feedbacks.findById(newParentId) : null - if (newParentId && !newParent) return false - - // Ensure the new parent is not a child of the feedback being moved - if (newParentId && oldItem.item.findChildById(newParentId)) return false - - // Check if the new parent can hold the feedback being moved - if (newParent && !newParent.canAcceptChild(oldItem.item)) return false - - const poppedFeedback = oldItem.parent.popFeedback(oldItem.index) - if (!poppedFeedback) return false - - if (newParent) { - newParent.pushChild(poppedFeedback, newIndex) - } else { - this.#feedbacks.pushFeedback(poppedFeedback, newIndex) - } - } - - this.#commitChange() - - return true - } - - /** - * Replace a feedback with an updated version - */ - feedbackReplace( - newProps: Pick, - skipNotifyModule = false - ): boolean { - const feedback = this.#feedbacks.findById(newProps.id) - if (feedback) { - feedback.replaceProps(newProps, skipNotifyModule) - - this.#commitChange(true) - - return true - } - - return false - } - - /** - * Update an option for a feedback - * @param id the id of the feedback - * @param key the key/name of the property - * @param value the new value - */ - feedbackSetOptions(id: string, key: string, value: any): boolean { - const feedback = this.#feedbacks.findById(id) - if (feedback) { - feedback.setOption(key, value) - - this.#commitChange() - - return true - } - - return false - } - - /** - * Set a new connection instance for a feedback - * @param id the id of the feedback - * @param connectionId the id of the new connection - */ - feedbackSetConnection(id: string, connectionId: string | number): boolean { - const feedback = this.#feedbacks.findById(id) - if (feedback) { - feedback.setInstance(connectionId) - - this.#commitChange() - - return true - } - - return false - } - - /** - * Set whether a boolean feedback should be inverted - * @param id the id of the feedback - * @param isInverted the new value - */ - feedbackSetInverted(id: string, isInverted: boolean): boolean { - const feedback = this.#feedbacks.findById(id) - if (feedback) { - feedback.setInverted(!!isInverted) - - this.#commitChange() - - return true - } - - return false - } - - /** - * Update the selected style properties for a boolean feedback - * @param id the id of the feedback - * @param selected the properties to be selected - */ - feedbackSetStyleSelection(id: string, selected: string[]): boolean { - if (this.#booleanOnly) throw new Error('FragmentFeedbacks not setup to use styles') - - const feedback = this.#feedbacks.findById(id) - if (feedback && feedback.setStyleSelection(selected, this.baseStyle)) { - this.#commitChange() - - return true - } - - return false - } - - /** - * Update an style property for a boolean feedback - * @param id the id of the feedback - * @param key the key/name of the property - * @param value the new value - */ - feedbackSetStyleValue(id: string, key: string, value: any): boolean { - if (this.#booleanOnly) throw new Error('FragmentFeedbacks not setup to use styles') - - const feedback = this.#feedbacks.findById(id) - if (feedback && feedback.setStyleValue(key, value)) { - this.#commitChange() - - return true - } - - return false - } - - /** - * Remove any actions referencing a specified connectionId - */ - forgetConnection(connectionId: string): boolean { - // Cleanup any feedbacks - return this.#feedbacks.forgetForConnection(connectionId) - } - - /** - * Get all the feedback instances - */ - getAllFeedbackInstances(): FeedbackInstance[] { - return this.#feedbacks.asFeedbackInstances() - } - - /** - * Get all the feedbacks contained - */ - getAllFeedbacks(): FragmentFeedbackInstance[] { - return this.#feedbacks.getAllFeedbacks() - } - - /** - * Get all the feedback instances - * @param onlyConnectionId Optionally, only for a specific connection - */ - getFlattenedFeedbackInstances(onlyConnectionId?: string): Omit[] { - const instances: FeedbackInstance[] = [] - - const extractInstances = (feedbacks: FeedbackInstance[]) => { - for (const feedback of feedbacks) { - if (!onlyConnectionId || onlyConnectionId === feedback.instance_id) { - instances.push({ - ...feedback, - children: undefined, - }) - } - - if (feedback.children) { - extractInstances(feedback.children) - } - } - } - - extractInstances(this.#feedbacks.asFeedbackInstances()) - - return instances - } - - /** - * Get the unparsed style for these feedbacks - * Note: Does not clone the style - */ - getUnparsedStyle(): UnparsedButtonStyle { - return this.#feedbacks.getUnparsedStyle(this.baseStyle) - } - - /** - * If this control was imported to a running system, do some data cleanup/validation - */ - async postProcessImport(): Promise { - await Promise.all(this.#feedbacks.postProcessImport()).catch((e) => { - this.#logger.silly(`postProcessImport for ${this.#controlId} failed: ${e.message}`) - }) - } - - /** - * Re-trigger 'subscribe' for all feedbacks - * This should be used when something has changed which will require all feedbacks to be re-run - * @param onlyConnectionId If set, only re-subscribe feedbacks for this connection - */ - resubscribeAllFeedbacks(onlyConnectionId?: string): void { - this.#feedbacks.subscribe(true, onlyConnectionId) - } - - /** - * Update the feedbacks on the button with new values - * @param connectionId The instance the feedbacks are for - * @param newValues The new feedback values - */ - updateFeedbackValues(connectionId: string, newValues: Record): void { - let changed = false - - for (const id in newValues) { - const feedback = this.#feedbacks.findById(id) - if (feedback && feedback.connectionId === connectionId && feedback.setCachedValue(newValues[id])) { - changed = true - } - } - - if (changed) { - this.#triggerRedraw() - } - } - - /** - * Prune all actions/feedbacks referencing unknown conncetions - * Doesn't do any cleanup, as it is assumed that the connection has not been running - */ - verifyConnectionIds(knownConnectionIds: Set): boolean { - return this.#feedbacks.verifyConnectionIds(knownConnectionIds) - } -} diff --git a/companion/lib/Controls/IControlFragments.ts b/companion/lib/Controls/IControlFragments.ts index bfc6d12db2..ba30c60558 100644 --- a/companion/lib/Controls/IControlFragments.ts +++ b/companion/lib/Controls/IControlFragments.ts @@ -1,83 +1,18 @@ import type { ButtonStatus } from '@companion-app/shared/Model/ButtonModel.js' import type { ControlBase } from './ControlBase.js' -import type { FragmentFeedbacks } from './Fragments/FragmentFeedbacks.js' -import type { ActionInstance, ActionOwner, ActionSetId } from '@companion-app/shared/Model/ActionModel.js' -import type { EventInstance } from '@companion-app/shared/Model/EventModel.js' +import type { ControlEntityListPoolBase } from './Entities/EntityListPoolBase.js' +import type { ControlActionSetAndStepsManager } from './Entities/ControlActionSetAndStepsManager.js' +import { EventInstance } from '@companion-app/shared/Model/EventModel.js' export type SomeControl = ControlBase & - (ControlWithSteps | ControlWithoutSteps) & (ControlWithStyle | ControlWithoutStyle) & - (ControlWithFeedbacks | ControlWithoutFeedbacks) & + (ControlWithEntities | ControlWithoutEntities) & (ControlWithActions | ControlWithoutActions) & (ControlWithEvents | ControlWithoutEvents) & (ControlWithActionSets | ControlWithoutActionSets) & (ControlWithOptions | ControlWithoutOptions) & (ControlWithPushed | ControlWithoutPushed) -export interface ControlWithSteps extends ControlBase { - readonly supportsSteps: true - - /** - * Get the index of the current (next to execute) step - * @returns The index of current step - */ - getActiveStepIndex(): number - - /** - * Add a step to this control - * @returns Id of new step - */ - stepAdd(): string - - /** - * Progress through the action-sets - * @param amount Number of steps to progress - */ - stepAdvanceDelta(amount: number): boolean - - /** - * Duplicate a step on this control - * @param stepId the id of the step to duplicate - */ - stepDuplicate(stepId: string): boolean - - /** - * Set the current (next to execute) action-set by index - * @param index The step index to make the next - */ - stepMakeCurrent(index: number): boolean - - /** - * Remove an action-set from this control - * @param stepId the id of the action-set - */ - stepRemove(stepId: string): boolean - - /** - * Set the current (next to execute) action-set by id - * @param stepId The step id to make the next - */ - stepSelectCurrent(stepId: string): boolean - - /** - * Swap two action-sets - * @param stepId1 One of the action-sets - * @param stepId2 The other action-set - */ - stepSwap(stepId1: string, stepId2: string): boolean - - /** - * Rename step - * @param stepId the id of the action-set - * @param newName The new name of the step - */ - stepRename(stepId: string, newName: string): boolean -} - -export interface ControlWithoutSteps extends ControlBase { - readonly supportsSteps: false -} - export interface ControlWithStyle extends ControlBase { readonly supportsStyle: true @@ -101,10 +36,10 @@ export interface ControlWithoutStyle extends ControlBase { readonly supportsStyle: false } -export interface ControlWithFeedbacks extends ControlBase { - readonly supportsFeedbacks: true +export interface ControlWithEntities extends ControlBase { + readonly supportsEntities: true - readonly feedbacks: FragmentFeedbacks + readonly entities: ControlEntityListPoolBase /** * Remove any tracked state for an connection @@ -117,8 +52,8 @@ export interface ControlWithFeedbacks extends ControlBase { forgetConnection(connectionId: string): void } -export interface ControlWithoutFeedbacks extends ControlBase { - readonly supportsFeedbacks: false +export interface ControlWithoutEntities extends ControlBase { + readonly supportsEntities: false } export interface ControlWithActions extends ControlBase { @@ -129,92 +64,6 @@ export interface ControlWithActions extends ControlBase { * @param skip_up Mark button as released */ abortDelayedActions(skip_up: boolean): void - - /** - * Add an action to this control - */ - actionAdd(stepId: string, setId: ActionSetId, actionItem: ActionInstance, ownerId: ActionOwner | null): boolean - - /** - * Append some actions to this button - * @param stepId - * @param setId the action_set id to update - * @param newActions actions to append - */ - actionAppend(stepId: string, setId: ActionSetId, newActions: ActionInstance[], ownerId: ActionOwner | null): boolean - - /** - * Duplicate an action on this control - */ - actionDuplicate(stepId: string, setId: ActionSetId, id: string): string | null - - /** - * Enable or disable an action - */ - actionEnabled(stepId: string, setId: ActionSetId, id: string, enabled: boolean): boolean - - /** - * Set action headline - */ - actionHeadline(stepId: string, setId: ActionSetId, id: string, headline: string): boolean - - /** - * Learn the options for an action, by asking the connection for the current values - */ - actionLearn(stepId: string, setId: ActionSetId, id: string): Promise - - /** - * Remove an action from this control - */ - actionRemove(stepId: string, setId: ActionSetId, id: string): boolean - - /** - * Reorder an action in the list or move between sets - */ - actionMoveTo( - dragStepId: string, - dragSetId: ActionSetId, - dragActionId: string, - hoverStepId: string, - hoverSetId: ActionSetId, - hoverOwnerId: ActionOwner | null, - hoverIndex: number - ): boolean - - /** - * Remove an action from this control - */ - actionReplace(newProps: Pick, skipNotifyModule?: boolean): boolean - - /** - * Replace all the actions in a set - */ - actionReplaceAll(stepId: string, setId: ActionSetId, newActions: ActionInstance[]): boolean - - /** - * Set the connection of an action - */ - actionSetConnection(stepId: string, setId: ActionSetId, id: string, connectionId: string): boolean - - /** - * Set an option of an action - */ - actionSetOption(stepId: string, setId: ActionSetId, id: string, key: string, value: any): boolean - - /** - * Remove any tracked state for a connection - */ - clearConnectionState(connectionId: string): void - - /** - * Update all controls to forget a connection - */ - forgetConnection(connectionId: string): void - - /** - * Get all the actions on this control - */ - getFlattenedActionInstances(): ActionInstance[] } export interface ControlWithoutActions extends ControlBase { @@ -272,25 +121,7 @@ export interface ControlWithoutEvents extends ControlBase { export interface ControlWithActionSets extends ControlBase { readonly supportsActionSets: true - /** - * Add an action set to this control - */ - actionSetAdd(stepId: string): boolean - - /** - * Remove an action-set from this control - */ - actionSetRemove(stepId: string, setId: ActionSetId): boolean - - /** - * Rename an action-sets - */ - actionSetRename(stepId: string, oldSetId: ActionSetId, newSetId: ActionSetId): boolean - - /** - * Set whether an action-set should run while the button is held - */ - actionSetRunWhileHeld(stepId: string, setId: ActionSetId, runWhileHeld: boolean): boolean + readonly actionSets: ControlActionSetAndStepsManager /** * Execute a rotate of this control diff --git a/companion/lib/Data/Upgrade.ts b/companion/lib/Data/Upgrade.ts index 71fe1e8cb0..abbb254f38 100644 --- a/companion/lib/Data/Upgrade.ts +++ b/companion/lib/Data/Upgrade.ts @@ -8,6 +8,7 @@ import { showFatalError } from '../Resources/Util.js' import type { DataDatabase } from './Database.js' import type { SomeExportv6 } from '@companion-app/shared/Model/ExportModel.js' import v5tov6 from './Upgrades/v5tov6.js' +import v6tov7 from './Upgrades/v6tov7.js' const logger = LogController.createLogger('Data/Upgrade') @@ -17,6 +18,7 @@ const allUpgrades = [ v3tov4, // v3.2 v4tov5, // v3.5 - first round of sqlite rearranging v5tov6, // v3.5 - replace action delay property https://github.com/bitfocus/companion/pull/3163 + v6tov7, // v3.6 - rework 'entities' for better nesting https://github.com/bitfocus/companion/pull/3185 ] const targetVersion = allUpgrades.length + 1 @@ -45,7 +47,7 @@ export function upgradeStartup(db: DataDatabase): void { allUpgrades[i - 1].upgradeStartup(db, logger) // Record that the upgrade has been done - db.setKey('page_config_version', i) + db.setKey('page_config_version', i + 1) } } diff --git a/companion/lib/Data/Upgrades/v6tov7.ts b/companion/lib/Data/Upgrades/v6tov7.ts new file mode 100644 index 0000000000..9e0ff9bea4 --- /dev/null +++ b/companion/lib/Data/Upgrades/v6tov7.ts @@ -0,0 +1,166 @@ +import type { DataStoreBase } from '../StoreBase.js' +import type { Logger } from '../../Log/Controller.js' +import { cloneDeep } from 'lodash-es' +import type { SomeExportv4 } from '@companion-app/shared/Model/ExportModelv4.js' +import type { + ExportControlv6, + ExportFullv6, + ExportPageContentv6, + ExportPageModelv6, + ExportTriggersListv6, + SomeExportv6, +} from '@companion-app/shared/Model/ExportModel.js' +import { ActionEntityModel, EntityModelType, FeedbackEntityModel } from '@companion-app/shared/Model/EntityModel.js' +import { ButtonStyleProperties } from '@companion-app/shared/Model/StyleModel.js' +import { Complete } from '@companion-module/base/dist/util.js' + +/** + * do the database upgrades to convert from the v6 to the v7 format + */ +function convertDatabaseToV7(db: DataStoreBase, _logger: Logger) { + if (!db.store) return + + const controls = db.getTable('controls') + + for (const [controlId, control] of Object.entries(controls)) { + // Fixup control + fixupControlEntities(control) + + db.setTableKey('controls', controlId, control) + } +} + +function convertImportToV7(obj: SomeExportv4): SomeExportv6 { + if (obj.type == 'full') { + const newObj: ExportFullv6 = { ...cloneDeep(obj), version: 6 } + if (newObj.pages) { + for (const page of Object.values(newObj.pages)) { + convertPageControls(page) + } + } + return newObj + } else if (obj.type == 'page') { + const newObj: ExportPageModelv6 = { ...cloneDeep(obj), version: 6 } + convertPageControls(newObj.page) + return newObj + } else if (obj.type == 'trigger_list') { + const newObj: ExportTriggersListv6 = { ...cloneDeep(obj), version: 6 } + for (const trigger of Object.values(newObj.triggers)) { + fixupControlEntities(trigger) + } + return newObj + } else { + // No change + return obj + } +} + +function fixupControlEntities(control: ExportControlv6): void { + if (control.type === 'button') { + for (const step of Object.values(control.steps || {})) { + for (const [setId, set] of Object.entries(step.action_sets || {})) { + if (!set) continue + step.action_sets[setId] = set.map(fixupAction) + } + } + + control.feedbacks = control.feedbacks?.map(fixupFeedback) ?? [] + } else if (control.type === 'trigger') { + if (control.action_sets) { + control.actions = control.action_sets.map(fixupAction) + delete control.action_sets + } else { + control.actions = [] + } + + control.condition = control.condition?.map(fixupFeedback) ?? [] + } else { + // Unknown control type! + } +} + +interface OldFeedbackInstance { + id: string + instance_id: string + headline?: string + type: string + options: Record + disabled?: boolean + upgradeIndex?: number + isInverted?: boolean + style?: Partial + + children?: OldFeedbackInstance[] +} +function fixupFeedback(feedback: OldFeedbackInstance): Complete { + return { + type: EntityModelType.Feedback, + + id: feedback.id, + definitionId: feedback.type, + connectionId: feedback.instance_id, + headline: feedback.headline, + options: feedback.options, + disabled: feedback.disabled, + upgradeIndex: feedback.upgradeIndex, + + children: + feedback.instance_id === 'internal' && feedback.children + ? { + default: feedback.children.map(fixupFeedback), + } + : undefined, + + isInverted: feedback.isInverted, + style: feedback.style, + } +} + +interface OldActionInstance { + id: string + instance: string + headline?: string + action: string + options: Record + disabled?: boolean + upgradeIndex?: number + + /** + * Some internal actions can have children, one or more set of them + */ + children?: Record +} +function fixupAction(action: OldActionInstance): Complete { + return { + type: EntityModelType.Action, + + id: action.id, + definitionId: action.action, + connectionId: action.instance, + headline: action.headline, + options: action.options, + disabled: action.disabled, + upgradeIndex: action.upgradeIndex, + + children: + action.instance === 'internal' && action.children + ? Object.fromEntries(Object.entries(action.children).map(([id, actions]) => [id, actions?.map(fixupAction)])) + : undefined, + } +} + +function convertPageControls(page: ExportPageContentv6): ExportPageContentv6 { + for (const row of Object.values(page.controls)) { + if (!row) continue + for (const control of Object.values(row)) { + fixupControlEntities(control) + } + } + + return page +} + +export default { + upgradeStartup: convertDatabaseToV7, + upgradeImport: convertImportToV7, +} diff --git a/companion/lib/ImportExport/Controller.ts b/companion/lib/ImportExport/Controller.ts index 2e4c95c7a5..4daa6e4e29 100644 --- a/companion/lib/ImportExport/Controller.ts +++ b/companion/lib/ImportExport/Controller.ts @@ -15,7 +15,7 @@ * */ -const FILE_VERSION = 6 +const FILE_VERSION = 7 import os from 'os' import { upgradeImport } from '../Data/Upgrade.js' @@ -58,8 +58,7 @@ import type { ClientSocket, UIHandler } from '../UI/Handler.js' import type { ControlTrigger } from '../Controls/ControlTypes/Triggers/Trigger.js' import type { ExportFormat } from '@companion-app/shared/Model/ExportFormat.js' import type { TriggerModel } from '@companion-app/shared/Model/TriggerModel.js' -import type { FeedbackInstance } from '@companion-app/shared/Model/FeedbackModel.js' -import type { ActionInstance, ActionSetsModel } from '@companion-app/shared/Model/ActionModel.js' +import type { ActionSetsModel } from '@companion-app/shared/Model/ActionModel.js' import type { NormalButtonModel, SomeButtonModel } from '@companion-app/shared/Model/ButtonModel.js' import type { ControlLocation } from '@companion-app/shared/Model/Common.js' import type { InstanceController } from '../Instance/Controller.js' @@ -71,6 +70,7 @@ import type { SurfaceController } from '../Surface/Controller.js' import type { GraphicsController } from '../Graphics/Controller.js' import type { InternalController } from '../Internal/Controller.js' import { compileUpdatePayload } from '../UI/UpdatePayload.js' +import { SomeEntityModel } from '@companion-app/shared/Model/EntityModel.js' function parseDownloadFormat(raw: ParsedQs[0]): ExportFormat | undefined { if (raw === 'json-gz' || raw === 'json' || raw === 'yaml') return raw @@ -1036,29 +1036,17 @@ export class ImportExportController { const result: TriggerModel = { type: 'trigger', options: cloneDeep(control.options), - action_sets: { - 0: [], - down: undefined, - up: undefined, - rotate_left: undefined, - rotate_right: undefined, - }, + actions: [], condition: [], events: control.events, } if (control.condition) { - result.condition = fixupFeedbacksRecursive(instanceIdMap, cloneDeep(control.condition)) + result.condition = fixupEntitiesRecursive(instanceIdMap, cloneDeep(control.condition)) } - const allActions: ActionInstance[] = [] - if (control.action_sets) { - // Triggers can only have the 0 set - const action_set = control.action_sets[0] - const newActions = fixupActionsRecursive(instanceIdMap, cloneDeep(action_set) as any) - - result.action_sets[0] = newActions - allActions.push(...newActions) + if (control.actions) { + result.actions = fixupEntitiesRecursive(instanceIdMap, cloneDeep(control.actions)) } ReferencesVisitors.fixupControlReferences( @@ -1068,9 +1056,7 @@ export class ImportExportController { connectionIds: connectionIdRemap, }, undefined, - allActions, - result.condition || [], - [], + result.condition.concat(result.actions), [], result.events || [], false @@ -1108,10 +1094,10 @@ export class ImportExportController { } if (control.feedbacks) { - result.feedbacks = fixupFeedbacksRecursive(instanceIdMap, cloneDeep(control.feedbacks)) + result.feedbacks = fixupEntitiesRecursive(instanceIdMap, cloneDeep(control.feedbacks)) } - const allActions: ActionInstance[] = [] + const allEntities: SomeEntityModel[] = [...result.feedbacks] if (control.steps) { for (const [stepId, step] of Object.entries(control.steps)) { const newStepSets: ActionSetsModel = { @@ -1132,10 +1118,10 @@ export class ImportExportController { continue } - const newActions = fixupActionsRecursive(instanceIdMap, cloneDeep(action_set) as any) + const newActions = fixupEntitiesRecursive(instanceIdMap, cloneDeep(action_set) as any) newStepSets[setIdSafe] = newActions - allActions.push(...newActions) + allEntities.push(...newActions) } } } @@ -1147,9 +1133,7 @@ export class ImportExportController { connectionIds: connectionIdRemap, }, result.style, - allActions, - result.feedbacks || [], - [], + allEntities, [], [], false @@ -1169,47 +1153,33 @@ type ClientPendingImport = { timeout: null } -function fixupFeedbacksRecursive(instanceIdMap: InstanceAppliedRemappings, feedbacks: FeedbackInstance[]) { - const newFeedbacks: FeedbackInstance[] = [] - for (const feedback of feedbacks) { - const instanceInfo = instanceIdMap[feedback?.instance_id] - if (feedback && instanceInfo) { - newFeedbacks.push({ - ...feedback, - instance_id: instanceInfo.id, - upgradeIndex: instanceInfo.lastUpgradeIndex, - children: - feedback.instance_id === 'internal' && feedback.children - ? fixupFeedbacksRecursive(instanceIdMap, feedback.children) - : undefined, - }) - } - } - return newFeedbacks -} +function fixupEntitiesRecursive( + instanceIdMap: InstanceAppliedRemappings, + entities: SomeEntityModel[] +): SomeEntityModel[] { + const newEntities: SomeEntityModel[] = [] + for (const entity of entities) { + if (!entity) continue -function fixupActionsRecursive(instanceIdMap: InstanceAppliedRemappings, actions: ActionInstance[]) { - const newActions: ActionInstance[] = [] - for (const action of actions) { - const instanceInfo = instanceIdMap[action?.instance] - if (action && instanceInfo) { - let newChildren: Record | undefined - if (action.instance === 'internal' && action.children) { - newChildren = {} - for (const [group, actions] of Object.entries(action.children)) { - if (!actions) continue - - newChildren[group] = fixupActionsRecursive(instanceIdMap, actions) - } - } + const instanceInfo = instanceIdMap[entity.connectionId] + if (!instanceInfo) continue - newActions.push({ - ...action, - instance: instanceInfo.id, - upgradeIndex: instanceInfo.lastUpgradeIndex, - children: newChildren, - }) + let newChildren: Record | undefined + if (entity.connectionId === 'internal' && entity.children) { + newChildren = {} + for (const [group, childEntities] of Object.entries(entity.children)) { + if (!childEntities) continue + + newChildren[group] = fixupEntitiesRecursive(instanceIdMap, childEntities) + } } + + newEntities.push({ + ...entity, + connectionId: instanceInfo.id, + upgradeIndex: instanceInfo.lastUpgradeIndex, + children: newChildren, + }) } - return newActions + return newEntities } diff --git a/companion/lib/Instance/Definitions.ts b/companion/lib/Instance/Definitions.ts index 1049243120..5ee29d1fe6 100644 --- a/companion/lib/Instance/Definitions.ts +++ b/companion/lib/Instance/Definitions.ts @@ -1,7 +1,7 @@ import { cloneDeep } from 'lodash-es' import { nanoid } from 'nanoid' import { EventDefinitions } from '../Resources/EventDefinitions.js' -import { ControlButtonNormal } from '../Controls/ControlTypes/Button/Normal.js' +import { ControlEntityListPoolButton } from '../Controls/Entities/EntityListPoolButton.js' import jsonPatch from 'fast-json-patch' import { diffObjects } from '@companion-app/shared/Diff.js' import { replaceAllVariables } from '../Variables/Util.js' @@ -11,11 +11,7 @@ import type { PresetDefinition, UIPresetDefinition, } from '@companion-app/shared/Model/Presets.js' -import type { ActionDefinition } from '@companion-app/shared/Model/ActionDefinitionModel.js' -import type { FeedbackDefinition } from '@companion-app/shared/Model/FeedbackDefinitionModel.js' import type { ClientSocket, UIHandler } from '../UI/Handler.js' -import type { ActionInstance } from '@companion-app/shared/Model/ActionModel.js' -import type { FeedbackInstance } from '@companion-app/shared/Model/FeedbackModel.js' import type { EventInstance } from '@companion-app/shared/Model/EventModel.js' import type { NormalButtonModel, NormalButtonSteps } from '@companion-app/shared/Model/ButtonModel.js' import type { ControlLocation } from '@companion-app/shared/Model/Common.js' @@ -25,6 +21,13 @@ import type { ControlsController } from '../Controls/Controller.js' import type { VariablesValues } from '../Variables/Values.js' import type { GraphicsController } from '../Graphics/Controller.js' import { validateActionSetId } from '@companion-app/shared/ControlId.js' +import { + ActionEntityModel, + EntityModelType, + type FeedbackEntityModel, +} from '@companion-app/shared/Model/EntityModel.js' +import type { ClientEntityDefinition } from '@companion-app/shared/Model/EntityDefinitionModel.js' +import { assertNever } from '@companion-app/shared/Util.js' const PresetsRoom = 'presets' const ActionsRoom = 'action-definitions' @@ -61,11 +64,11 @@ export class InstanceDefinitions { /** * The action definitions */ - #actionDefinitions: Record> = {} + #actionDefinitions: Record> = {} /** * The feedback definitions */ - #feedbackDefinitions: Record> = {} + #feedbackDefinitions: Record> = {} /** * The preset definitions */ @@ -168,28 +171,27 @@ export class InstanceDefinitions { * @param connectionId - the id of the instance * @param actionId - the id of the action */ - createActionItem(connectionId: string, actionId: string): ActionInstance | null { - const definition = this.getActionDefinition(connectionId, actionId) - if (definition) { - const action: ActionInstance = { - id: nanoid(), - action: actionId, - instance: connectionId, - options: {}, - } + createActionItem(connectionId: string, actionId: string): ActionEntityModel | null { + const definition = this.getEntityDefinition(EntityModelType.Action, connectionId, actionId) + if (!definition) return null + + const action: ActionEntityModel = { + type: EntityModelType.Action, + id: nanoid(), + definitionId: actionId, + connectionId: connectionId, + options: {}, + } - if (definition.options !== undefined && definition.options.length > 0) { - for (const j in definition.options) { - const opt = definition.options[j] - // @ts-ignore - action.options[opt.id] = cloneDeep(opt.default) - } + if (definition.options !== undefined && definition.options.length > 0) { + for (const j in definition.options) { + const opt = definition.options[j] + // @ts-ignore + action.options[opt.id] = cloneDeep(opt.default) } - - return action - } else { - return null } + + return action } /** @@ -198,36 +200,35 @@ export class InstanceDefinitions { * @param feedbackId - the id of the feedback * @param booleanOnly - whether the feedback must be boolean */ - createFeedbackItem(connectionId: string, feedbackId: string, booleanOnly: boolean): FeedbackInstance | null { - const definition = this.getFeedbackDefinition(connectionId, feedbackId) - if (definition) { - if (booleanOnly && definition.type !== 'boolean') return null - - const feedback: FeedbackInstance = { - id: nanoid(), - type: feedbackId, - instance_id: connectionId, - options: {}, - style: {}, - isInverted: false, - } - - if (definition.options !== undefined && definition.options.length > 0) { - for (const j in definition.options) { - const opt = definition.options[j] - // @ts-ignore - feedback.options[opt.id] = cloneDeep(opt.default) - } - } + createFeedbackItem(connectionId: string, feedbackId: string, booleanOnly: boolean): FeedbackEntityModel | null { + const definition = this.getEntityDefinition(EntityModelType.Feedback, connectionId, feedbackId) + if (!definition) return null + + if (booleanOnly && definition.feedbackType !== 'boolean') return null + + const feedback: FeedbackEntityModel = { + type: EntityModelType.Feedback, + id: nanoid(), + definitionId: feedbackId, + connectionId: connectionId, + options: {}, + style: {}, + isInverted: false, + } - if (!booleanOnly && definition.type === 'boolean' && definition.style) { - feedback.style = cloneDeep(definition.style) + if (definition.options !== undefined && definition.options.length > 0) { + for (const j in definition.options) { + const opt = definition.options[j] + // @ts-ignore + feedback.options[opt.id] = cloneDeep(opt.default) } + } - return feedback - } else { - return null + if (!booleanOnly && definition.feedbackType === 'boolean' && definition.feedbackStyle) { + feedback.style = cloneDeep(definition.feedbackStyle) } + + return feedback } createEventItem(eventType: string): EventInstance | null { @@ -278,24 +279,21 @@ export class InstanceDefinitions { } /** - * Get an action definition + * Get an entity definition */ - getActionDefinition(connectionId: string, actionId: string): ActionDefinition | undefined { - if (this.#actionDefinitions[connectionId]) { - return this.#actionDefinitions[connectionId][actionId] - } else { - return undefined - } - } - - /** - * Get a feedback definition - */ - getFeedbackDefinition(connectionId: string, feedbackId: string): FeedbackDefinition | undefined { - if (this.#feedbackDefinitions[connectionId]) { - return this.#feedbackDefinitions[connectionId][feedbackId] - } else { - return undefined + getEntityDefinition( + entityType: EntityModelType, + connectionId: string, + definitionId: string + ): ClientEntityDefinition | undefined { + switch (entityType) { + case EntityModelType.Action: + return this.#actionDefinitions[connectionId]?.[definitionId] + case EntityModelType.Feedback: + return this.#feedbackDefinitions[connectionId]?.[definitionId] + default: + assertNever(entityType) + return undefined } } @@ -333,7 +331,7 @@ export class InstanceDefinitions { rotate_left: undefined, rotate_right: undefined, }, - options: cloneDeep(definition.steps[i].options) ?? cloneDeep(ControlButtonNormal.DefaultStepOptions), + options: cloneDeep(definition.steps[i].options) ?? cloneDeep(ControlEntityListPoolButton.DefaultStepOptions), } result.steps[i] = newStep @@ -355,9 +353,10 @@ export class InstanceDefinitions { if (definition.feedbacks) { result.feedbacks = definition.feedbacks.map((feedback) => ({ + type: EntityModelType.Feedback, id: nanoid(), - instance_id: connectionId, - type: feedback.type, + connectionId: connectionId, + definitionId: feedback.type, options: cloneDeep(feedback.options ?? {}), isInverted: feedback.isInverted, style: cloneDeep(feedback.style), @@ -373,7 +372,7 @@ export class InstanceDefinitions { /** * Set the action definitions for a connection */ - setActionDefinitions(connectionId: string, actionDefinitions: Record): void { + setActionDefinitions(connectionId: string, actionDefinitions: Record): void { const lastActionDefinitions = this.#actionDefinitions[connectionId] this.#actionDefinitions[connectionId] = cloneDeep(actionDefinitions) @@ -383,7 +382,7 @@ export class InstanceDefinitions { type: 'add-connection', connectionId, - actions: actionDefinitions, + entities: actionDefinitions, }) } else { const diff = diffObjects(lastActionDefinitions, actionDefinitions || {}) @@ -402,7 +401,7 @@ export class InstanceDefinitions { /** * Set the feedback definitions for a connection */ - setFeedbackDefinitions(connectionId: string, feedbackDefinitions: Record): void { + setFeedbackDefinitions(connectionId: string, feedbackDefinitions: Record): void { const lastFeedbackDefinitions = this.#feedbackDefinitions[connectionId] this.#feedbackDefinitions[connectionId] = cloneDeep(feedbackDefinitions) @@ -412,7 +411,7 @@ export class InstanceDefinitions { type: 'add-connection', connectionId, - feedbacks: feedbackDefinitions, + entities: feedbackDefinitions, }) } else { const diff = diffObjects(lastFeedbackDefinitions, feedbackDefinitions || {}) @@ -456,7 +455,7 @@ export class InstanceDefinitions { rawPreset.steps.length === 0 ? [{ action_sets: { down: [], up: [] } }] : rawPreset.steps.map((step) => { - const options = cloneDeep(ControlButtonNormal.DefaultStepOptions) + const options = cloneDeep(ControlEntityListPoolButton.DefaultStepOptions) const action_sets: PresetActionSets = { down: [], up: [], @@ -594,11 +593,12 @@ export type PresetDefinitionTmp = CompanionPresetDefinition & { id: string } -function toActionInstance(action: PresetActionInstance, connectionId: string): ActionInstance { +function toActionInstance(action: PresetActionInstance, connectionId: string): ActionEntityModel { return { + type: EntityModelType.Action, id: nanoid(), - instance: connectionId, - action: action.action, + connectionId: connectionId, + definitionId: action.action, options: cloneDeep(action.options ?? {}), headline: action.headline, } @@ -608,23 +608,16 @@ function convertActionsDelay( actions: PresetActionInstance[], connectionId: string, relativeDelays: boolean | undefined -) { +): ActionEntityModel[] { if (relativeDelays) { - const newActions: ActionInstance[] = [] + const newActions: ActionEntityModel[] = [] for (const action of actions) { const delay = Number(action.delay) // Add the wait action if (!isNaN(delay) && delay > 0) { - newActions.push({ - id: nanoid(), - instance: 'internal', - action: 'wait', - options: { - time: delay, - }, - }) + newActions.push(createWaitAction(delay)) } newActions.push(toActionInstance(action, connectionId)) @@ -633,9 +626,9 @@ function convertActionsDelay( return newActions } else { let currentDelay = 0 - let currentDelayGroupChildren: any[] = [] + let currentDelayGroupChildren: ActionEntityModel[] = [] - let delayGroups: any[] = [wrapActionsInGroup(currentDelayGroupChildren)] + let delayGroups: ActionEntityModel[] = [wrapActionsInGroup(currentDelayGroupChildren)] for (const action of actions) { const delay = Number(action.delay) @@ -668,11 +661,12 @@ function convertActionsDelay( } } -function wrapActionsInGroup(actions: any[]): any { +function wrapActionsInGroup(actions: ActionEntityModel[]): ActionEntityModel { return { + type: EntityModelType.Action, id: nanoid(), - instance: 'internal', - action: 'action_group', + connectionId: 'internal', + definitionId: 'action_group', options: { execution_mode: 'concurrent', }, @@ -681,11 +675,12 @@ function wrapActionsInGroup(actions: any[]): any { }, } } -function createWaitAction(delay: number): any { +function createWaitAction(delay: number): ActionEntityModel { return { + type: EntityModelType.Action, id: nanoid(), - instance: 'internal', - action: 'wait', + connectionId: 'internal', + definitionId: 'wait', options: { time: delay, }, diff --git a/companion/lib/Instance/Host.ts b/companion/lib/Instance/Host.ts index a40ba9ae53..8683774da5 100644 --- a/companion/lib/Instance/Host.ts +++ b/companion/lib/Instance/Host.ts @@ -12,6 +12,8 @@ import type { ConnectionConfig } from '@companion-app/shared/Model/Connections.j import type { InstanceModules, ModuleInfo } from './Modules.js' import type { ConnectionConfigStore } from './ConnectionConfigStore.js' import { isModuleApiVersionCompatible } from '@companion-app/shared/ModuleApiVersionCheck.js' +import { SomeEntityModel } from '@companion-app/shared/Model/EntityModel.js' +import { CompanionOptionValues } from '@companion-module/base' /** * A backoff sleep strategy @@ -505,4 +507,30 @@ export class ModuleHost { }) }) } + + async connectionEntityUpdate(entityModel: SomeEntityModel, controlId: string): Promise { + const connection = this.getChild(entityModel.connectionId, true) + if (!connection) return false + + await connection.entityUpdate(entityModel, controlId) + + return true + } + async connectionEntityDelete(entityModel: SomeEntityModel, _controlId: string): Promise { + const connection = this.getChild(entityModel.connectionId, true) + if (!connection) return false + + await connection.entityDelete(entityModel) + + return true + } + async connectionEntityLearnOptions( + entityModel: SomeEntityModel, + controlId: string + ): Promise { + const connection = this.getChild(entityModel.connectionId) + if (!connection) return undefined + + return connection.entityLearnValues(entityModel, controlId) + } } diff --git a/companion/lib/Instance/Wrapper.ts b/companion/lib/Instance/Wrapper.ts index c2f818c208..7c39c26ed4 100644 --- a/companion/lib/Instance/Wrapper.ts +++ b/companion/lib/Instance/Wrapper.ts @@ -30,18 +30,15 @@ import type { } from '@companion-module/base/dist/host-api/api.js' import type { InstanceStatus } from './Status.js' import type { ConnectionConfig } from '@companion-app/shared/Model/Connections.js' -import type { FeedbackInstance } from '@companion-app/shared/Model/FeedbackModel.js' -import type { - CompanionHTTPRequest, - CompanionInputFieldBase, - CompanionOptionValues, - CompanionVariableValue, - LogLevel, +import { + assertNever, + type CompanionHTTPRequest, + type CompanionInputFieldBase, + type CompanionOptionValues, + type CompanionVariableValue, + type LogLevel, } from '@companion-module/base' -import type { ActionInstance } from '@companion-app/shared/Model/ActionModel.js' import type { ControlLocation } from '@companion-app/shared/Model/Common.js' -import type { ActionDefinition } from '@companion-app/shared/Model/ActionDefinitionModel.js' -import type { FeedbackDefinition } from '@companion-app/shared/Model/FeedbackDefinitionModel.js' import type { InstanceDefinitions, PresetDefinitionTmp } from './Definitions.js' import type { ControlsController } from '../Controls/Controller.js' import type { UIHandler } from '../UI/Handler.js' @@ -49,6 +46,14 @@ import type { VariablesController } from '../Variables/Controller.js' import type { PageController } from '../Page/Controller.js' import type { ServiceOscSender } from '../Service/OscSender.js' import type { InstanceSharedUdpManager } from './SharedUdpManager.js' +import { + ActionEntityModel, + EntityModelType, + FeedbackEntityModel, + SomeEntityModel, +} from '@companion-app/shared/Model/EntityModel.js' +import type { ClientEntityDefinition } from '@companion-app/shared/Model/EntityDefinitionModel.js' +import type { Complete } from '@companion-module/base/dist/util.js' const range1_2_0OrLater = new semver.Range('>=1.2.0-0', { includePrerelease: true }) @@ -222,24 +227,28 @@ export class SocketEventsHandler { // Find all the feedbacks on controls const allControls = this.#deps.controls.getAllControls() for (const [controlId, control] of allControls.entries()) { - const controlFeedbacks = - control.supportsFeedbacks && control.feedbacks.getFlattenedFeedbackInstances(this.connectionId) - if (controlFeedbacks && controlFeedbacks.length > 0) { - const imageSize = control.getBitmapSize() - for (const feedback of controlFeedbacks) { - allFeedbacks[feedback.id] = { - id: feedback.id, - controlId: controlId, - feedbackId: feedback.type, - options: feedback.options, - - isInverted: !!feedback.isInverted, - - upgradeIndex: feedback.upgradeIndex ?? null, - disabled: !!feedback.disabled, - - image: imageSize ?? undefined, - } + if (!control.supportsEntities) continue + + const controlEntities = control.entities.getAllEntities() + if (!controlEntities || controlEntities.length === 0) continue + + const imageSize = control.getBitmapSize() + for (const entity of controlEntities) { + if (entity.type !== EntityModelType.Feedback) continue + + const entityModel = entity.asEntityModel(false) as FeedbackEntityModel + allFeedbacks[entityModel.id] = { + id: entityModel.id, + controlId: controlId, + feedbackId: entityModel.definitionId, + options: entityModel.options, + + isInverted: !!entityModel.isInverted, + + upgradeIndex: entityModel.upgradeIndex ?? null, + disabled: !!entityModel.disabled, + + image: imageSize ?? undefined, } } } @@ -280,21 +289,23 @@ export class SocketEventsHandler { const allControls = this.#deps.controls.getAllControls() for (const [controlId, control] of allControls.entries()) { - if (control.supportsActions) { - const actions = control.getFlattenedActionInstances() - - for (const action of actions) { - if (action.instance == this.connectionId) { - allActions[action.id] = { - id: action.id, - controlId: controlId, - actionId: action.action, - options: action.options, - - upgradeIndex: action.upgradeIndex ?? null, - disabled: !!action.disabled, - } - } + if (!control.supportsEntities) continue + // const actions = .map(e => e.asEntityModel()) + + for (const entity of control.entities.getAllEntities()) { + if (entity.connectionId !== this.connectionId) continue + if (entity.type !== EntityModelType.Action) continue + + const entityModel = entity.asEntityModel(false) + + allActions[entity.id] = { + id: entityModel.id, + controlId: controlId, + actionId: entityModel.definitionId, + options: entityModel.options, + + upgradeIndex: entityModel.upgradeIndex ?? null, + disabled: !!entityModel.disabled, } } } @@ -314,11 +325,20 @@ export class SocketEventsHandler { // await this.ipcWrapper.sendWithCb('updateActions', msg) // } + entityUpdate(entity: SomeEntityModel, controlId: string): Promise { + switch (entity.type) { + case EntityModelType.Action: + return this.#actionUpdate(entity, controlId) + case EntityModelType.Feedback: + return this.#feedbackUpdate(entity, controlId) + } + } + /** * Inform the child instance class about an updated feedback */ - async feedbackUpdate(feedback: FeedbackInstance, controlId: string): Promise { - if (feedback.instance_id !== this.connectionId) throw new Error(`Feedback is for a different instance`) + async #feedbackUpdate(feedback: FeedbackEntityModel, controlId: string): Promise { + if (feedback.connectionId !== this.connectionId) throw new Error(`Feedback is for a different connection`) if (feedback.disabled) return const control = this.#deps.controls.getControl(controlId) @@ -328,7 +348,7 @@ export class SocketEventsHandler { [feedback.id]: { id: feedback.id, controlId: controlId, - feedbackId: feedback.type, + feedbackId: feedback.definitionId, options: feedback.options, isInverted: !!feedback.isInverted, @@ -345,65 +365,111 @@ export class SocketEventsHandler { /** * */ - async feedbackLearnValues( - feedback: FeedbackInstance, + async entityLearnValues( + entity: SomeEntityModel, controlId: string ): Promise { - if (feedback.instance_id !== this.connectionId) throw new Error(`Feedback is for a different instance`) + if (entity.connectionId !== this.connectionId) throw new Error(`Entity is for a different connection`) - const control = this.#deps.controls.getControl(controlId) - - const feedbackSpec = this.#deps.instanceDefinitions.getFeedbackDefinition(this.connectionId, feedback.type) - const learnTimeout = feedbackSpec?.learnTimeout + const entityDefinition = this.#deps.instanceDefinitions.getEntityDefinition( + entity.type, + this.connectionId, + entity.definitionId + ) + const learnTimeout = entityDefinition?.learnTimeout try { - const msg = await this.#ipcWrapper.sendWithCb( - 'learnFeedback', - { - feedback: { - id: feedback.id, - controlId: controlId, - feedbackId: feedback.type, - options: feedback.options, + switch (entity.type) { + case EntityModelType.Action: { + const msg = await this.#ipcWrapper.sendWithCb( + 'learnAction', + { + action: { + id: entity.id, + controlId: controlId, + actionId: entity.definitionId, + options: entity.options, + + upgradeIndex: null, + disabled: !!entity.disabled, + }, + }, + undefined, + learnTimeout + ) - isInverted: !!feedback.isInverted, + return msg.options + } + case EntityModelType.Feedback: { + const control = this.#deps.controls.getControl(controlId) - image: control?.getBitmapSize() ?? undefined, + const msg = await this.#ipcWrapper.sendWithCb( + 'learnFeedback', + { + feedback: { + id: entity.id, + controlId: controlId, + feedbackId: entity.definitionId, + options: entity.options, - upgradeIndex: null, - disabled: !!feedback.disabled, - }, - }, - undefined, - learnTimeout - ) + isInverted: !!entity.isInverted, - return msg.options + image: control?.getBitmapSize() ?? undefined, + + upgradeIndex: null, + disabled: !!entity.disabled, + }, + }, + undefined, + learnTimeout + ) + + return msg.options + } + default: + assertNever(entity) + break + } } catch (e: any) { - this.logger.warn('Error learning feedback options: ' + e?.message) - this.#sendToModuleLog('error', 'Error learning feedback options: ' + e?.message) + this.logger.warn('Error learning options: ' + e?.message) + this.#sendToModuleLog('error', 'Error learning options: ' + e?.message) } } /** - * Inform the child instance class about an feedback that has been deleted + * Inform the child instance class about an entity that has been deleted */ - async feedbackDelete(oldFeedback: FeedbackInstance): Promise { - if (oldFeedback.instance_id !== this.connectionId) throw new Error(`Feedback is for a different instance`) + async entityDelete(oldEntity: SomeEntityModel): Promise { + if (oldEntity.connectionId !== this.connectionId) throw new Error(`Entity is for a different connection`) - await this.#ipcWrapper.sendWithCb('updateFeedbacks', { - feedbacks: { - // Mark as deleted - [oldFeedback.id]: null, - }, - }) + switch (oldEntity.type) { + case EntityModelType.Action: + await this.#ipcWrapper.sendWithCb('updateActions', { + actions: { + // Mark as deleted + [oldEntity.id]: null, + }, + }) + break + case EntityModelType.Feedback: + await this.#ipcWrapper.sendWithCb('updateFeedbacks', { + feedbacks: { + // Mark as deleted + [oldEntity.id]: null, + }, + }) + break + default: + assertNever(oldEntity) + break + } } /** * Inform the child instance class about an updated action */ - async actionUpdate(action: ActionInstance, controlId: string): Promise { - if (action.instance !== this.connectionId) throw new Error(`Action is for a different instance`) + async #actionUpdate(action: ActionEntityModel, controlId: string): Promise { + if (action.connectionId !== this.connectionId) throw new Error(`Action is for a different connection`) if (action.disabled) return await this.#ipcWrapper.sendWithCb('updateActions', { @@ -411,7 +477,7 @@ export class SocketEventsHandler { [action.id]: { id: action.id, controlId: controlId, - actionId: action.action, + actionId: action.definitionId, options: action.options, upgradeIndex: action.upgradeIndex ?? null, @@ -420,69 +486,19 @@ export class SocketEventsHandler { }, }) } - /** - * Inform the child instance class about an action that has been deleted - */ - async actionDelete(oldAction: ActionInstance): Promise { - if (oldAction.instance !== this.connectionId) throw new Error(`Action is for a different instance`) - - await this.#ipcWrapper.sendWithCb('updateActions', { - actions: { - // Mark as deleted - [oldAction.id]: null, - }, - }) - } - - /** - * - */ - async actionLearnValues( - action: ActionInstance, - controlId: string - ): Promise { - if (action.instance !== this.connectionId) throw new Error(`Action is for a different instance`) - - const actionSpec = this.#deps.instanceDefinitions.getActionDefinition(this.connectionId, action.action) - const learnTimeout = actionSpec?.learnTimeout - - try { - const msg = await this.#ipcWrapper.sendWithCb( - 'learnAction', - { - action: { - id: action.id, - controlId: controlId, - actionId: action.action, - options: action.options, - - upgradeIndex: null, - disabled: !!action.disabled, - }, - }, - undefined, - learnTimeout - ) - - return msg.options - } catch (e: any) { - this.logger.warn('Error learning action options: ' + e?.message) - this.#sendToModuleLog('error', 'Error learning action options: ' + e?.message) - } - } /** * Tell the child instance class to execute an action */ - async actionRun(action: ActionInstance, extras: RunActionExtras): Promise { - if (action.instance !== this.connectionId) throw new Error(`Action is for a different instance`) + async actionRun(action: ActionEntityModel, extras: RunActionExtras): Promise { + if (action.connectionId !== this.connectionId) throw new Error(`Action is for a different connection`) try { await this.#ipcWrapper.sendWithCb('executeAction', { action: { id: action.id, controlId: extras?.controlId, - actionId: action.action, + actionId: action.definitionId, options: action.options, upgradeIndex: null, @@ -618,17 +634,25 @@ export class SocketEventsHandler { * Handle settings action definitions from the child process */ async #handleSetActionDefinitions(msg: SetActionDefinitionsMessage): Promise { - const actions: Record = {} + const actions: Record = {} for (const rawAction of msg.actions || []) { actions[rawAction.id] = { + entityType: EntityModelType.Action, label: rawAction.name, description: rawAction.description, - // @ts-expect-error @companion-module-base exposes these through a mapping that loses the differentiation between types - options: rawAction.options || [], + // @companion-module-base exposes these through a mapping that loses the differentiation between types + options: (rawAction.options || []) as any[], hasLearn: !!rawAction.hasLearn, learnTimeout: rawAction.learnTimeout, - } + + showInvert: false, + showButtonPreview: false, + supportsChildGroups: [], + + feedbackType: null, + feedbackStyle: undefined, + } satisfies Complete } this.#deps.instanceDefinitions.setActionDefinitions(this.connectionId, actions) @@ -638,20 +662,24 @@ export class SocketEventsHandler { * Handle settings feedback definitions from the child process */ async #handleSetFeedbackDefinitions(msg: SetFeedbackDefinitionsMessage): Promise { - const feedbacks: Record = {} + const feedbacks: Record = {} for (const rawFeedback of msg.feedbacks || []) { feedbacks[rawFeedback.id] = { + entityType: EntityModelType.Feedback, label: rawFeedback.name, description: rawFeedback.description, - // @ts-expect-error @companion-module-base exposes these through a mapping that loses the differentiation between types - options: rawFeedback.options || [], - type: rawFeedback.type, - style: rawFeedback.defaultStyle, + // @companion-module-base exposes these through a mapping that loses the differentiation between types + options: (rawFeedback.options || []) as any[], + feedbackType: rawFeedback.type, + feedbackStyle: rawFeedback.defaultStyle, hasLearn: !!rawFeedback.hasLearn, learnTimeout: rawFeedback.learnTimeout, showInvert: rawFeedback.showInvert ?? shouldShowInvertForFeedback(rawFeedback.options || []), - } + + showButtonPreview: false, + supportsChildGroups: [], + } satisfies Complete } this.#deps.instanceDefinitions.setFeedbackDefinitions(this.connectionId, feedbacks) @@ -819,11 +847,12 @@ export class SocketEventsHandler { if (feedback) { const control = this.#deps.controls.getControl(feedback.controlId) const found = - control?.supportsFeedbacks && - control.feedbacks.feedbackReplace( + control?.supportsEntities && + control.entities.entityReplace( { + type: EntityModelType.Feedback, id: feedback.id, - type: feedback.feedbackId, + definitionId: feedback.feedbackId, options: feedback.options, style: feedback.style, isInverted: feedback.isInverted, @@ -840,11 +869,12 @@ export class SocketEventsHandler { if (action) { const control = this.#deps.controls.getControl(action.controlId) const found = - control?.supportsActions && - control.actionReplace( + control?.supportsEntities && + control.entities.entityReplace( { + type: EntityModelType.Action, id: action.id, - action: action.actionId, + definitionId: action.actionId, options: action.options, }, true diff --git a/companion/lib/Internal/ActionRecorder.ts b/companion/lib/Internal/ActionRecorder.ts index 697454b9dd..8713e20b84 100644 --- a/companion/lib/Internal/ActionRecorder.ts +++ b/companion/lib/Internal/ActionRecorder.ts @@ -22,15 +22,15 @@ import type { PageController } from '../Page/Controller.js' import type { ActionForVisitor, FeedbackForVisitor, - FeedbackInstanceExt, + FeedbackEntityModelExt, InternalModuleFragment, InternalVisitor, InternalActionDefinition, InternalFeedbackDefinition, } from './Types.js' -import type { ActionInstance } from '@companion-app/shared/Model/ActionModel.js' import type { RunActionExtras, VariableDefinitionTmp } from '../Instance/Wrapper.js' import { validateActionSetId } from '@companion-app/shared/ControlId.js' +import type { ActionEntityModel } from '@companion-app/shared/Model/EntityModel.js' export class InternalActionRecorder implements InternalModuleFragment { readonly #logger = LogController.createLogger('Internal/ActionRecorder') @@ -168,8 +168,8 @@ export class InternalActionRecorder implements InternalModuleFragment { } } - executeAction(action: ActionInstance, extras: RunActionExtras): boolean { - if (action.action === 'action_recorder_set_recording') { + executeAction(action: ActionEntityModel, extras: RunActionExtras): boolean { + if (action.definitionId === 'action_recorder_set_recording') { const session = this.#actionRecorder.getSession() if (session) { let newState = action.options.enable == 'true' @@ -179,7 +179,7 @@ export class InternalActionRecorder implements InternalModuleFragment { } return true - } else if (action.action === 'action_recorder_set_connections') { + } else if (action.definitionId === 'action_recorder_set_connections') { const session = this.#actionRecorder.getSession() if (session) { let result = new Set(session.connectionIds) @@ -214,7 +214,7 @@ export class InternalActionRecorder implements InternalModuleFragment { } return true - } else if (action.action === 'action_recorder_save_to_button') { + } else if (action.definitionId === 'action_recorder_save_to_button') { let stepId = this.#internalModule.parseVariablesForInternalActionOrFeedback(action.options.step, extras).text let setId = this.#internalModule.parseVariablesForInternalActionOrFeedback(action.options.set, extras).text const pageRaw = this.#internalModule.parseVariablesForInternalActionOrFeedback(action.options.page, extras).text @@ -255,7 +255,7 @@ export class InternalActionRecorder implements InternalModuleFragment { } return true - } else if (action.action === 'action_recorder_discard_actions') { + } else if (action.definitionId === 'action_recorder_discard_actions') { this.#actionRecorder.discardActions() return true @@ -267,10 +267,10 @@ export class InternalActionRecorder implements InternalModuleFragment { getFeedbackDefinitions(): Record { return { action_recorder_check_connections: { - type: 'boolean', + feedbackType: 'boolean', label: 'Action Recorder: Check if specified connections are selected', description: undefined, - style: { + feedbackStyle: { color: 0xffffff, bgcolor: 0xff0000, }, @@ -313,8 +313,8 @@ export class InternalActionRecorder implements InternalModuleFragment { /** * Get an updated value for a feedback */ - executeFeedback(feedback: FeedbackInstanceExt): boolean | void { - if (feedback.type === 'action_recorder_check_connections') { + executeFeedback(feedback: FeedbackEntityModelExt): boolean | void { + if (feedback.definitionId === 'action_recorder_check_connections') { const session = this.#actionRecorder.getSession() if (!session) return false diff --git a/companion/lib/Internal/BuildingBlocks.ts b/companion/lib/Internal/BuildingBlocks.ts index b28ef3ebcd..df4f9ce549 100644 --- a/companion/lib/Internal/BuildingBlocks.ts +++ b/companion/lib/Internal/BuildingBlocks.ts @@ -15,7 +15,6 @@ * */ -import type { FeedbackInstance } from '@companion-app/shared/Model/FeedbackModel.js' import LogController from '../Log/Controller.js' import type { FeedbackForVisitor, @@ -25,10 +24,10 @@ import type { InternalActionDefinition, ActionForVisitor, } from './Types.js' -import type { ActionInstance } from '@companion-app/shared/Model/ActionModel.js' import type { ActionRunner } from '../Controls/ActionRunner.js' import type { RunActionExtras } from '../Instance/Wrapper.js' import type { InternalController } from './Controller.js' +import { ActionEntityModel, EntityModelType, FeedbackEntityModel } from '@companion-app/shared/Model/EntityModel.js' export class InternalBuildingBlocks implements InternalModuleFragment { readonly #logger = LogController.createLogger('Internal/BuildingBlocks') @@ -44,10 +43,10 @@ export class InternalBuildingBlocks implements InternalModuleFragment { getFeedbackDefinitions(): Record { return { logic_and: { - type: 'boolean', + feedbackType: 'boolean', label: 'Logic: AND', description: 'Test if multiple conditions are true', - style: { + feedbackStyle: { color: 0xffffff, bgcolor: 0xff0000, }, @@ -55,13 +54,21 @@ export class InternalBuildingBlocks implements InternalModuleFragment { options: [], hasLearn: false, learnTimeout: undefined, - supportsChildFeedbacks: true, + supportsChildGroups: [ + { + type: EntityModelType.Feedback, + booleanFeedbacksOnly: true, + groupId: 'default', + entityTypeLabel: 'condition', + label: '', + }, + ], }, logic_or: { - type: 'boolean', + feedbackType: 'boolean', label: 'Logic: OR', description: 'Test if one or more of multiple conditions is true', - style: { + feedbackStyle: { color: 0xffffff, bgcolor: 0xff0000, }, @@ -69,13 +76,21 @@ export class InternalBuildingBlocks implements InternalModuleFragment { options: [], hasLearn: false, learnTimeout: undefined, - supportsChildFeedbacks: true, + supportsChildGroups: [ + { + type: EntityModelType.Feedback, + booleanFeedbacksOnly: true, + groupId: 'default', + entityTypeLabel: 'condition', + label: '', + }, + ], }, logic_xor: { - type: 'boolean', + feedbackType: 'boolean', label: 'Logic: XOR', description: 'Test if only one of multiple conditions is true', - style: { + feedbackStyle: { color: 0xffffff, bgcolor: 0xff0000, }, @@ -83,7 +98,41 @@ export class InternalBuildingBlocks implements InternalModuleFragment { options: [], hasLearn: false, learnTimeout: undefined, - supportsChildFeedbacks: true, + supportsChildGroups: [ + { + type: EntityModelType.Feedback, + booleanFeedbacksOnly: true, + groupId: 'default', + entityTypeLabel: 'condition', + label: '', + }, + ], + }, + logic_conditionalise_advanced: { + feedbackType: 'advanced', + label: 'Conditionalise existing feedbacks', + description: "Make 'advanced' feedbacks conditional", + feedbackStyle: undefined, + showInvert: false, + options: [], + hasLearn: false, + learnTimeout: undefined, + supportsChildGroups: [ + { + type: EntityModelType.Feedback, + booleanFeedbacksOnly: true, + groupId: 'children', + entityTypeLabel: 'condition', + label: 'Condition', + hint: 'This feedback will only execute when all of the conditions are true', + }, + { + type: EntityModelType.Feedback, + groupId: 'feedbacks', + entityTypeLabel: 'feedback', + label: 'Feedbacks', + }, + ], }, } } @@ -110,7 +159,14 @@ export class InternalBuildingBlocks implements InternalModuleFragment { ], hasLearn: false, learnTimeout: undefined, - supportsChildActionGroups: ['default'], + supportsChildGroups: [ + { + type: EntityModelType.Action, + groupId: 'default', + entityTypeLabel: 'action', + label: '', + }, + ], }, wait: { label: 'Wait', @@ -134,14 +190,14 @@ export class InternalBuildingBlocks implements InternalModuleFragment { /** * Execute a logic feedback */ - executeLogicFeedback(feedback: FeedbackInstance, childValues: boolean[]): boolean { - if (feedback.type === 'logic_and') { + executeLogicFeedback(feedback: FeedbackEntityModel, childValues: boolean[]): boolean { + if (feedback.definitionId === 'logic_and' || feedback.definitionId === 'logic_conditionalise_advanced') { if (childValues.length === 0) return !!feedback.isInverted return childValues.reduce((acc, val) => acc && val, true) === !feedback.isInverted - } else if (feedback.type === 'logic_or') { + } else if (feedback.definitionId === 'logic_or') { return childValues.reduce((acc, val) => acc || val, false) - } else if (feedback.type === 'logic_xor') { + } else if (feedback.definitionId === 'logic_xor') { const isSingleTrue = childValues.reduce((acc, val) => acc + (val ? 1 : 0), 0) === 1 return isSingleTrue === !feedback.isInverted } else { @@ -150,8 +206,8 @@ export class InternalBuildingBlocks implements InternalModuleFragment { } } - executeAction(action: ActionInstance, extras: RunActionExtras): Promise | boolean { - if (action.action === 'wait') { + executeAction(action: ActionEntityModel, extras: RunActionExtras): Promise | boolean { + if (action.definitionId === 'wait') { if (extras.abortDelayed.aborted) return true let delay = 0 @@ -170,7 +226,7 @@ export class InternalBuildingBlocks implements InternalModuleFragment { // No wait, return immediately return true } - } else if (action.action === 'action_group') { + } else if (action.definitionId === 'action_group') { if (extras.abortDelayed.aborted) return true let executeSequential = false diff --git a/companion/lib/Internal/Controller.ts b/companion/lib/Internal/Controller.ts index cddc613204..a7ee64764d 100644 --- a/companion/lib/Internal/Controller.ts +++ b/companion/lib/Internal/Controller.ts @@ -21,26 +21,30 @@ import { ParseInternalControlReference } from './Util.js' import type { ActionForVisitor, FeedbackForVisitor, - FeedbackInstanceExt, + FeedbackEntityModelExt, InternalModuleFragment, InternalVisitor, } from './Types.js' -import type { ActionInstance } from '@companion-app/shared/Model/ActionModel.js' -import type { FeedbackInstance } from '@companion-app/shared/Model/FeedbackModel.js' -import type { FragmentFeedbackInstance } from '../Controls/Fragments/FragmentFeedbackInstance.js' import type { RunActionExtras } from '../Instance/Wrapper.js' import type { CompanionVariableValue, CompanionVariableValues } from '@companion-module/base' import type { ControlsController, NewFeedbackValue } from '../Controls/Controller.js' import type { VariablesCache } from '../Variables/Util.js' import type { ParseVariablesResult } from '../Variables/Util.js' import type { ControlLocation } from '@companion-app/shared/Model/Common.js' -import type { FeedbackDefinition } from '@companion-app/shared/Model/FeedbackDefinitionModel.js' -import type { ActionDefinition } from '@companion-app/shared/Model/ActionDefinitionModel.js' import type { VariablesController } from '../Variables/Controller.js' import type { InstanceDefinitions } from '../Instance/Definitions.js' import type { PageController } from '../Page/Controller.js' import LogController from '../Log/Controller.js' -import type { FragmentActionInstance } from '../Controls/Fragments/FragmentActionInstance.js' +import { + ActionEntityModel, + EntityModelType, + FeedbackEntityModel, + SomeEntityModel, +} from '@companion-app/shared/Model/EntityModel.js' +import type { ControlEntityInstance } from '../Controls/Entities/EntityInstance.js' +import { assertNever } from '@companion-app/shared/Util.js' +import { ClientEntityDefinition } from '@companion-app/shared/Model/EntityDefinitionModel.js' +import { Complete } from '@companion-module/base/dist/util.js' export class InternalController { readonly #logger = LogController.createLogger('Internal/Controller') @@ -50,7 +54,7 @@ export class InternalController { readonly #instanceDefinitions: InstanceDefinitions readonly #variablesController: VariablesController - readonly #feedbacks = new Map() + readonly #feedbacks = new Map() readonly #buildingBlocksFragment: InternalBuildingBlocks readonly #fragments: InternalModuleFragment[] @@ -89,40 +93,25 @@ export class InternalController { this.regenerateVariables() } + /** + * Trigger the first update after launch of each action and feedback + */ firstUpdate(): void { if (!this.#initialized) throw new Error(`InternalController is not initialized`) // Find all the feedbacks on controls const allControls = this.#controlsController.getAllControls() for (const [controlId, control] of allControls.entries()) { - // Discover feedbacks to process - if (control.supportsFeedbacks) { - for (let feedback of control.feedbacks.getFlattenedFeedbackInstances('internal')) { - if (control.feedbacks.feedbackReplace) { - const newFeedback = this.feedbackUpgrade(feedback, controlId) - if (newFeedback) { - feedback = newFeedback - control.feedbacks.feedbackReplace(newFeedback) - } - } + if (!control.supportsEntities) continue - this.feedbackUpdate(feedback, controlId) - } - } + const allEntities = control.entities.getAllEntities() + for (const entity of allEntities) { + if (entity.connectionId !== 'internal') continue - // Discover actions to process - if (control.supportsActions) { - const actions = control.getFlattenedActionInstances() - - for (const action of actions) { - if (action.instance === 'internal') { - // Try and run an upgrade - const newAction = this.actionUpgrade(action, controlId) - if (newAction) { - control.actionReplace(newAction) - } - } - } + const newEntity = this.entityUpgrade(entity.asEntityModel(), controlId) + if (newEntity) control.entities.entityReplace(newEntity) + + this.entityUpdate(newEntity || entity.asEntityModel(), controlId) } } @@ -134,13 +123,33 @@ export class InternalController { } } + /** + * Perform an upgrade for an entity + * @param entity + * @param controlId + * @returns Updated entity if any changes were made + */ + entityUpgrade(entity: SomeEntityModel, controlId: string): SomeEntityModel | undefined { + switch (entity.type) { + case EntityModelType.Feedback: { + return this.#feedbackUpgrade(entity, controlId) + } + case EntityModelType.Action: { + return this.#actionUpgrade(entity, controlId) + } + default: + assertNever(entity) + return undefined + } + } + /** * Perform an upgrade for an action * @param action * @param controlId * @returns Updated action if any changes were made */ - actionUpgrade(action: ActionInstance, controlId: string): ActionInstance | undefined { + #actionUpgrade(action: ActionEntityModel, controlId: string): ActionEntityModel | undefined { if (!this.#initialized) throw new Error(`InternalController is not initialized`) for (const fragment of this.#fragments) { @@ -148,7 +157,6 @@ export class InternalController { try { const newAction = fragment.actionUpgrade(action, controlId) if (newAction !== undefined) { - // newAction.actionId = newAction.action // It was handled, so break return newAction } @@ -168,7 +176,7 @@ export class InternalController { * @param controlId * @returns Updated feedback if any changes were made */ - feedbackUpgrade(feedback: FeedbackInstance, controlId: string): FeedbackInstance | undefined { + #feedbackUpgrade(feedback: FeedbackEntityModel, controlId: string): FeedbackEntityModel | undefined { if (!this.#initialized) throw new Error(`InternalController is not initialized`) for (const fragment of this.#fragments) { @@ -176,7 +184,6 @@ export class InternalController { try { const newFeedback = fragment.feedbackUpgrade(feedback, controlId) if (newFeedback !== undefined) { - // newFeedback.feedbackId = newFeedback.type // It was handled, so break return newFeedback } @@ -191,18 +198,24 @@ export class InternalController { return undefined } + entityUpdate(entity: SomeEntityModel, controlId: string): void { + if (entity.type === EntityModelType.Feedback) { + this.#feedbackUpdate(entity, controlId) + } + } + /** * A feedback has changed, and state should be updated */ - feedbackUpdate(feedback: FeedbackInstance, controlId: string): void { + #feedbackUpdate(feedback: FeedbackEntityModel, controlId: string): void { if (!this.#initialized) throw new Error(`InternalController is not initialized`) - if (feedback.instance_id !== 'internal') throw new Error(`Feedback is not for internal instance`) + if (feedback.connectionId !== 'internal') throw new Error(`Feedback is not for internal instance`) if (feedback.disabled) return const location = this.#pageController.getLocationOfControlId(controlId) - const cloned: FeedbackInstanceExt = { + const cloned: FeedbackEntityModelExt = { ...cloneDeep(feedback), controlId, location, @@ -221,19 +234,21 @@ export class InternalController { /** * A feedback has been deleted */ - feedbackDelete(feedback: FeedbackInstance): void { + entityDelete(entity: SomeEntityModel): void { if (!this.#initialized) throw new Error(`InternalController is not initialized`) - if (feedback.instance_id !== 'internal') throw new Error(`Feedback is not for internal instance`) + if (entity.connectionId !== 'internal') throw new Error(`Feedback is not for internal instance`) + + if (entity.type !== EntityModelType.Feedback) return - this.#feedbacks.delete(feedback.id) + this.#feedbacks.delete(entity.id) for (const fragment of this.#fragments) { - if ('forgetFeedback' in fragment && typeof fragment.forgetFeedback === 'function') { + if (typeof fragment.forgetFeedback === 'function') { try { - fragment.forgetFeedback(feedback) + fragment.forgetFeedback(entity) } catch (e: any) { - this.#logger.silly(`Feedback forget failed: ${JSON.stringify(feedback)} - ${e?.message ?? e} ${e?.stack}`) + this.#logger.silly(`Feedback forget failed: ${JSON.stringify(entity)} - ${e?.message ?? e} ${e?.stack}`) } } } @@ -241,7 +256,7 @@ export class InternalController { /** * Get an updated value for a feedback */ - #feedbackGetValue(feedback: FeedbackInstanceExt): any { + #feedbackGetValue(feedback: FeedbackEntityModelExt): any { for (const fragment of this.#fragments) { if ('executeFeedback' in fragment && typeof fragment.executeFeedback === 'function') { let value: ReturnType['executeFeedback']> | undefined @@ -269,44 +284,57 @@ export class InternalController { /** * Visit any references in some inactive internal actions and feedbacks */ - visitReferences( - visitor: InternalVisitor, - rawActions: ActionInstance[], - actions: FragmentActionInstance[], - rawFeedbacks: FeedbackInstance[], - feedbacks: FragmentFeedbackInstance[] - ): void { + visitReferences(visitor: InternalVisitor, rawEntities: SomeEntityModel[], entities: ControlEntityInstance[]): void { if (!this.#initialized) throw new Error(`InternalController is not initialized`) const simpleInternalFeedbacks: FeedbackForVisitor[] = [] - - for (const feedback of rawFeedbacks) { - if (feedback.instance_id !== 'internal') continue - simpleInternalFeedbacks.push(feedback) - } - for (const feedback of feedbacks) { - if (feedback.connectionId !== 'internal') continue - const feedbackInstance = feedback.asFeedbackInstance() - simpleInternalFeedbacks.push({ - id: feedbackInstance.id, - type: feedbackInstance.type, - options: feedback.rawOptions, // Ensure the options is not a copy/clone - }) - } - const simpleInternalActions: ActionForVisitor[] = [] - for (const action of rawActions) { - if (action.instance !== 'internal') continue - simpleInternalActions.push(action) + + for (const entity of rawEntities) { + if (entity.connectionId !== 'internal') continue + + switch (entity.type) { + case EntityModelType.Feedback: + simpleInternalFeedbacks.push({ + id: entity.id, + type: entity.definitionId, + options: entity.options, + }) + break + case EntityModelType.Action: + simpleInternalActions.push({ + id: entity.id, + action: entity.definitionId, + options: entity.options, + }) + break + default: + assertNever(entity) + break + } } - for (const action of actions) { - if (action.connectionId !== 'internal') continue - const actionInstance = action.asActionInstance() - simpleInternalActions.push({ - id: actionInstance.id, - action: actionInstance.action, - options: action.rawOptions, // Ensure the options is not a copy/clone - }) + for (const entity of entities) { + if (entity.connectionId !== 'internal') continue + + switch (entity.type) { + case EntityModelType.Feedback: + simpleInternalFeedbacks.push({ + id: entity.id, + type: entity.definitionId, + options: entity.rawOptions, // Ensure the options is not a copy/clone + }) + break + case EntityModelType.Action: + simpleInternalActions.push({ + id: entity.id, + action: entity.definitionId, + options: entity.rawOptions, // Ensure the options is not a copy/clone + }) + break + default: + assertNever(entity.type) + break + } } for (const fragment of this.#fragments) { @@ -319,7 +347,7 @@ export class InternalController { /** * Run a single internal action */ - async executeAction(action: ActionInstance, extras: RunActionExtras): Promise { + async executeAction(action: ActionEntityModel, extras: RunActionExtras): Promise { if (!this.#initialized) throw new Error(`InternalController is not initialized`) for (const fragment of this.#fragments) { @@ -347,7 +375,7 @@ export class InternalController { /** * Execute a logic feedback */ - executeLogicFeedback(feedback: FeedbackInstance, childValues: boolean[]): boolean { + executeLogicFeedback(feedback: FeedbackEntityModel, childValues: boolean[]): boolean { if (!this.#initialized) throw new Error(`InternalController is not initialized`) return this.#buildingBlocksFragment.executeLogicFeedback(feedback, childValues) @@ -372,7 +400,7 @@ export class InternalController { const newValues: NewFeedbackValue[] = [] for (const [id, feedback] of this.#feedbacks.entries()) { - if (typesSet.size === 0 || typesSet.has(feedback.type)) { + if (typesSet.size === 0 || typesSet.has(feedback.definitionId)) { newValues.push({ id: id, controlId: feedback.controlId, @@ -407,7 +435,7 @@ export class InternalController { #regenerateActions(): void { if (!this.#initialized) throw new Error(`InternalController is not initialized`) - let actions: Record = {} + let actions: Record = {} for (const fragment of this.#fragments) { if ('getActionDefinitions' in fragment && typeof fragment.getActionDefinitions === 'function') { @@ -418,8 +446,13 @@ export class InternalController { learnTimeout: action.learnTimeout, showButtonPreview: action.showButtonPreview ?? false, - supportsChildActionGroups: action.supportsChildActionGroups ?? [], - } + supportsChildGroups: action.supportsChildGroups ?? [], + + entityType: EntityModelType.Action, + showInvert: false, + feedbackType: null, + feedbackStyle: undefined, + } satisfies Complete } } } @@ -429,7 +462,7 @@ export class InternalController { #regenerateFeedbacks(): void { if (!this.#initialized) throw new Error(`InternalController is not initialized`) - let feedbacks: Record = {} + let feedbacks: Record = {} for (const fragment of this.#fragments) { if ('getFeedbackDefinitions' in fragment && typeof fragment.getFeedbackDefinitions === 'function') { @@ -440,9 +473,10 @@ export class InternalController { hasLearn: feedback.hasLearn ?? false, learnTimeout: feedback.learnTimeout, + entityType: EntityModelType.Feedback, showButtonPreview: feedback.showButtonPreview ?? false, - supportsChildFeedbacks: feedback.supportsChildFeedbacks ?? false, - } + supportsChildGroups: feedback.supportsChildGroups ?? [], + } satisfies Complete } } } @@ -501,7 +535,7 @@ export class InternalController { */ parseVariablesForInternalActionOrFeedback( str: string, - extras: RunActionExtras | FeedbackInstanceExt, + extras: RunActionExtras | FeedbackEntityModelExt, injectedVariableValues?: VariablesCache ): ParseVariablesResult { if (!this.#initialized) throw new Error(`InternalController is not initialized`) @@ -523,7 +557,7 @@ export class InternalController { */ executeExpressionForInternalActionOrFeedback( str: string, - extras: RunActionExtras | FeedbackInstanceExt, + extras: RunActionExtras | FeedbackEntityModelExt, requiredType?: string, injectedVariableValues?: CompanionVariableValues ): { value: boolean | number | string | undefined; variableIds: Set } { @@ -545,7 +579,7 @@ export class InternalController { * */ parseInternalControlReferenceForActionOrFeedback( - extras: RunActionExtras | FeedbackInstanceExt, + extras: RunActionExtras | FeedbackEntityModelExt, options: Record, useVariableFields: boolean ): { diff --git a/companion/lib/Internal/Controls.ts b/companion/lib/Internal/Controls.ts index aacc356aa1..c40460ee75 100644 --- a/companion/lib/Internal/Controls.ts +++ b/companion/lib/Internal/Controls.ts @@ -22,7 +22,7 @@ import { ButtonStyleProperties } from '@companion-app/shared/Style.js' import debounceFn from 'debounce-fn' import type { FeedbackForVisitor, - FeedbackInstanceExt, + FeedbackEntityModelExt, InternalModuleFragment, InternalVisitor, ExecuteFeedbackResultWithReferences, @@ -37,10 +37,9 @@ import type { ControlsController } from '../Controls/Controller.js' import type { PageController } from '../Page/Controller.js' import type { VariablesValues } from '../Variables/Values.js' import type { RunActionExtras } from '../Instance/Wrapper.js' -import type { FeedbackInstance } from '@companion-app/shared/Model/FeedbackModel.js' -import type { ActionInstance } from '@companion-app/shared/Model/ActionModel.js' import type { InternalActionInputField, InternalFeedbackInputField } from '@companion-app/shared/Model/Options.js' import type { ControlLocation } from '@companion-app/shared/Model/Common.js' +import type { ActionEntityModel, FeedbackEntityModel } from '@companion-app/shared/Model/EntityModel.js' const CHOICES_DYNAMIC_LOCATION: InternalFeedbackInputField[] = [ { @@ -193,7 +192,7 @@ export class InternalControls implements InternalModuleFragment { #fetchLocationAndControlId( options: Record, - extras: RunActionExtras | FeedbackInstanceExt, + extras: RunActionExtras | FeedbackEntityModelExt, useVariableFields = false ): { theControlId: string | null @@ -617,11 +616,11 @@ export class InternalControls implements InternalModuleFragment { getFeedbackDefinitions(): Record { return { bank_style: { - type: 'advanced', + feedbackType: 'advanced', label: 'Button: Use another buttons style', description: 'Imitate the style of another button', showButtonPreview: true, - style: undefined, + feedbackStyle: undefined, showInvert: false, options: [ ...CHOICES_DYNAMIC_LOCATION, @@ -636,11 +635,11 @@ export class InternalControls implements InternalModuleFragment { ], }, bank_pushed: { - type: 'boolean', + feedbackType: 'boolean', label: 'Button: When pushed', description: 'Change style when a button is being pressed', showButtonPreview: true, - style: { + feedbackStyle: { color: 0xffffff, bgcolor: 0xff0000, }, @@ -656,11 +655,11 @@ export class InternalControls implements InternalModuleFragment { ], }, bank_current_step: { - type: 'boolean', + feedbackType: 'boolean', label: 'Button: Check step', description: 'Change style based on the current step of a button', showButtonPreview: true, - style: { + feedbackStyle: { color: 0x000000, bgcolor: 0x00ff00, }, @@ -681,7 +680,7 @@ export class InternalControls implements InternalModuleFragment { } } - feedbackUpgrade(feedback: FeedbackInstance, _controlId: string): FeedbackInstance | void { + feedbackUpgrade(feedback: FeedbackEntityModel, _controlId: string): FeedbackEntityModel | void { let changed = false if (feedback.options.bank !== undefined) { @@ -711,8 +710,8 @@ export class InternalControls implements InternalModuleFragment { if (changed) return feedback } - executeFeedback(feedback: FeedbackInstanceExt): ExecuteFeedbackResultWithReferences | void { - if (feedback.type === 'bank_style') { + executeFeedback(feedback: FeedbackEntityModelExt): ExecuteFeedbackResultWithReferences | void { + if (feedback.definitionId === 'bank_style') { const { theLocation, referencedVariables } = this.#fetchLocationAndControlId(feedback.options, feedback, true) if ( @@ -760,16 +759,16 @@ export class InternalControls implements InternalModuleFragment { value: {}, } } - } else if (feedback.type === 'bank_pushed') { + } else if (feedback.definitionId === 'bank_pushed') { const { theControlId, referencedVariables } = this.#fetchLocationAndControlId(feedback.options, feedback, true) const control = theControlId && this.#controlsController.getControl(theControlId) if (control && control.supportsPushed) { let isPushed = !!control.pushed - if (!isPushed && feedback.options.latch_compatability && control.supportsSteps) { + if (!isPushed && feedback.options.latch_compatability && control.supportsActionSets) { // Backwards compatibility for the old 'latching' behaviour - isPushed = control.getActiveStepIndex() !== 0 + isPushed = control.actionSets.getActiveStepIndex() !== 0 } return { @@ -782,16 +781,16 @@ export class InternalControls implements InternalModuleFragment { value: false, } } - } else if (feedback.type == 'bank_current_step') { + } else if (feedback.definitionId == 'bank_current_step') { const { theControlId } = this.#fetchLocationAndControlId(feedback.options, feedback, true) const theStep = feedback.options.step const control = theControlId && this.#controlsController.getControl(theControlId) - if (control && control.supportsSteps) { + if (control && control.supportsActionSets) { return { referencedVariables: [], - value: control.getActiveStepIndex() + 1 === theStep, + value: control.actionSets.getActiveStepIndex() + 1 === Number(theStep), } } else { return { @@ -802,15 +801,15 @@ export class InternalControls implements InternalModuleFragment { } } - actionUpgrade(action: ActionInstance, _controlId: string): ActionInstance | void { + actionUpgrade(action: ActionEntityModel, _controlId: string): ActionEntityModel | void { let changed = false if ( - action.action === 'button_pressrelease' || - action.action === 'button_pressrelease_if_expression' || - action.action === 'button_pressrelease_condition' || - action.action === 'button_pressrelease_condition_variable' || - action.action === 'button_press' || - action.action === 'button_release' + action.definitionId === 'button_pressrelease' || + action.definitionId === 'button_pressrelease_if_expression' || + action.definitionId === 'button_pressrelease_condition' || + action.definitionId === 'button_pressrelease_condition_variable' || + action.definitionId === 'button_press' || + action.definitionId === 'button_release' ) { if (action.options.force === undefined) { action.options.force = true @@ -819,8 +818,8 @@ export class InternalControls implements InternalModuleFragment { } } - if (action.action === 'button_pressrelease_condition_variable') { - action.action = 'button_pressrelease_condition' + if (action.definitionId === 'button_pressrelease_condition_variable') { + action.definitionId = 'button_pressrelease_condition' // Also mangle the page & bank inputs action.options.page_from_variable = true @@ -836,22 +835,22 @@ export class InternalControls implements InternalModuleFragment { // Update bank -> location if ( action.options.location_target === undefined && - (action.action === 'button_pressrelease' || - action.action === 'button_press' || - action.action === 'button_pressrelease_if_expression' || - action.action === 'button_pressrelease_condition' || - action.action === 'button_press' || - action.action === 'button_release' || - action.action === 'button_rotate_left' || - action.action === 'button_rotate_right' || - action.action === 'button_text' || - action.action === 'textcolor' || - action.action === 'bgcolor' || - action.action === 'panic_bank' || - action.action === 'bank_current_step' || - action.action === 'bank_current_step_condition' || - action.action === 'bank_current_step_if_expression' || - action.action === 'bank_current_step_delta') + (action.definitionId === 'button_pressrelease' || + action.definitionId === 'button_press' || + action.definitionId === 'button_pressrelease_if_expression' || + action.definitionId === 'button_pressrelease_condition' || + action.definitionId === 'button_press' || + action.definitionId === 'button_release' || + action.definitionId === 'button_rotate_left' || + action.definitionId === 'button_rotate_right' || + action.definitionId === 'button_text' || + action.definitionId === 'textcolor' || + action.definitionId === 'bgcolor' || + action.definitionId === 'panic_bank' || + action.definitionId === 'bank_current_step' || + action.definitionId === 'bank_current_step_condition' || + action.definitionId === 'bank_current_step_if_expression' || + action.definitionId === 'bank_current_step_delta') ) { const oldOptions = { ...action.options } delete action.options.bank @@ -893,8 +892,8 @@ export class InternalControls implements InternalModuleFragment { if (changed) return action } - executeAction(action: ActionInstance, extras: RunActionExtras): boolean { - if (action.action === 'button_pressrelease') { + executeAction(action: ActionEntityModel, extras: RunActionExtras): boolean { + if (action.definitionId === 'button_pressrelease') { const { theControlId } = this.#fetchLocationAndControlId(action.options, extras, true) if (!theControlId) return true @@ -903,7 +902,7 @@ export class InternalControls implements InternalModuleFragment { this.#controlsController.pressControl(theControlId, true, extras.surfaceId, forcePress) this.#controlsController.pressControl(theControlId, false, extras.surfaceId, forcePress) return true - } else if (action.action == 'button_pressrelease_if_expression') { + } else if (action.definitionId == 'button_pressrelease_if_expression') { const { theControlId } = this.#fetchLocationAndControlId(action.options, extras, true) if (!theControlId) return true @@ -920,7 +919,7 @@ export class InternalControls implements InternalModuleFragment { this.#controlsController.pressControl(theControlId, false, extras.surfaceId, forcePress) } return true - } else if (action.action == 'button_pressrelease_condition') { + } else if (action.definitionId == 'button_pressrelease_condition') { const { theControlId } = this.#fetchLocationAndControlId(action.options, extras, true) if (!theControlId) return true @@ -941,7 +940,7 @@ export class InternalControls implements InternalModuleFragment { this.#controlsController.pressControl(theControlId, false, extras.surfaceId, forcePress) } return true - } else if (action.action == 'button_press_condition') { + } else if (action.definitionId == 'button_press_condition') { const { theControlId } = this.#fetchLocationAndControlId(action.options, extras, true) if (!theControlId) return true @@ -961,7 +960,7 @@ export class InternalControls implements InternalModuleFragment { this.#controlsController.pressControl(theControlId, true, extras.surfaceId, forcePress) } return true - } else if (action.action == 'button_release_condition') { + } else if (action.definitionId == 'button_release_condition') { const { theControlId } = this.#fetchLocationAndControlId(action.options, extras, true) if (!theControlId) return true @@ -981,31 +980,31 @@ export class InternalControls implements InternalModuleFragment { this.#controlsController.pressControl(theControlId, false, extras.surfaceId, forcePress) } return true - } else if (action.action === 'button_press') { + } else if (action.definitionId === 'button_press') { const { theControlId } = this.#fetchLocationAndControlId(action.options, extras, true) if (!theControlId) return true this.#controlsController.pressControl(theControlId, true, extras.surfaceId, !!action.options.force) return true - } else if (action.action === 'button_release') { + } else if (action.definitionId === 'button_release') { const { theControlId } = this.#fetchLocationAndControlId(action.options, extras, true) if (!theControlId) return true this.#controlsController.pressControl(theControlId, false, extras.surfaceId, !!action.options.force) return true - } else if (action.action === 'button_rotate_left') { + } else if (action.definitionId === 'button_rotate_left') { const { theControlId } = this.#fetchLocationAndControlId(action.options, extras, true) if (!theControlId) return true this.#controlsController.rotateControl(theControlId, false, extras.surfaceId) return true - } else if (action.action === 'button_rotate_right') { + } else if (action.definitionId === 'button_rotate_right') { const { theControlId } = this.#fetchLocationAndControlId(action.options, extras, true) if (!theControlId) return true this.#controlsController.rotateControl(theControlId, true, extras.surfaceId) return true - } else if (action.action === 'bgcolor') { + } else if (action.definitionId === 'bgcolor') { const { theControlId } = this.#fetchLocationAndControlId(action.options, extras, true) if (!theControlId) return true @@ -1014,7 +1013,7 @@ export class InternalControls implements InternalModuleFragment { control.styleSetFields({ bgcolor: action.options.color }) } return true - } else if (action.action === 'textcolor') { + } else if (action.definitionId === 'textcolor') { const { theControlId } = this.#fetchLocationAndControlId(action.options, extras, true) if (!theControlId) return true @@ -1023,7 +1022,7 @@ export class InternalControls implements InternalModuleFragment { control.styleSetFields({ color: action.options.color }) } return true - } else if (action.action === 'button_text') { + } else if (action.definitionId === 'button_text') { const { theControlId } = this.#fetchLocationAndControlId(action.options, extras, true) if (!theControlId) return true @@ -1033,7 +1032,7 @@ export class InternalControls implements InternalModuleFragment { } return true - } else if (action.action === 'panic_bank') { + } else if (action.definitionId === 'panic_bank') { const { theControlId } = this.#fetchLocationAndControlId(action.options, extras, true) if (!theControlId) return true @@ -1043,7 +1042,7 @@ export class InternalControls implements InternalModuleFragment { } return true - } else if (action.action === 'panic_page') { + } else if (action.definitionId === 'panic_page') { const { thePage } = this.#fetchPage(action.options, extras) if (thePage === null) return true @@ -1058,7 +1057,7 @@ export class InternalControls implements InternalModuleFragment { } return true - } else if (action.action === 'panic_trigger') { + } else if (action.definitionId === 'panic_trigger') { let controlId = action.options.trigger_id if (controlId === 'self') controlId = extras.controlId @@ -1070,10 +1069,10 @@ export class InternalControls implements InternalModuleFragment { } return true - } else if (action.action === 'panic') { + } else if (action.definitionId === 'panic') { this.#controlsController.abortAllDelayedActions() return true - } else if (action.action == 'bank_current_step') { + } else if (action.definitionId == 'bank_current_step') { const { theControlId } = this.#fetchLocationAndControlId(action.options, extras, true) if (!theControlId) return true @@ -1081,11 +1080,11 @@ export class InternalControls implements InternalModuleFragment { const control = this.#controlsController.getControl(theControlId) - if (control && control.supportsSteps) { - control.stepMakeCurrent(theStep) + if (control && control.supportsActionSets) { + control.actionSets.stepMakeCurrent(theStep) } return true - } else if (action.action == 'bank_current_step_condition') { + } else if (action.definitionId == 'bank_current_step_condition') { const { theControlId } = this.#fetchLocationAndControlId(action.options, extras, true) if (!theControlId) return true @@ -1104,12 +1103,12 @@ export class InternalControls implements InternalModuleFragment { let pressIt = checkCondition(action.options.op, condition, variable_value) if (pressIt) { - if (control && control.supportsSteps) { - control.stepMakeCurrent(theStep) + if (control && control.supportsActionSets) { + control.actionSets.stepMakeCurrent(theStep) } } return true - } else if (action.action == 'bank_current_step_if_expression') { + } else if (action.definitionId == 'bank_current_step_if_expression') { const { theControlId } = this.#fetchLocationAndControlId(action.options, extras, true) if (!theControlId) return true @@ -1124,19 +1123,19 @@ export class InternalControls implements InternalModuleFragment { ).value if (pressIt) { - if (control && control.supportsSteps) { - control.stepMakeCurrent(theStep) + if (control && control.supportsActionSets) { + control.actionSets.stepMakeCurrent(theStep) } } return true - } else if (action.action == 'bank_current_step_delta') { + } else if (action.definitionId == 'bank_current_step_delta') { const { theControlId } = this.#fetchLocationAndControlId(action.options, extras, true) if (!theControlId) return true const control = this.#controlsController.getControl(theControlId) - if (control && control.supportsSteps) { - control.stepAdvanceDelta(action.options.amount) + if (control && control.supportsActionSets) { + control.actionSets.stepAdvanceDelta(action.options.amount) } return true } else { diff --git a/companion/lib/Internal/CustomVariables.ts b/companion/lib/Internal/CustomVariables.ts index eeeafa0fc5..a6fb32067e 100644 --- a/companion/lib/Internal/CustomVariables.ts +++ b/companion/lib/Internal/CustomVariables.ts @@ -26,8 +26,8 @@ import type { } from './Types.js' import type { InternalController } from './Controller.js' import type { VariablesController } from '../Variables/Controller.js' -import type { ActionInstance } from '@companion-app/shared/Model/ActionModel.js' import type { RunActionExtras } from '../Instance/Wrapper.js' +import type { ActionEntityModel } from '@companion-app/shared/Model/EntityModel.js' export class InternalCustomVariables implements InternalModuleFragment { readonly #logger = LogController.createLogger('Internal/CustomVariables') @@ -140,7 +140,7 @@ export class InternalCustomVariables implements InternalModuleFragment { } } - actionUpgrade(action: ActionInstance, _controlId: string): ActionInstance | void { + actionUpgrade(action: ActionEntityModel, _controlId: string): ActionEntityModel | void { const variableRegex = /^\$\(([^:$)]+):([^)$]+)\)$/ const wrapValue = (val: string | number) => { if (!isNaN(Number(val))) { @@ -152,7 +152,7 @@ export class InternalCustomVariables implements InternalModuleFragment { } } - if (action.action === 'custom_variable_math_operation') { + if (action.definitionId === 'custom_variable_math_operation') { let op = '???' let reverse = false switch (action.options.operation) { @@ -178,7 +178,7 @@ export class InternalCustomVariables implements InternalModuleFragment { break } - action.action = 'custom_variable_set_expression' + action.definitionId = 'custom_variable_set_expression' const parts = [`$(${action.options.variable})`, op, wrapValue(action.options.value)] if (reverse) parts.reverse() @@ -191,8 +191,8 @@ export class InternalCustomVariables implements InternalModuleFragment { delete action.options.result return action - } else if (action.action === 'custom_variable_math_int_operation') { - action.action = 'custom_variable_set_expression' + } else if (action.definitionId === 'custom_variable_math_int_operation') { + action.definitionId = 'custom_variable_set_expression' action.options.expression = `fromRadix($(${action.options.variable}), ${action.options.radix || 2})` action.options.name = action.options.result delete action.options.variable @@ -200,16 +200,16 @@ export class InternalCustomVariables implements InternalModuleFragment { delete action.options.result return action - } else if (action.action === 'custom_variable_string_trim_operation') { - action.action = 'custom_variable_set_expression' + } else if (action.definitionId === 'custom_variable_string_trim_operation') { + action.definitionId = 'custom_variable_set_expression' action.options.expression = `trim($(${action.options.variable}))` action.options.name = action.options.result delete action.options.variable delete action.options.result return action - } else if (action.action === 'custom_variable_string_concat_operation') { - action.action = 'custom_variable_set_expression' + } else if (action.definitionId === 'custom_variable_string_concat_operation') { + action.definitionId = 'custom_variable_set_expression' const wrappedValue = action.options.value.indexOf('$(') !== -1 ? `\${${wrapValue(action.options.value)}}` : action.options.value @@ -227,8 +227,8 @@ export class InternalCustomVariables implements InternalModuleFragment { delete action.options.result return action - } else if (action.action === 'custom_variable_string_substring_operation') { - action.action = 'custom_variable_set_expression' + } else if (action.definitionId === 'custom_variable_string_substring_operation') { + action.definitionId = 'custom_variable_set_expression' action.options.expression = `substr($(${action.options.variable}), ${wrapValue( action.options.start @@ -241,8 +241,8 @@ export class InternalCustomVariables implements InternalModuleFragment { delete action.options.result return action - } else if (action.action === 'custom_variable_set_via_jsonpath') { - action.action = 'custom_variable_set_expression' + } else if (action.definitionId === 'custom_variable_set_via_jsonpath') { + action.definitionId = 'custom_variable_set_expression' action.options.expression = `jsonpath($(custom:${action.options.jsonResultDataVariable}), "${action.options.jsonPath?.replaceAll('"', '\\"')}")` action.options.name = action.options.targetVariable @@ -255,18 +255,18 @@ export class InternalCustomVariables implements InternalModuleFragment { } } - executeAction(action: ActionInstance, extras: RunActionExtras): boolean { - if (action.action === 'custom_variable_set_value') { + executeAction(action: ActionEntityModel, extras: RunActionExtras): boolean { + if (action.definitionId === 'custom_variable_set_value') { this.#variableController.custom.setValue(action.options.name, action.options.value) return true - } else if (action.action === 'custom_variable_create_value') { + } else if (action.definitionId === 'custom_variable_create_value') { if (this.#variableController.custom.hasCustomVariable(action.options.name)) { this.#variableController.custom.setValue(action.options.name, action.options.value) } else { this.#variableController.custom.createVariable(action.options.name, action.options.value) } return true - } else if (action.action === 'custom_variable_set_expression') { + } else if (action.definitionId === 'custom_variable_set_expression') { try { const result = this.#internalModule.executeExpressionForInternalActionOrFeedback( action.options.expression, @@ -278,15 +278,15 @@ export class InternalCustomVariables implements InternalModuleFragment { } return true - } else if (action.action === 'custom_variable_store_variable') { + } else if (action.definitionId === 'custom_variable_store_variable') { const [connectionLabel, variableName] = SplitVariableId(action.options.variable) const value = this.#variableController.values.getVariableValue(connectionLabel, variableName) this.#variableController.custom.setValue(action.options.name, value) return true - } else if (action.action === 'custom_variable_reset_to_default') { + } else if (action.definitionId === 'custom_variable_reset_to_default') { this.#variableController.custom.resetValueToDefault(action.options.name) return true - } else if (action.action === 'custom_variable_sync_to_default') { + } else if (action.definitionId === 'custom_variable_sync_to_default') { this.#variableController.custom.syncValueToDefault(action.options.name) return true } else { diff --git a/companion/lib/Internal/Instance.ts b/companion/lib/Internal/Instance.ts index 9591f74484..f84dd80147 100644 --- a/companion/lib/Internal/Instance.ts +++ b/companion/lib/Internal/Instance.ts @@ -23,14 +23,14 @@ import type { RunActionExtras, VariableDefinitionTmp } from '../Instance/Wrapper import type { ActionForVisitor, FeedbackForVisitor, - FeedbackInstanceExt, + FeedbackEntityModelExt, InternalModuleFragment, InternalVisitor, InternalActionDefinition, InternalFeedbackDefinition, } from './Types.js' -import type { ActionInstance } from '@companion-app/shared/Model/ActionModel.js' import type { CompanionFeedbackButtonStyleResult, CompanionVariableValues } from '@companion-module/base' +import type { ActionEntityModel } from '@companion-app/shared/Model/EntityModel.js' export class InternalInstance implements InternalModuleFragment { readonly #internalModule: InternalController @@ -145,11 +145,11 @@ export class InternalInstance implements InternalModuleFragment { getFeedbackDefinitions(): Record { return { instance_status: { - type: 'advanced', + feedbackType: 'advanced', label: 'Connection: Check Status', description: 'Change button color on Connection Status\nDisabled color is not used when "All" connections is selected', - style: undefined, + feedbackStyle: undefined, showInvert: false, options: [ { @@ -210,10 +210,10 @@ export class InternalInstance implements InternalModuleFragment { ], }, instance_custom_state: { - type: 'boolean', + feedbackType: 'boolean', label: 'Connection: When matches specified status', description: 'Change style when a connection matches the specified status', - style: { + feedbackStyle: { color: 0xffffff, bgcolor: 0x00ff00, }, @@ -243,8 +243,8 @@ export class InternalInstance implements InternalModuleFragment { } } - executeAction(action: ActionInstance, _extras: RunActionExtras): boolean { - if (action.action === 'instance_control') { + executeAction(action: ActionEntityModel, _extras: RunActionExtras): boolean { + if (action.definitionId === 'instance_control') { let newState = action.options.enable == 'true' if (action.options.enable == 'toggle') { const curState = this.#instanceController.getConnectionStatus(action.options.instance_id) @@ -259,8 +259,8 @@ export class InternalInstance implements InternalModuleFragment { } } - executeFeedback(feedback: FeedbackInstanceExt): CompanionFeedbackButtonStyleResult | boolean | void { - if (feedback.type === 'instance_status') { + executeFeedback(feedback: FeedbackEntityModelExt): CompanionFeedbackButtonStyleResult | boolean | void { + if (feedback.definitionId === 'instance_status') { if (feedback.options.instance_id == 'all') { if (this.#instancesError > 0) { return { @@ -312,7 +312,7 @@ export class InternalInstance implements InternalModuleFragment { color: feedback.options.disabled_fg, bgcolor: feedback.options.disabled_bg, } - } else if (feedback.type === 'instance_custom_state') { + } else if (feedback.definitionId === 'instance_custom_state') { const selected_status = this.#instanceStatuses[String(feedback.options.instance_id)]?.category ?? null return selected_status == feedback.options.state diff --git a/companion/lib/Internal/Surface.ts b/companion/lib/Internal/Surface.ts index 2710ba3284..caa8d1571e 100644 --- a/companion/lib/Internal/Surface.ts +++ b/companion/lib/Internal/Surface.ts @@ -22,7 +22,7 @@ import debounceFn from 'debounce-fn' import type { ActionForVisitor, FeedbackForVisitor, - FeedbackInstanceExt, + FeedbackEntityModelExt, InternalModuleFragment, InternalVisitor, InternalActionDefinition, @@ -33,8 +33,8 @@ import type { ControlsController } from '../Controls/Controller.js' import type { PageController } from '../Page/Controller.js' import type { SurfaceController } from '../Surface/Controller.js' import type { RunActionExtras, VariableDefinitionTmp } from '../Instance/Wrapper.js' -import type { ActionInstance } from '@companion-app/shared/Model/ActionModel.js' import type { InternalActionInputField } from '@companion-app/shared/Model/Options.js' +import type { ActionEntityModel } from '@companion-app/shared/Model/EntityModel.js' const CHOICES_SURFACE_GROUP_WITH_VARIABLES: InternalActionInputField[] = [ { @@ -192,7 +192,7 @@ export class InternalSurface implements InternalModuleFragment { #fetchSurfaceId( options: Record, - info: RunActionExtras | FeedbackInstanceExt, + info: RunActionExtras | FeedbackEntityModelExt, useVariableFields: boolean ): string | undefined { let surfaceId: string | undefined = options.controller + '' @@ -210,7 +210,7 @@ export class InternalSurface implements InternalModuleFragment { #fetchPage( options: Record, - extras: RunActionExtras | FeedbackInstanceExt, + extras: RunActionExtras | FeedbackEntityModelExt, useVariableFields: boolean, surfaceId: string | undefined ): string | 'back' | 'forward' | '+1' | '-1' | undefined { @@ -329,7 +329,7 @@ export class InternalSurface implements InternalModuleFragment { this.#internalModule.setVariables(values) } - actionUpgrade(action: ActionInstance, _controlId: string): void | ActionInstance { + actionUpgrade(action: ActionEntityModel, _controlId: string): void | ActionEntityModel { // Upgrade an action. This check is not the safest, but it should be ok if (action.options.controller === 'emulator') { // Hope that the default emulator still exists @@ -443,14 +443,14 @@ export class InternalSurface implements InternalModuleFragment { } } - executeAction(action: ActionInstance, extras: RunActionExtras): boolean { - if (action.action === 'set_brightness') { + executeAction(action: ActionEntityModel, extras: RunActionExtras): boolean { + if (action.definitionId === 'set_brightness') { const surfaceId = this.#fetchSurfaceId(action.options, extras, true) if (!surfaceId) return true this.#surfaceController.setDeviceBrightness(surfaceId, action.options.brightness, true) return true - } else if (action.action === 'set_page') { + } else if (action.definitionId === 'set_page') { const surfaceId = this.#fetchSurfaceId(action.options, extras, true) if (!surfaceId) return true @@ -459,7 +459,7 @@ export class InternalSurface implements InternalModuleFragment { this.#changeSurfacePage(surfaceId, thePage) return true - } else if (action.action === 'set_page_byindex') { + } else if (action.definitionId === 'set_page_byindex') { let surfaceIndex = action.options.controller if (action.options.controller_from_variable) { surfaceIndex = this.#internalModule.parseVariablesForInternalActionOrFeedback( @@ -485,19 +485,19 @@ export class InternalSurface implements InternalModuleFragment { this.#changeSurfacePage(surfaceId, thePage) return true - } else if (action.action === 'inc_page') { + } else if (action.definitionId === 'inc_page') { const surfaceId = this.#fetchSurfaceId(action.options, extras, true) if (!surfaceId) return true this.#changeSurfacePage(surfaceId, '+1') return true - } else if (action.action === 'dec_page') { + } else if (action.definitionId === 'dec_page') { const surfaceId = this.#fetchSurfaceId(action.options, extras, true) if (!surfaceId) return true this.#changeSurfacePage(surfaceId, '-1') return true - } else if (action.action === 'lockout_device') { + } else if (action.definitionId === 'lockout_device') { if (this.#surfaceController.isPinLockEnabled()) { const surfaceId = this.#fetchSurfaceId(action.options, extras, true) if (!surfaceId) return true @@ -515,7 +515,7 @@ export class InternalSurface implements InternalModuleFragment { }) } return true - } else if (action.action === 'unlockout_device') { + } else if (action.definitionId === 'unlockout_device') { const surfaceId = this.#fetchSurfaceId(action.options, extras, true) if (!surfaceId) return true @@ -524,7 +524,7 @@ export class InternalSurface implements InternalModuleFragment { }) return true - } else if (action.action === 'lockout_all') { + } else if (action.definitionId === 'lockout_all') { if (this.#surfaceController.isPinLockEnabled()) { if (extras.controlId) { const control = this.#controlsController.getControl(extras.controlId) @@ -539,12 +539,12 @@ export class InternalSurface implements InternalModuleFragment { }) } return true - } else if (action.action === 'unlockout_all') { + } else if (action.definitionId === 'unlockout_all') { setImmediate(() => { this.#surfaceController.setAllLocked(false) }) return true - } else if (action.action === 'rescan') { + } else if (action.definitionId === 'rescan') { this.#surfaceController.triggerRefreshDevices().catch(() => { // TODO }) @@ -621,10 +621,10 @@ export class InternalSurface implements InternalModuleFragment { getFeedbackDefinitions(): Record { return { surface_on_page: { - type: 'boolean', + feedbackType: 'boolean', label: 'Surface: When on the selected page', description: 'Change style when a surface is on the selected page', - style: { + feedbackStyle: { color: combineRgb(255, 255, 255), bgcolor: combineRgb(255, 0, 0), }, @@ -650,8 +650,8 @@ export class InternalSurface implements InternalModuleFragment { } } - executeFeedback(feedback: FeedbackInstanceExt): boolean | void { - if (feedback.type == 'surface_on_page') { + executeFeedback(feedback: FeedbackEntityModelExt): boolean | void { + if (feedback.definitionId == 'surface_on_page') { const surfaceId = this.#fetchSurfaceId(feedback.options, feedback, false) if (!surfaceId) return false diff --git a/companion/lib/Internal/System.ts b/companion/lib/Internal/System.ts index 1256497730..38e95359f8 100644 --- a/companion/lib/Internal/System.ts +++ b/companion/lib/Internal/System.ts @@ -32,7 +32,7 @@ import type { import type { Registry } from '../Registry.js' import type { InternalController } from './Controller.js' import type { VariablesController } from '../Variables/Controller.js' -import type { ActionInstance } from '@companion-app/shared/Model/ActionModel.js' +import type { ActionEntityModel } from '@companion-app/shared/Model/EntityModel.js' async function getHostnameVariables() { const values: CompanionVariableValues = {} @@ -242,8 +242,8 @@ export class InternalSystem implements InternalModuleFragment { return actions } - async executeAction(action: ActionInstance, extras: RunActionExtras): Promise { - if (action.action === 'exec') { + async executeAction(action: ActionEntityModel, extras: RunActionExtras): Promise { + if (action.definitionId === 'exec') { if (action.options.path) { const path = this.#internalModule.parseVariablesForInternalActionOrFeedback(action.options.path, extras).text this.#logger.silly(`Running path: '${path}'`) @@ -270,10 +270,10 @@ export class InternalSystem implements InternalModuleFragment { ) } return true - } else if (action.action === 'app_restart') { + } else if (action.definitionId === 'app_restart') { this.#registry.exit(true, true) return true - } else if (action.action === 'app_exit') { + } else if (action.definitionId === 'app_exit') { this.#registry.exit(true, false) return true } else { diff --git a/companion/lib/Internal/Triggers.ts b/companion/lib/Internal/Triggers.ts index ef2fc6d77a..a99bc54372 100644 --- a/companion/lib/Internal/Triggers.ts +++ b/companion/lib/Internal/Triggers.ts @@ -20,7 +20,7 @@ import debounceFn from 'debounce-fn' import type { ActionForVisitor, FeedbackForVisitor, - FeedbackInstanceExt, + FeedbackEntityModelExt, InternalModuleFragment, InternalVisitor, InternalActionDefinition, @@ -28,8 +28,8 @@ import type { } from './Types.js' import type { ControlsController } from '../Controls/Controller.js' import type { InternalController } from './Controller.js' -import type { ActionInstance } from '@companion-app/shared/Model/ActionModel.js' import type { RunActionExtras } from '../Instance/Wrapper.js' +import type { ActionEntityModel } from '@companion-app/shared/Model/EntityModel.js' export class InternalTriggers implements InternalModuleFragment { readonly #controlsController: ControlsController @@ -80,16 +80,16 @@ export class InternalTriggers implements InternalModuleFragment { } } - actionUpgrade(action: ActionInstance, _controlId: string): ActionInstance | void { - if (action.action === 'trigger_enabled' && !isNaN(Number(action.options.trigger_id))) { + actionUpgrade(action: ActionEntityModel, _controlId: string): ActionEntityModel | void { + if (action.definitionId === 'trigger_enabled' && !isNaN(Number(action.options.trigger_id))) { action.options.trigger_id = CreateTriggerControlId(action.options.trigger_id) return action } } - executeAction(action: ActionInstance, _extras: RunActionExtras): boolean { - if (action.action === 'trigger_enabled') { + executeAction(action: ActionEntityModel, _extras: RunActionExtras): boolean { + if (action.definitionId === 'trigger_enabled') { const control = this.#controlsController.getControl(action.options.trigger_id) if (!control || control.type !== 'trigger' || !control.supportsOptions) return false @@ -107,10 +107,10 @@ export class InternalTriggers implements InternalModuleFragment { getFeedbackDefinitions(): Record { return { trigger_enabled: { - type: 'boolean', + feedbackType: 'boolean', label: 'Trigger: When enabled or disabled', description: undefined, - style: { + feedbackStyle: { color: 0xffffff, bgcolor: 0xff0000, }, @@ -136,8 +136,8 @@ export class InternalTriggers implements InternalModuleFragment { } } - executeFeedback(feedback: FeedbackInstanceExt): boolean | void { - if (feedback.type === 'trigger_enabled') { + executeFeedback(feedback: FeedbackEntityModelExt): boolean | void { + if (feedback.definitionId === 'trigger_enabled') { const control = this.#controlsController.getControl(feedback.options.trigger_id) if (!control || control.type !== 'trigger' || !control.supportsOptions) return false diff --git a/companion/lib/Internal/Types.ts b/companion/lib/Internal/Types.ts index 1f01286faa..bf85056ab7 100644 --- a/companion/lib/Internal/Types.ts +++ b/companion/lib/Internal/Types.ts @@ -1,15 +1,13 @@ import type { ControlLocation } from '@companion-app/shared/Model/Common.js' -import type { FeedbackInstance } from '@companion-app/shared/Model/FeedbackModel.js' import type { VisitorReferencesCollector } from '../Resources/Visitors/ReferencesCollector.js' import type { VisitorReferencesUpdater } from '../Resources/Visitors/ReferencesUpdater.js' import type { CompanionFeedbackButtonStyleResult, CompanionOptionValues } from '@companion-module/base' -import type { ActionInstance } from '@companion-app/shared/Model/ActionModel.js' import type { RunActionExtras, VariableDefinitionTmp } from '../Instance/Wrapper.js' -import type { FeedbackDefinition } from '@companion-app/shared/Model/FeedbackDefinitionModel.js' import type { SetOptional } from 'type-fest' -import type { ActionDefinition } from '@companion-app/shared/Model/ActionDefinitionModel.js' +import type { ActionEntityModel, FeedbackEntityModel } from '@companion-app/shared/Model/EntityModel.js' +import type { ClientEntityDefinition } from '@companion-app/shared/Model/EntityDefinitionModel.js' -export interface FeedbackInstanceExt extends FeedbackInstance { +export interface FeedbackEntityModelExt extends FeedbackEntityModel { controlId: string location: ControlLocation | undefined referencedVariables: string[] | null @@ -42,13 +40,13 @@ export interface InternalModuleFragment { * Run a single internal action * @returns Whether the action was handled */ - executeAction?(action: ActionInstance, extras: RunActionExtras): Promise | boolean + executeAction?(action: ActionEntityModel, extras: RunActionExtras): Promise | boolean /** * Perform an upgrade for an action * @returns Updated action if any changes were made */ - actionUpgrade?: (action: ActionInstance, _controlId: string) => ActionInstance | void + actionUpgrade?: (action: ActionEntityModel, controlId: string) => ActionEntityModel | void getFeedbackDefinitions?: () => Record @@ -56,10 +54,12 @@ export interface InternalModuleFragment { * Get an updated value for a feedback */ executeFeedback?: ( - feedback: FeedbackInstanceExt + feedback: FeedbackEntityModelExt ) => CompanionFeedbackButtonStyleResult | boolean | ExecuteFeedbackResultWithReferences | void - feedbackUpgrade?: (feedback: FeedbackInstance, _controlId: string) => FeedbackInstance | void + feedbackUpgrade?: (feedback: FeedbackEntityModel, controlId: string) => FeedbackEntityModel | void + + forgetFeedback?: (feedback: FeedbackEntityModel) => void /** * @@ -76,11 +76,11 @@ export interface ExecuteFeedbackResultWithReferences { } export type InternalActionDefinition = SetOptional< - ActionDefinition, - 'hasLearn' | 'learnTimeout' | 'showButtonPreview' | 'supportsChildActionGroups' + Omit, + 'hasLearn' | 'learnTimeout' | 'showButtonPreview' | 'supportsChildGroups' > export type InternalFeedbackDefinition = SetOptional< - FeedbackDefinition, - 'hasLearn' | 'learnTimeout' | 'showButtonPreview' | 'supportsChildFeedbacks' + Omit, + 'hasLearn' | 'learnTimeout' | 'showButtonPreview' | 'supportsChildGroups' > diff --git a/companion/lib/Internal/Variables.ts b/companion/lib/Internal/Variables.ts index f9b86553ab..352ac57e05 100644 --- a/companion/lib/Internal/Variables.ts +++ b/companion/lib/Internal/Variables.ts @@ -21,12 +21,13 @@ import type { VariablesValues } from '../Variables/Values.js' import type { ActionForVisitor, FeedbackForVisitor, - FeedbackInstanceExt, + FeedbackEntityModelExt, InternalModuleFragment, InternalVisitor, InternalFeedbackDefinition, } from './Types.js' import type { CompanionInputFieldDropdown } from '@companion-module/base' +import type { FeedbackEntityModel } from '@companion-app/shared/Model/EntityModel.js' const COMPARISON_OPERATION: CompanionInputFieldDropdown = { type: 'dropdown', @@ -71,10 +72,10 @@ export class InternalVariables implements InternalModuleFragment { getFeedbackDefinitions(): Record { return { variable_value: { - type: 'boolean', + feedbackType: 'boolean', label: 'Variable: Check value', description: 'Change style based on the value of a variable', - style: { + feedbackStyle: { color: 0xffffff, bgcolor: 0xff0000, }, @@ -109,10 +110,10 @@ export class InternalVariables implements InternalModuleFragment { }, variable_variable: { - type: 'boolean', + feedbackType: 'boolean', label: 'Variable: Compare two variables', description: 'Change style based on a variable compared to another variable', - style: { + feedbackStyle: { color: 0xffffff, bgcolor: 0xff0000, }, @@ -135,10 +136,10 @@ export class InternalVariables implements InternalModuleFragment { }, check_expression: { - type: 'boolean', + feedbackType: 'boolean', label: 'Variable: Check boolean expression', description: 'Change style based on a boolean expression', - style: { + feedbackStyle: { color: 0xffffff, bgcolor: 0xff0000, }, @@ -162,8 +163,8 @@ export class InternalVariables implements InternalModuleFragment { /** * Get an updated value for a feedback */ - executeFeedback(feedback: FeedbackInstanceExt): boolean | void { - if (feedback.type == 'variable_value') { + executeFeedback(feedback: FeedbackEntityModelExt): boolean | void { + if (feedback.definitionId == 'variable_value') { const result = this.#internalModule.parseVariablesForInternalActionOrFeedback( `$(${feedback.options.variable})`, feedback @@ -172,7 +173,7 @@ export class InternalVariables implements InternalModuleFragment { this.#variableSubscriptions.set(feedback.id, result.variableIds) return compareValues(feedback.options.op, result.text, feedback.options.value) - } else if (feedback.type == 'variable_variable') { + } else if (feedback.definitionId == 'variable_variable') { const result1 = this.#internalModule.parseVariablesForInternalActionOrFeedback( `$(${feedback.options.variable})`, feedback @@ -185,7 +186,7 @@ export class InternalVariables implements InternalModuleFragment { this.#variableSubscriptions.set(feedback.id, [...result1.variableIds, ...result2.variableIds]) return compareValues(feedback.options.op, result1.text, result2.text) - } else if (feedback.type == 'check_expression') { + } else if (feedback.definitionId == 'check_expression') { try { const res = this.#variableController.executeExpression( feedback.options.expression, @@ -205,7 +206,7 @@ export class InternalVariables implements InternalModuleFragment { } } - forgetFeedback(feedback: FeedbackInstanceExt): void { + forgetFeedback(feedback: FeedbackEntityModel): void { this.#variableSubscriptions.delete(feedback.id) } diff --git a/companion/lib/Log/Controller.ts b/companion/lib/Log/Controller.ts index 7a54796a70..14600ec7c3 100644 --- a/companion/lib/Log/Controller.ts +++ b/companion/lib/Log/Controller.ts @@ -138,6 +138,13 @@ class LogController { this.#logger = this.createLogger('Log/Controller') } + /** + * Get the log level + */ + getLogLevel(): string { + return this.#winston.level + } + /** * Set the log level to output */ diff --git a/companion/lib/Resources/Visitors/ActionInstanceVisitor.ts b/companion/lib/Resources/Visitors/ActionInstanceVisitor.ts deleted file mode 100644 index ffc9b8b119..0000000000 --- a/companion/lib/Resources/Visitors/ActionInstanceVisitor.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { ActionInstance } from '@companion-app/shared/Model/ActionModel.js' -import type { InternalVisitor } from '../../Internal/Types.js' - -/** - * Visits a action instance. - */ -export function visitActionInstance(visitor: InternalVisitor, action: ActionInstance) { - // Fixup any references in action options - for (const key of Object.keys(action.options || {})) { - visitor.visitString(action.options, key, action.id) - } -} diff --git a/companion/lib/Resources/Visitors/EntityInstanceVisitor.ts b/companion/lib/Resources/Visitors/EntityInstanceVisitor.ts new file mode 100644 index 0000000000..af747004fd --- /dev/null +++ b/companion/lib/Resources/Visitors/EntityInstanceVisitor.ts @@ -0,0 +1,19 @@ +import type { InternalVisitor } from '../../Internal/Types.js' +import { EntityModelType, SomeEntityModel } from '@companion-app/shared/Model/EntityModel.js' + +/** + * Visits an entity instance. + */ +export function visitEntityModel(visitor: InternalVisitor, entity: SomeEntityModel) { + if (entity.type === EntityModelType.Feedback) { + // Fixup any boolean feedbacks + if (entity.style?.text) { + visitor.visitString(entity.style, 'text') + } + } + + // Fixup any references in entity options + for (const key of Object.keys(entity.options || {})) { + visitor.visitString(entity.options, key, entity.id) + } +} diff --git a/companion/lib/Resources/Visitors/FeedbackInstanceVisitor.ts b/companion/lib/Resources/Visitors/FeedbackInstanceVisitor.ts deleted file mode 100644 index 59ac9556ef..0000000000 --- a/companion/lib/Resources/Visitors/FeedbackInstanceVisitor.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { FeedbackInstance } from '@companion-app/shared/Model/FeedbackModel.js' -import type { InternalVisitor } from '../../Internal/Types.js' - -/** - * Visits a feedback instance. - */ -export function visitFeedbackInstance(visitor: InternalVisitor, feedback: FeedbackInstance) { - // Fixup any boolean feedbacks - if (feedback.style?.text) { - visitor.visitString(feedback.style, 'text') - } - - // Fixup any references in feedback options - for (const key of Object.keys(feedback.options || {})) { - visitor.visitString(feedback.options, key, feedback.id) - } -} diff --git a/companion/lib/Resources/Visitors/ReferencesVisitors.ts b/companion/lib/Resources/Visitors/ReferencesVisitors.ts index 542030e61c..9d3213cb3b 100644 --- a/companion/lib/Resources/Visitors/ReferencesVisitors.ts +++ b/companion/lib/Resources/Visitors/ReferencesVisitors.ts @@ -1,15 +1,12 @@ import { VisitorReferencesUpdater } from './ReferencesUpdater.js' import { visitEventOptions } from '../EventDefinitions.js' -import { visitFeedbackInstance } from './FeedbackInstanceVisitor.js' +import { visitEntityModel } from './EntityInstanceVisitor.js' import type { InternalController } from '../../Internal/Controller.js' import type { InternalVisitor } from '../../Internal/Types.js' import type { ButtonStyleProperties } from '@companion-app/shared/Model/StyleModel.js' -import type { FragmentFeedbackInstance } from '../../Controls/Fragments/FragmentFeedbackInstance.js' -import type { ActionInstance } from '@companion-app/shared/Model/ActionModel.js' -import type { FeedbackInstance } from '@companion-app/shared/Model/FeedbackModel.js' import type { EventInstance } from '@companion-app/shared/Model/EventModel.js' -import type { FragmentActionInstance } from '../../Controls/Fragments/FragmentActionInstance.js' -import { visitActionInstance } from './ActionInstanceVisitor.js' +import type { SomeEntityModel } from '@companion-app/shared/Model/EntityModel.js' +import type { ControlEntityInstance } from '../../Controls/Entities/EntityInstance.js' export class ReferencesVisitors { /** @@ -17,43 +14,30 @@ export class ReferencesVisitors { * @param internalModule * @param visitor Visitor to be used * @param style Style object of the control (if any) - * @param rawActions Array of unprocessed actions belonging to the control - * @param rawFeedbacks Array of unprocessed feedbacks belonging to the control - * @param actions Array of actions belonging to the control - * @param feedbacks Array of loaded feedbacks belonging to the control + * @param rawEntities Array of unprocessed entities belonging to the control + * @param entities Array of loaded entities belonging to the control * @param events Array of events belonging to the control */ static visitControlReferences( internalModule: InternalController, visitor: InternalVisitor, style: ButtonStyleProperties | undefined, - rawActions: ActionInstance[], - rawFeedbacks: FeedbackInstance[], - actions: FragmentActionInstance[], - feedbacks: FragmentFeedbackInstance[], + rawEntities: SomeEntityModel[], + entities: ControlEntityInstance[], events: EventInstance[] ): void { // Update the base style if (style) visitor.visitString(style, 'text') // Apply any updates to the internal actions/feedbacks - internalModule.visitReferences(visitor, rawActions, actions, rawFeedbacks, feedbacks) + internalModule.visitReferences(visitor, rawEntities, entities) - for (const feedback of rawFeedbacks) { - visitFeedbackInstance(visitor, feedback) + for (const entity of rawEntities) { + visitEntityModel(visitor, entity) } - for (const feedback of feedbacks) { - feedback.visitReferences(visitor) - } - - // Fixup any references in action options - for (const action of rawActions) { - visitActionInstance(visitor, action) - } - - for (const action of actions) { - action.visitReferences(visitor) + for (const entity of entities) { + entity.visitReferences(visitor) } // Fixup any references in event options @@ -67,10 +51,8 @@ export class ReferencesVisitors { * @param internalModule * @param updateMaps Description of instance ids and labels to remap * @param style Style object of the control (if any) - * @param rawActions Array of unprocessed actions belonging to the control - * @param rawFeedbacks Array of unprocessed feedbacks belonging to the control - * @param actions Array of actions belonging to the control - * @param feedbacks Array of loaded feedbacks belonging to the control + * @param rawEntities Array of unprocessed entities belonging to the control + * @param entities Array of loaded entities belonging to the control * @param events Array of events belonging to the control * @param recheckChangedFeedbacks Whether to recheck the feedbacks that were modified * @returns Whether any changes were made @@ -79,16 +61,14 @@ export class ReferencesVisitors { internalModule: InternalController, updateMaps: FixupReferencesUpdateMaps, style: ButtonStyleProperties | undefined, - rawActions: ActionInstance[], - rawFeedbacks: FeedbackInstance[], - actions: FragmentActionInstance[], - feedbacks: FragmentFeedbackInstance[], + rawEntities: SomeEntityModel[], + entities: ControlEntityInstance[], events: EventInstance[], recheckChangedFeedbacks: boolean ): boolean { const visitor = new VisitorReferencesUpdater(updateMaps.connectionLabels, updateMaps.connectionIds) - this.visitControlReferences(internalModule, visitor, style, rawActions, rawFeedbacks, actions, feedbacks, events) + this.visitControlReferences(internalModule, visitor, style, rawEntities, entities, events) // Trigger the feedbacks to be rechecked, this will cause a redraw if needed if (recheckChangedFeedbacks && visitor.changedFeedbackIds.size > 0) { diff --git a/companion/lib/Service/HttpApi.ts b/companion/lib/Service/HttpApi.ts index 2d7378da01..6ea1bc5c7c 100644 --- a/companion/lib/Service/HttpApi.ts +++ b/companion/lib/Service/HttpApi.ts @@ -456,12 +456,12 @@ export class ServiceHttpApi extends CoreBase { } const control = this.controls.getControl(controlId) - if (!control || !control.supportsSteps) { + if (!control || !control.supportsActionSets) { res.status(204).send('No control') return } - if (!control.stepMakeCurrent(step)) { + if (!control.actionSets.stepMakeCurrent(step)) { res.status(400).send('Bad step') return } diff --git a/companion/lib/Service/OscApi.ts b/companion/lib/Service/OscApi.ts index b25a16f0dc..fc55408de8 100644 --- a/companion/lib/Service/OscApi.ts +++ b/companion/lib/Service/OscApi.ts @@ -269,11 +269,11 @@ export class ServiceOscApi extends CoreBase { if (!controlId) return const control = this.controls.getControl(controlId) - if (!control || !control.supportsSteps) { + if (!control || !control.supportsActionSets) { return } - control.stepMakeCurrent(step) + control.actionSets.stepMakeCurrent(step) } /** diff --git a/companion/lib/Service/TcpUdpApi.ts b/companion/lib/Service/TcpUdpApi.ts index 1efabcf8d1..5b1411679b 100644 --- a/companion/lib/Service/TcpUdpApi.ts +++ b/companion/lib/Service/TcpUdpApi.ts @@ -162,9 +162,9 @@ export class ServiceTcpUdpApi extends CoreBase { if (isNaN(step) || step <= 0) throw new ApiMessageError('Step out of range') const control = this.controls.getControl(controlId) - if (!control || !control.supportsSteps) throw new ApiMessageError('Invalid control') + if (!control || !control.supportsActionSets) throw new ApiMessageError('Invalid control') - if (!control.stepMakeCurrent(step)) throw new ApiMessageError('Step out of range') + if (!control.actionSets.stepMakeCurrent(step)) throw new ApiMessageError('Step out of range') }) this.#router.addPath('style bank :page :bank text{ *text}', (match) => { @@ -378,11 +378,11 @@ export class ServiceTcpUdpApi extends CoreBase { } const control = this.controls.getControl(controlId) - if (!control || !control.supportsSteps) { + if (!control || !control.supportsActionSets) { throw new ApiMessageError('No control at location') } - if (!control.stepMakeCurrent(step)) throw new ApiMessageError('Step out of range') + if (!control.actionSets.stepMakeCurrent(step)) throw new ApiMessageError('Step out of range') } /** diff --git a/companion/test/Controls/Entities/EntityList.test.ts b/companion/test/Controls/Entities/EntityList.test.ts new file mode 100644 index 0000000000..e1182979d1 --- /dev/null +++ b/companion/test/Controls/Entities/EntityList.test.ts @@ -0,0 +1,1554 @@ +import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest' +import { ControlEntityList, ControlEntityListDefinition } from '../../../lib/Controls/Entities/EntityList.js' +import { + ActionEntityModel, + EntityModelType, + EntityOwner, + FeedbackEntityModel, + SomeEntityModel, +} from '@companion-app/shared/Model/EntityModel.js' +import { cloneDeep } from 'lodash-es' +import { + ActionTree, + ActionTreeEntityDefinitions, + FeedbackTree, + FeedbackTreeEntityDefinitions, + getAllModelsInTree, +} from './EntityListModels.js' +import { + InstanceDefinitionsForEntity, + InternalControllerForEntity, + ModuleHostForEntity, +} from '../../../lib/Controls/Entities/Types.js' +import { ClientEntityDefinition } from '@companion-app/shared/Model/EntityDefinitionModel.js' +import { ControlEntityInstance } from '../../../lib/Controls/Entities/EntityInstance.js' +import { FeedbackStyleBuilder } from '../../../lib/Controls/Entities/FeedbackStyleBuilder.js' +import { mock } from 'vitest-mock-extended' + +function createList(controlId: string, ownerId?: EntityOwner | null, listId?: ControlEntityListDefinition | null) { + const getEntityDefinition = vi.fn() + const connectionEntityUpdate = vi.fn(async () => false) + const connectionEntityDelete = vi.fn(async () => false) + const internalEntityUpdate = vi.fn() + const internalEntityDelete = vi.fn() + + const instanceDefinitions: InstanceDefinitionsForEntity = { + getEntityDefinition, + } + const moduleHost: ModuleHostForEntity = { + connectionEntityUpdate, + connectionEntityDelete, + connectionEntityLearnOptions: null as any, + } + const internalController: InternalControllerForEntity = { + entityUpdate: internalEntityUpdate, + entityDelete: internalEntityDelete, + entityUpgrade: null as any, + executeLogicFeedback: null as any, + } + + const list = new ControlEntityList( + instanceDefinitions, + internalController, + moduleHost, + controlId, + ownerId ?? null, + listId ?? { + type: EntityModelType.Action, + } + ) + + const newActionModel: ActionEntityModel = { + type: EntityModelType.Action, + id: 'my-new-action', + connectionId: 'internal', + definitionId: 'def01', + options: {}, + } + const newAction = new ControlEntityInstance( + instanceDefinitions, + internalController, + moduleHost, + controlId, + newActionModel, + false + ) + + // Clear any calls made by the above + getEntityDefinition.mockClear() + + return { + list, + getEntityDefinition, + connectionEntityUpdate, + connectionEntityDelete, + internalEntityUpdate, + internalEntityDelete, + instanceDefinitions, + internalController, + moduleHost, + newActionModel, + newAction, + } +} + +test('construction', () => { + const { list } = createList('test01') + + expect(list.getAllEntities()).toHaveLength(0) + expect(list.getDirectEntities()).toHaveLength(0) +}) + +describe('loadStorage', () => { + test('actions tree missing definition', () => { + const { list } = createList('test01') + + const inputModels = ActionTree + + list.loadStorage(cloneDeep(inputModels), true, false) + + const compiled = list.getDirectEntities().map((e) => e.asEntityModel()) + + // Prune out the children which will have been discarded + const expected = cloneDeep(inputModels) + for (const entity of expected) { + if (entity.connectionId === 'internal') { + entity.children = {} + } + } + expect(compiled).toEqual(expected) + + expect(list.getAllEntities()).toHaveLength(3) + expect(list.getDirectEntities()).toHaveLength(3) + }) + + test('actions tree with definition', () => { + const { list, getEntityDefinition } = createList('test01') + + getEntityDefinition.mockImplementation(ActionTreeEntityDefinitions) + + const inputModels = ActionTree + + list.loadStorage(cloneDeep(inputModels), true, false) + + const compiled = list.getDirectEntities().map((e) => e.asEntityModel()) + delete inputModels[2].children?.group2?.[0]?.children + expect(compiled).toEqual(inputModels) + + expect(getEntityDefinition).toHaveBeenCalledTimes(2) + expect(getEntityDefinition).toHaveBeenNthCalledWith(1, EntityModelType.Action, 'internal', 'action-with-children') + expect(getEntityDefinition).toHaveBeenNthCalledWith(2, EntityModelType.Action, 'internal', 'def01') + + expect(list.getAllEntities()).toHaveLength(6) + expect(list.getDirectEntities()).toHaveLength(3) + }) + + test('actions tree with definition and unknown child group', () => { + const { list, getEntityDefinition } = createList('test01') + + getEntityDefinition.mockReturnValueOnce({ + entityType: EntityModelType.Action, + supportsChildGroups: [ + { + type: EntityModelType.Action, + groupId: 'group1', + entityTypeLabel: 'Action', + label: 'Action', + }, + ], + } as Partial as any) + getEntityDefinition.mockReturnValueOnce({ + entityType: EntityModelType.Action, + supportsChildGroups: [ + { + type: EntityModelType.Action, + groupId: 'default', + entityTypeLabel: 'Action', + label: 'Action', + }, + ], + } as Partial as any) + + const inputModels = ActionTree + + list.loadStorage(cloneDeep(inputModels), true, false) + + const compiled = list.getDirectEntities().map((e) => e.asEntityModel()) + + // Prune out the children which will have been discarded + const expected = cloneDeep(inputModels) + for (const entity of expected) { + if (entity.connectionId === 'internal') { + delete entity.children?.group2 + } + } + expect(compiled).toEqual(expected) + + expect(getEntityDefinition).toHaveBeenCalledTimes(2) + expect(getEntityDefinition).toHaveBeenNthCalledWith(1, EntityModelType.Action, 'internal', 'action-with-children') + expect(getEntityDefinition).toHaveBeenNthCalledWith(2, EntityModelType.Action, 'internal', 'def01') + + expect(list.getAllEntities()).toHaveLength(5) + expect(list.getDirectEntities()).toHaveLength(3) + }) + + test('actions tree with children on non-internal', () => { + const { list, getEntityDefinition } = createList('test01') + + const inputModels = cloneDeep(ActionTree) + for (const entity of inputModels) { + if (entity.connectionId === 'internal') { + entity.connectionId = 'fake' + } + } + + list.loadStorage(cloneDeep(inputModels), true, false) + + const compiled = list.getDirectEntities().map((e) => e.asEntityModel()) + + // Prune out the children which will have been discarded + const expected = cloneDeep(inputModels) + for (const entity of expected) { + if (entity.connectionId === 'fake') { + delete entity.children + } + } + expect(compiled).toEqual(expected) + + expect(getEntityDefinition).toHaveBeenCalledTimes(0) + + expect(list.getAllEntities()).toHaveLength(3) + expect(list.getDirectEntities()).toHaveLength(3) + }) + + test('ids when not cloned', () => { + const { list, getEntityDefinition } = createList('test01') + + getEntityDefinition.mockImplementation(ActionTreeEntityDefinitions) + + const inputModels = ActionTree + list.loadStorage(cloneDeep(inputModels), true, false) + + const inputIds = getAllModelsInTree(inputModels).map((e) => e.id) + const compiledIds = list.getAllEntities().map((e) => e.id) + expect(compiledIds).toEqual(inputIds) + + expect(getEntityDefinition).toHaveBeenCalledTimes(2) + }) + + test('ids when cloned', () => { + const { list, getEntityDefinition } = createList('test01') + + getEntityDefinition.mockImplementation(ActionTreeEntityDefinitions) + + const inputModels = ActionTree + list.loadStorage(cloneDeep(inputModels), true, true) + + const inputIds = getAllModelsInTree(inputModels).map((e) => e.id) + const inputIdsSet = new Set(inputIds) + expect(inputIdsSet.size).toBe(inputIds.length) + + const compiledIds = list.getAllEntities().map((e) => e.id) + for (const id of compiledIds) { + expect(inputIdsSet.has(id)).toBe(false) + } + + expect(getEntityDefinition).toHaveBeenCalledTimes(2) + }) + + test('subscribe when enabled', () => { + const { list, getEntityDefinition, connectionEntityUpdate, internalEntityUpdate } = createList('test01') + + getEntityDefinition.mockImplementation(ActionTreeEntityDefinitions) + + const inputModels = ActionTree + list.loadStorage(cloneDeep(inputModels), false, false) + + expect(connectionEntityUpdate).toHaveBeenCalledTimes(4) + expect(internalEntityUpdate).toHaveBeenCalledTimes(2) + }) +}) + +test('cleanup entities', () => { + const { list, getEntityDefinition, connectionEntityDelete, internalEntityDelete } = createList('test01') + + getEntityDefinition.mockImplementation(ActionTreeEntityDefinitions) + + const inputModels = ActionTree + + list.loadStorage(cloneDeep(inputModels), true, false) + + list.cleanup() + + expect(connectionEntityDelete).toHaveBeenCalledTimes(4) + expect(internalEntityDelete).toHaveBeenCalledTimes(2) + + // Entities should remain + expect(list.getAllEntities()).toHaveLength(6) + expect(list.getDirectEntities()).toHaveLength(3) +}) + +describe('subscribe entities', () => { + const { list, getEntityDefinition, connectionEntityUpdate, internalEntityUpdate } = createList('test01') + + getEntityDefinition.mockImplementation(ActionTreeEntityDefinitions) + + list.loadStorage(cloneDeep(ActionTree), true, false) + + beforeEach(() => { + connectionEntityUpdate.mockClear() + internalEntityUpdate.mockClear() + }) + + afterEach(() => { + expect(list.getAllEntities()).toHaveLength(6) + expect(list.getDirectEntities()).toHaveLength(3) + }) + + test('non recursive', () => { + list.subscribe(false) + + expect(connectionEntityUpdate).toHaveBeenCalledTimes(2) + expect(internalEntityUpdate).toHaveBeenCalledTimes(1) + }) + + test('recursive', () => { + list.subscribe(true) + + expect(connectionEntityUpdate).toHaveBeenCalledTimes(4) + expect(internalEntityUpdate).toHaveBeenCalledTimes(2) + }) + + test('only actions', () => { + list.subscribe(true, EntityModelType.Action) + + expect(connectionEntityUpdate).toHaveBeenCalledTimes(3) + expect(internalEntityUpdate).toHaveBeenCalledTimes(2) + }) + + test('only feedbacks', () => { + list.subscribe(true, EntityModelType.Feedback) + + expect(connectionEntityUpdate).toHaveBeenCalledTimes(1) + expect(internalEntityUpdate).toHaveBeenCalledTimes(0) + }) + + test('only internal', () => { + list.subscribe(true, undefined, 'internal') + + expect(connectionEntityUpdate).toHaveBeenCalledTimes(0) + expect(internalEntityUpdate).toHaveBeenCalledTimes(2) + }) + + test('only conn01', () => { + list.subscribe(true, undefined, 'conn01') + + expect(connectionEntityUpdate).toHaveBeenCalledTimes(1) + expect(internalEntityUpdate).toHaveBeenCalledTimes(0) + }) + + test('only missing-connection', () => { + list.subscribe(true, undefined, 'missing-connection') + + expect(connectionEntityUpdate).toHaveBeenCalledTimes(0) + expect(internalEntityUpdate).toHaveBeenCalledTimes(0) + }) +}) + +describe('findById', () => { + const { list, getEntityDefinition, connectionEntityUpdate, internalEntityUpdate } = createList('test01') + + getEntityDefinition.mockImplementation(ActionTreeEntityDefinitions) + + list.loadStorage(cloneDeep(ActionTree), true, false) + + test('find missing id', () => { + const entity = list.findById('missing-id') + expect(entity).toBeUndefined() + }) + + test('find at root level', () => { + const entity = list.findById('01') + expect(entity).not.toBeUndefined() + expect(entity?.id).toBe('01') + }) + + test('find deep - int1-b', () => { + const entity = list.findById('int1-b') + expect(entity).not.toBeUndefined() + expect(entity?.id).toBe('int1-b') + }) +}) + +describe('findParentAndIndex', () => { + const { list, getEntityDefinition } = createList('test01') + + getEntityDefinition.mockImplementation(ActionTreeEntityDefinitions) + + list.loadStorage(cloneDeep(ActionTree), true, false) + + test('find missing id', () => { + const entity = list.findParentAndIndex('missing-id') + expect(entity).toBeUndefined() + }) + + test('find at root level', () => { + const info = list.findParentAndIndex('02') + expect(info).not.toBeUndefined() + const { parent, index, item } = info! + expect(item.id).toBe('02') + expect(index).toBe(1) + expect(parent.ownerId).toBe(null) + }) + + test('find deep - int1-b', () => { + const info = list.findParentAndIndex('int1-b') + expect(info).not.toBeUndefined() + const { parent, index, item } = info! + expect(item.id).toBe('int1-b') + expect(index).toBe(0) + expect(parent.ownerId).toEqual({ + childGroup: 'default', + parentId: 'int1', + } satisfies EntityOwner) + }) +}) + +describe('addEntity', () => { + function expectEntityToMatchModel(entity: SomeEntityModel, instance: ControlEntityInstance) { + expect(instance).not.toBeUndefined() + expect(instance.id).toBe(entity.id) + expect(instance.connectionId).toBe(entity.connectionId) + expect(instance.definitionId).toBe(entity.definitionId) + expect(instance.rawOptions).toEqual(entity.options) + expect(instance.asEntityModel()).toEqual(entity) + } + + test('add action to action list', () => { + const { list } = createList('test01', null, { type: EntityModelType.Action }) + + const newAction: ActionEntityModel = { + id: 'new01', + type: EntityModelType.Action, + connectionId: 'my-conn99', + definitionId: 'something', + options: { + test: 123, + }, + } + + const newInstance = list.addEntity(cloneDeep(newAction)) + expect(newInstance).not.toBeUndefined() + expectEntityToMatchModel(newAction, newInstance) + }) + + test('add action to feedback list', () => { + const { list } = createList('test01', null, { type: EntityModelType.Feedback }) + + const newAction: ActionEntityModel = { + id: 'new01', + type: EntityModelType.Action, + connectionId: 'my-conn99', + definitionId: 'something', + options: { + test: 123, + }, + } + + expect(() => list.addEntity(cloneDeep(newAction))).toThrowError('EntityList cannot accept this type of entity') + expect(list.getAllEntities()).toHaveLength(0) + }) + + test('add feedback to action list', () => { + const { list } = createList('test01', null, { type: EntityModelType.Action }) + + const newFeedback: FeedbackEntityModel = { + id: 'new01', + type: EntityModelType.Feedback, + connectionId: 'my-conn99', + definitionId: 'something', + options: { + test: 123, + }, + } + + expect(() => list.addEntity(cloneDeep(newFeedback))).toThrowError('EntityList cannot accept this type of entity') + expect(list.getAllEntities()).toHaveLength(0) + }) + + test('add feedback to feedback list', () => { + const { list } = createList('test01', null, { type: EntityModelType.Feedback }) + + const newFeedback: FeedbackEntityModel = { + id: 'new01', + type: EntityModelType.Feedback, + connectionId: 'my-conn99', + definitionId: 'something', + options: { + test: 123, + }, + } + + const newInstance = list.addEntity(cloneDeep(newFeedback)) + expect(newInstance).not.toBeUndefined() + expectEntityToMatchModel(newFeedback, newInstance) + }) + + test('add unknown feedback to boolean feedback list', () => { + const { list } = createList('test01', null, { type: EntityModelType.Feedback, booleanFeedbacksOnly: true }) + + const newFeedback: FeedbackEntityModel = { + id: 'new01', + type: EntityModelType.Feedback, + connectionId: 'my-conn99', + definitionId: 'something', + options: { + test: 123, + }, + } + + expect(() => list.addEntity(cloneDeep(newFeedback))).toThrowError('EntityList cannot accept this type of entity') + expect(list.getAllEntities()).toHaveLength(0) + }) + + test('add advanced feedback to boolean feedback list', () => { + const { list, getEntityDefinition } = createList('test01', null, { + type: EntityModelType.Feedback, + booleanFeedbacksOnly: true, + }) + + getEntityDefinition.mockReturnValueOnce({ + entityType: EntityModelType.Feedback, + feedbackType: 'advanced', + } as Partial as any) + + const newFeedback: FeedbackEntityModel = { + id: 'new01', + type: EntityModelType.Feedback, + connectionId: 'my-conn99', + definitionId: 'something', + options: { + test: 123, + }, + } + + expect(() => list.addEntity(cloneDeep(newFeedback))).toThrowError('EntityList cannot accept this type of entity') + expect(list.getAllEntities()).toHaveLength(0) + + expect(getEntityDefinition).toHaveBeenCalledTimes(1) + expect(getEntityDefinition).toHaveBeenCalledWith(EntityModelType.Feedback, 'my-conn99', 'something') + }) + + test('add boolean feedback to boolean feedback list', () => { + const { list, getEntityDefinition } = createList('test01', null, { + type: EntityModelType.Feedback, + booleanFeedbacksOnly: true, + }) + + const newFeedback: FeedbackEntityModel = { + id: 'new01', + type: EntityModelType.Feedback, + connectionId: 'my-conn99', + definitionId: 'something', + options: { + test: 123, + }, + } + + getEntityDefinition.mockReturnValueOnce({ + entityType: EntityModelType.Feedback, + feedbackType: 'boolean', + } as Partial as any) + + const newInstance = list.addEntity(cloneDeep(newFeedback)) + expect(newInstance).not.toBeUndefined() + expectEntityToMatchModel(newFeedback, newInstance) + + expect(getEntityDefinition).toHaveBeenCalledTimes(1) + expect(getEntityDefinition).toHaveBeenCalledWith(EntityModelType.Feedback, 'my-conn99', 'something') + }) + + test('non-internal with children', () => { + const { list } = createList('test01', null, { + type: EntityModelType.Feedback, + }) + + const newFeedback: FeedbackEntityModel = { + id: 'new01', + type: EntityModelType.Feedback, + connectionId: 'my-conn99', + definitionId: 'something', + options: { + test: 123, + }, + children: { + group1: [ + { + id: 'child01', + type: EntityModelType.Feedback, + connectionId: 'my-conn99', + definitionId: 'something', + options: { + test: 123, + }, + }, + ], + }, + } + + const newInstance = list.addEntity(cloneDeep(newFeedback)) + expect(newInstance).not.toBeUndefined() + + // Prune out the children which will have been discarded + delete newFeedback.children + expectEntityToMatchModel(newFeedback, newInstance) + + expect(list.getAllEntities()).toHaveLength(1) + }) + + test('internal with children', () => { + const { list, getEntityDefinition, connectionEntityUpdate, internalEntityUpdate } = createList('test01', null, { + type: EntityModelType.Feedback, + }) + + getEntityDefinition.mockReturnValueOnce({ + entityType: EntityModelType.Feedback, + supportsChildGroups: [ + { + type: EntityModelType.Feedback, + groupId: 'group1', + entityTypeLabel: 'Feedback', + label: 'Feedback', + }, + ], + } satisfies Partial as any) + + const newFeedback: FeedbackEntityModel = { + id: 'new01', + type: EntityModelType.Feedback, + connectionId: 'internal', + definitionId: 'something', + options: { + test: 123, + }, + children: { + group1: [ + { + id: 'child01', + type: EntityModelType.Feedback, + connectionId: 'my-conn99', + definitionId: 'thing', + options: { + test: 99, + }, + }, + ], + group2: [ + { + id: 'child02', + type: EntityModelType.Feedback, + connectionId: 'my-conn99', + definitionId: 'another', + options: { + test: 45, + }, + }, + ], + }, + } + + const newInstance = list.addEntity(cloneDeep(newFeedback)) + expect(newInstance).not.toBeUndefined() + + // Prune out the children which will have been discarded + delete newFeedback.children!.group2 + expectEntityToMatchModel(newFeedback, newInstance) + + const allInstances = list.getAllEntities() + expect(allInstances).toHaveLength(2) + expectEntityToMatchModel(newFeedback, allInstances[0]) + expectEntityToMatchModel(newFeedback.children!['group1']![0], allInstances[1]) + + // ensure not cloned + expect(connectionEntityUpdate).toHaveBeenCalledTimes(0) + expect(internalEntityUpdate).toHaveBeenCalledTimes(0) + }) + + test('add cloned', () => { + const { list } = createList('test01', null, { type: EntityModelType.Feedback }) + + const newFeedback: FeedbackEntityModel = { + id: 'new01', + type: EntityModelType.Feedback, + connectionId: 'my-conn99', + definitionId: 'something', + options: { + test: 123, + }, + } + + const newInstance = list.addEntity(cloneDeep(newFeedback), true) + expect(newInstance).not.toBeUndefined() + expect(newInstance.id).not.toBe(newFeedback.id) + + // Update the expected id + newFeedback.id = newInstance.id + expectEntityToMatchModel(newFeedback, newInstance) + }) + + test('clone with children', () => { + const { list, getEntityDefinition } = createList('test01', null, { + type: EntityModelType.Feedback, + }) + + getEntityDefinition.mockReturnValueOnce({ + entityType: EntityModelType.Feedback, + supportsChildGroups: [ + { + type: EntityModelType.Feedback, + groupId: 'group1', + entityTypeLabel: 'Feedback', + label: 'Feedback', + }, + ], + } satisfies Partial as any) + + const newFeedback: FeedbackEntityModel = { + id: 'new01', + type: EntityModelType.Feedback, + connectionId: 'internal', + definitionId: 'something', + options: { + test: 123, + }, + children: { + group1: [ + { + id: 'child01', + type: EntityModelType.Feedback, + connectionId: 'my-conn99', + definitionId: 'thing', + options: { + test: 99, + }, + }, + ], + }, + } + + const newInstance = list.addEntity(cloneDeep(newFeedback), true) + expect(newInstance).not.toBeUndefined() + + expect(newInstance.id).not.toBe(newFeedback.id) + newFeedback.id = newInstance.id + expect(newInstance.getAllChildren()[0].id).not.toBe(newFeedback.children!['group1']![0].id) + newFeedback.children!['group1']![0].id = newInstance.getAllChildren()[0].id + expectEntityToMatchModel(newFeedback, newInstance) + }) +}) + +describe('removeEntity', () => { + test('remove from root', () => { + const { list, getEntityDefinition, internalEntityDelete, connectionEntityDelete } = createList('test01') + + getEntityDefinition.mockImplementation(ActionTreeEntityDefinitions) + + list.loadStorage(cloneDeep(ActionTree), true, false) + expect(internalEntityDelete).toHaveBeenCalledTimes(0) + expect(connectionEntityDelete).toHaveBeenCalledTimes(0) + + // Starts with correct length + expect(list.getAllEntities()).toHaveLength(6) + expect(list.findById('02')).not.toBeUndefined() + + // Remove from root + list.removeEntity('02') + + // Check was removed + expect(list.getAllEntities()).toHaveLength(5) + expect(list.findById('02')).toBeUndefined() + expect(internalEntityDelete).toHaveBeenCalledTimes(0) + expect(connectionEntityDelete).toHaveBeenCalledTimes(1) + expect(connectionEntityDelete).toHaveBeenCalledWith(ActionTree[1], 'test01') + }) + + test('remove from root with children', () => { + const { list, getEntityDefinition, internalEntityDelete, connectionEntityDelete } = createList('test01') + + getEntityDefinition.mockImplementation(ActionTreeEntityDefinitions) + + list.loadStorage(cloneDeep(ActionTree), true, false) + expect(internalEntityDelete).toHaveBeenCalledTimes(0) + expect(connectionEntityDelete).toHaveBeenCalledTimes(0) + + // Starts with correct length + expect(list.getAllEntities()).toHaveLength(6) + expect(list.findById('int0')).not.toBeUndefined() + + // Remove from root + list.removeEntity('int0') + + // Check was removed + expect(list.getAllEntities()).toHaveLength(2) + expect(list.findById('int0')).toBeUndefined() + expect(internalEntityDelete).toHaveBeenCalledTimes(2) + expect(connectionEntityDelete).toHaveBeenCalledTimes(2) + }) + + test('remove deep', () => { + const { list, getEntityDefinition, internalEntityDelete, connectionEntityDelete } = createList('test01') + + getEntityDefinition.mockImplementation(ActionTreeEntityDefinitions) + + list.loadStorage(cloneDeep(ActionTree), true, false) + expect(internalEntityDelete).toHaveBeenCalledTimes(0) + expect(connectionEntityDelete).toHaveBeenCalledTimes(0) + + // Starts with correct length + expect(list.getAllEntities()).toHaveLength(6) + expect(list.findById('int1-b')).not.toBeUndefined() + + // Remove from root + list.removeEntity('int1-b') + + // Check was removed + expect(list.getAllEntities()).toHaveLength(5) + expect(list.findById('int1-b')).toBeUndefined() + expect(internalEntityDelete).toHaveBeenCalledTimes(0) + expect(connectionEntityDelete).toHaveBeenCalledTimes(1) + }) +}) + +describe('popEntity', () => { + test('pop empty', () => { + const { list } = createList('test01') + + const entity = list.popEntity(0) + expect(entity).toBeUndefined() + }) + + test('pop first', () => { + const { list, getEntityDefinition, internalEntityDelete, connectionEntityDelete } = createList('test01') + + getEntityDefinition.mockImplementation(ActionTreeEntityDefinitions) + + list.loadStorage(cloneDeep(ActionTree), true, false) + expect(internalEntityDelete).toHaveBeenCalledTimes(0) + expect(connectionEntityDelete).toHaveBeenCalledTimes(0) + + const allEntitiesBefore = list.getAllEntities() + const beforeIds = allEntitiesBefore.map((e) => e.id) + + // Starts with correct length + expect(allEntitiesBefore).toHaveLength(6) + + // Remove from root + const popped = list.popEntity(0) + expect(popped).not.toBeUndefined() + expect(popped).toBe(allEntitiesBefore[0]) + + // Check no lifecycle + expect(internalEntityDelete).toHaveBeenCalledTimes(0) + expect(connectionEntityDelete).toHaveBeenCalledTimes(0) + + // Check ids after remove + const afterIds = list.getAllEntities().map((e) => e.id) + beforeIds.splice(0, 1) + expect(afterIds).toEqual(beforeIds) + }) + + test('pop negative', () => { + const { list, getEntityDefinition, internalEntityDelete, connectionEntityDelete } = createList('test01') + + getEntityDefinition.mockImplementation(ActionTreeEntityDefinitions) + + list.loadStorage(cloneDeep(ActionTree), true, false) + expect(internalEntityDelete).toHaveBeenCalledTimes(0) + expect(connectionEntityDelete).toHaveBeenCalledTimes(0) + + // Starts with correct length + expect(list.getAllEntities()).toHaveLength(6) + + // Remove from root + expect(list.popEntity(-1)).toBeUndefined() + + // Check no lifecycle + expect(internalEntityDelete).toHaveBeenCalledTimes(0) + expect(connectionEntityDelete).toHaveBeenCalledTimes(0) + + // Check ids after remove + expect(list.getAllEntities()).toHaveLength(6) + }) + + test('after end', () => { + const { list, getEntityDefinition, internalEntityDelete, connectionEntityDelete } = createList('test01') + + getEntityDefinition.mockImplementation(ActionTreeEntityDefinitions) + + list.loadStorage(cloneDeep(ActionTree), true, false) + expect(internalEntityDelete).toHaveBeenCalledTimes(0) + expect(connectionEntityDelete).toHaveBeenCalledTimes(0) + + // Starts with correct length + expect(list.getAllEntities()).toHaveLength(6) + + // Remove from root + expect(list.popEntity(4)).toBeUndefined() + + // Check no lifecycle + expect(internalEntityDelete).toHaveBeenCalledTimes(0) + expect(connectionEntityDelete).toHaveBeenCalledTimes(0) + + // Check ids after remove + expect(list.getAllEntities()).toHaveLength(6) + }) + + test('with children', () => { + const { list, getEntityDefinition, internalEntityDelete, connectionEntityDelete } = createList('test01') + + getEntityDefinition.mockImplementation(ActionTreeEntityDefinitions) + + list.loadStorage(cloneDeep(ActionTree), true, false) + expect(internalEntityDelete).toHaveBeenCalledTimes(0) + expect(connectionEntityDelete).toHaveBeenCalledTimes(0) + + const allEntitiesBefore = list.getAllEntities() + const beforeIds = allEntitiesBefore.map((e) => e.id) + + // Starts with correct length + expect(allEntitiesBefore).toHaveLength(6) + + // Remove from root + const popped = list.popEntity(2) + expect(popped).not.toBeUndefined() + expect(popped).toBe(allEntitiesBefore[2]) + + // Check no lifecycle + expect(internalEntityDelete).toHaveBeenCalledTimes(0) + expect(connectionEntityDelete).toHaveBeenCalledTimes(0) + + // Check ids after remove + const afterIds = list.getAllEntities().map((e) => e.id) + beforeIds.splice(2, 4) + expect(afterIds).toEqual(beforeIds) + }) +}) + +describe('pushEntity', () => { + test('push to empty', () => { + const { list, newAction, internalEntityUpdate, connectionEntityUpdate } = createList('test01') + + expect(list.getAllEntities()).toHaveLength(0) + expect(internalEntityUpdate).toHaveBeenCalledTimes(0) + expect(connectionEntityUpdate).toHaveBeenCalledTimes(0) + + list.pushEntity(newAction, 5) + + expect(list.getAllEntities()).toHaveLength(1) + expect(list.getAllEntities()[0]).toBe(newAction) + expect(internalEntityUpdate).toHaveBeenCalledTimes(0) + expect(connectionEntityUpdate).toHaveBeenCalledTimes(0) + }) + + test('push front', () => { + const { list, newAction } = createList('test01') + + list.loadStorage(cloneDeep(ActionTree), true, false) + + // Starts with correct length + expect(list.getAllEntities()).toHaveLength(3) + + list.pushEntity(newAction, 0) + + // Check ids after push + const afterIds = list.getAllEntities().map((e) => e.id) + expect(afterIds).toHaveLength(4) + expect(afterIds[0]).toBe(newAction.id) + }) + + test('push front - negative', () => { + const { list, newAction } = createList('test01') + + list.loadStorage(cloneDeep(ActionTree), true, false) + + // Starts with correct length + expect(list.getAllEntities()).toHaveLength(3) + + list.pushEntity(newAction, -1) + + // Check ids after push + const afterIds = list.getAllEntities().map((e) => e.id) + expect(afterIds).toHaveLength(4) + expect(afterIds[0]).toBe(newAction.id) + }) + + test('push front - beyond end', () => { + const { list, newAction } = createList('test01') + + list.loadStorage(cloneDeep(ActionTree), true, false) + + // Starts with correct length + expect(list.getAllEntities()).toHaveLength(3) + + list.pushEntity(newAction, 5) + + // Check ids after push + const afterIds = list.getAllEntities().map((e) => e.id) + expect(afterIds).toHaveLength(4) + expect(afterIds[3]).toBe(newAction.id) + }) + + test('push front - middle', () => { + const { list, newAction } = createList('test01') + + list.loadStorage(cloneDeep(ActionTree), true, false) + + // Starts with correct length + expect(list.getAllEntities()).toHaveLength(3) + + list.pushEntity(newAction, 1) + + // Check ids after push + const afterIds = list.getAllEntities().map((e) => e.id) + expect(afterIds).toHaveLength(4) + expect(afterIds[1]).toBe(newAction.id) + }) +}) + +// canAcceptEntity is tested as part of addEntity + +describe('duplicateEntity', () => { + test('duplicate missing', () => { + const { list, connectionEntityUpdate, internalEntityUpdate } = createList('test01') + + list.loadStorage(cloneDeep(ActionTree), true, false) + + // Starts with correct length + expect(list.getAllEntities()).toHaveLength(3) + + const newEntity = list.duplicateEntity('not-real') + expect(newEntity).toBeUndefined() + + // Check ids after push + expect(list.getAllEntities()).toHaveLength(3) + + // No callbacks + expect(connectionEntityUpdate).toHaveBeenCalledTimes(0) + expect(internalEntityUpdate).toHaveBeenCalledTimes(0) + }) + + test('duplicate at root ', () => { + const { list, connectionEntityUpdate, internalEntityUpdate } = createList('test01') + + list.loadStorage(cloneDeep(ActionTree), true, false) + + // Starts with correct length + expect(list.getAllEntities()).toHaveLength(3) + + const newEntity = list.duplicateEntity('02') + expect(newEntity).not.toBeUndefined() + + // Check ids after push + const afterIds = list.getAllEntities().map((e) => e.id) + expect(afterIds).toHaveLength(4) + expect(afterIds[1]).toBe('02') + expect(afterIds[2]).toBe(newEntity!.id) + + expect(connectionEntityUpdate).toHaveBeenCalledTimes(1) + expect(connectionEntityUpdate).toHaveBeenCalledWith(newEntity!.asEntityModel(), 'test01') + expect(internalEntityUpdate).toHaveBeenCalledTimes(0) + }) + + test('duplicate deep', () => { + const { list, getEntityDefinition, connectionEntityUpdate, internalEntityUpdate } = createList('test01') + + getEntityDefinition.mockImplementation(ActionTreeEntityDefinitions) + + list.loadStorage(cloneDeep(ActionTree), true, false) + + // Starts with correct length + expect(list.getAllEntities()).toHaveLength(6) + + const newEntity = list.duplicateEntity('int1') + expect(newEntity).not.toBeUndefined() + + // Check ids after push + const afterIds = list.getAllEntities().map((e) => e.id) + expect(afterIds).toHaveLength(8) + expect(afterIds[3]).toBe('int1') + expect(afterIds[5]).toBe(newEntity!.id) + + const newEntityChild = newEntity?.getAllChildren()[0] + expect(newEntityChild).toBeTruthy() + expect(afterIds[6]).toBe(newEntityChild!.id) + + expect(connectionEntityUpdate).toHaveBeenCalledTimes(1) + expect(connectionEntityUpdate).toHaveBeenCalledWith(newEntityChild!.asEntityModel(), 'test01') + expect(internalEntityUpdate).toHaveBeenCalledTimes(1) + expect(internalEntityUpdate).toHaveBeenCalledWith(newEntity!.asEntityModel(), 'test01') + }) +}) + +describe('forgetForConnection', () => { + test('cleanup nothing', () => { + const { list, getEntityDefinition } = createList('test01') + + getEntityDefinition.mockImplementation(ActionTreeEntityDefinitions) + + list.loadStorage(cloneDeep(ActionTree), true, false) + + // Starts with correct length + expect(list.getAllEntities()).toHaveLength(6) + + expect(list.forgetForConnection('missing-connection')).toBe(false) + + // Check ids after cleanup + expect(list.getAllEntities()).toHaveLength(6) + }) + + test('remove from root', () => { + const { list, getEntityDefinition, connectionEntityDelete } = createList('test01') + + getEntityDefinition.mockImplementation(ActionTreeEntityDefinitions) + + list.loadStorage(cloneDeep(ActionTree), true, false) + + // Starts with correct length + expect(list.getAllEntities()).toHaveLength(6) + + expect(list.forgetForConnection('conn02')).toBe(true) + + // Check ids after cleanup + expect(list.getAllEntities()).toHaveLength(5) + expect(connectionEntityDelete).toHaveBeenCalledTimes(1) + }) + + test('remove deep', () => { + const { list, getEntityDefinition, connectionEntityDelete } = createList('test01') + + getEntityDefinition.mockImplementation(ActionTreeEntityDefinitions) + + list.loadStorage(cloneDeep(ActionTree), true, false) + + // Starts with correct length + expect(list.getAllEntities()).toHaveLength(6) + + expect(list.forgetForConnection('conn04')).toBe(true) + + // Check ids after cleanup + expect(list.getAllEntities()).toHaveLength(4) + expect(connectionEntityDelete).toHaveBeenCalledTimes(2) + }) +}) + +describe('verifyConnectionIds', () => { + test('cleanup nothing', () => { + const { list, getEntityDefinition } = createList('test01') + + getEntityDefinition.mockImplementation(ActionTreeEntityDefinitions) + + list.loadStorage(cloneDeep(ActionTree), true, false) + + // Starts with correct length + expect(list.getAllEntities()).toHaveLength(6) + + const allConnectionIds = new Set(list.getAllEntities().map((e) => e.connectionId)) + list.verifyConnectionIds(allConnectionIds) + + // Check ids after cleanup + expect(list.getAllEntities()).toHaveLength(6) + }) + + test('remove from root', () => { + const { list, getEntityDefinition, connectionEntityDelete } = createList('test01') + + getEntityDefinition.mockImplementation(ActionTreeEntityDefinitions) + + list.loadStorage(cloneDeep(ActionTree), true, false) + + // Starts with correct length + expect(list.getAllEntities()).toHaveLength(6) + + const allConnectionIds = new Set(list.getAllEntities().map((e) => e.connectionId)) + allConnectionIds.delete('conn02') + list.verifyConnectionIds(allConnectionIds) + + // Check ids after cleanup + expect(list.getAllEntities()).toHaveLength(5) + expect(connectionEntityDelete).toHaveBeenCalledTimes(0) + }) + + test('remove deep', () => { + const { list, getEntityDefinition, connectionEntityDelete } = createList('test01') + + getEntityDefinition.mockImplementation(ActionTreeEntityDefinitions) + + list.loadStorage(cloneDeep(ActionTree), true, false) + + // Starts with correct length + expect(list.getAllEntities()).toHaveLength(6) + + const allConnectionIds = new Set(list.getAllEntities().map((e) => e.connectionId)) + allConnectionIds.delete('conn04') + list.verifyConnectionIds(allConnectionIds) + + // Check ids after cleanup + expect(list.getAllEntities()).toHaveLength(4) + expect(connectionEntityDelete).toHaveBeenCalledTimes(0) + }) +}) + +// TODO - postProcessImport + +describe('getChildBooleanFeedbackValues', () => { + const { list, getEntityDefinition } = createList('test01', null, { + type: EntityModelType.Feedback, + booleanFeedbacksOnly: true, + }) + getEntityDefinition.mockImplementation(FeedbackTreeEntityDefinitions) + + test('invalid for action list', () => { + const { list } = createList('test01', null, { type: EntityModelType.Action }) + + expect(list.clearCachedValueForConnectionId('internal')).toBe(false) + }) + + test('invalid for non boolean feedbacks list', () => { + const { list } = createList('test01', null, { type: EntityModelType.Feedback }) + + expect(list.clearCachedValueForConnectionId('internal')).toBe(false) + }) + + test('all disabled', () => { + list.loadStorage(cloneDeep(FeedbackTree), true, false) + // seed some values for boolean feedabcks + list.updateFeedbackValues('conn02', { '02': true }) + + // Disable all feedbacks + for (const entity of list.getAllEntities()) { + entity.setEnabled(false) + } + + expect(list.clearCachedValueForConnectionId('internal')).toBe(true) + }) + + test('basic boolean values', () => { + list.loadStorage(cloneDeep(FeedbackTree), true, false) + // seed some values for boolean feedabcks + list.updateFeedbackValues('conn02', { '02': true }) + + const entity = list.findById('02') + expect(entity).not.toBeUndefined() + expect(entity!.feedbackValue).toBe(true) + + expect(list.clearCachedValueForConnectionId(entity!.connectionId)).toBe(true) + + expect(entity!.feedbackValue).toBe(undefined) + }) + + test('advanced value values', () => { + list.loadStorage(cloneDeep(FeedbackTree), true, false) + // seed some values for boolean feedabcks + list.updateFeedbackValues('internal', { int0: 'abc' }) + + const entity = list.findById('int0') + expect(entity).not.toBeUndefined() + expect(entity!.feedbackValue).toBe('abc') + + expect(list.clearCachedValueForConnectionId(entity!.connectionId)).toBe(true) + + expect(entity!.feedbackValue).toBe(undefined) + }) +}) + +describe('getBooleanFeedbackValue', () => { + const { list, getEntityDefinition } = createList('test01', null, { + type: EntityModelType.Feedback, + booleanFeedbacksOnly: true, + }) + getEntityDefinition.mockImplementation(FeedbackTreeEntityDefinitions) + + test('invalid for action list', () => { + const { list } = createList('test01', null, { type: EntityModelType.Action }) + + expect(() => list.getBooleanFeedbackValue()).toThrow('ControlEntityList is not boolean feedbacks') + }) + + test('invalid for non boolean feedbacks list', () => { + const { list } = createList('test01', null, { type: EntityModelType.Feedback }) + + expect(() => list.getBooleanFeedbackValue()).toThrow('ControlEntityList is not boolean feedbacks') + }) + + test('empty list', () => { + list.loadStorage([], true, false) + + // When empty disabled, should return true + expect(list.getBooleanFeedbackValue()).toBe(true) + }) + + test('all disabled', () => { + list.loadStorage(cloneDeep(FeedbackTree), true, false) + // seed some values for boolean feedabcks + list.updateFeedbackValues('conn02', { '02': true }) + + // Disable all feedbacks + for (const entity of list.getAllEntities()) { + entity.setEnabled(false) + } + + // When all disabled, should return true + expect(list.getBooleanFeedbackValue()).toBe(true) + }) + + test('boolean values values', () => { + list.loadStorage(cloneDeep(FeedbackTree), true, false) + + // disable the one set to non-boolean + list.findById('int0')?.setEnabled(false) + + // set some values + list.updateFeedbackValues('conn01', { '01': true }) + + // check still false + expect(list.getBooleanFeedbackValue()).toBe(false) + + // set final value + list.updateFeedbackValues('conn02', { '02': true }) + expect(list.getBooleanFeedbackValue()).toBe(true) + }) +}) + +describe('getChildBooleanFeedbackValues', () => { + const { list, getEntityDefinition } = createList('test01', null, { + type: EntityModelType.Feedback, + booleanFeedbacksOnly: true, + }) + getEntityDefinition.mockImplementation(FeedbackTreeEntityDefinitions) + + test('invalid for action list', () => { + const { list } = createList('test01', null, { type: EntityModelType.Action }) + + expect(() => list.getChildBooleanFeedbackValues()).toThrow('ControlEntityList is not boolean feedbacks') + }) + + test('invalid for non boolean feedbacks list', () => { + const { list } = createList('test01', null, { type: EntityModelType.Feedback }) + + expect(() => list.getChildBooleanFeedbackValues()).toThrow('ControlEntityList is not boolean feedbacks') + }) + + test('all disabled', () => { + list.loadStorage(cloneDeep(FeedbackTree), true, false) + // seed some values for boolean feedabcks + list.updateFeedbackValues('conn02', { '02': true }) + + // Disable all feedbacks + for (const entity of list.getAllEntities()) { + entity.setEnabled(false) + } + + expect(list.getChildBooleanFeedbackValues()).toHaveLength(0) + }) + + test('basic feedback values', () => { + list.loadStorage(cloneDeep(FeedbackTree), true, false) + // seed some values for boolean feedabcks + list.updateFeedbackValues('conn02', { '02': true }) + list.updateFeedbackValues('internal', { int0: 'abcd' }) + + const fb = list.findById('02') + fb!.setStyleValue('bgcolor', 123) + + const len = list.getDirectEntities().length + + const values = list.getChildBooleanFeedbackValues() + expect(values).toHaveLength(len) + + expect(values).toEqual([false, true, false]) + }) +}) + +describe('buildFeedbackStyle', () => { + const { list, getEntityDefinition } = createList('test01', null, { type: EntityModelType.Feedback }) + getEntityDefinition.mockImplementation(FeedbackTreeEntityDefinitions) + + test('invalid for action list', () => { + const { list } = createList('test01', null, { type: EntityModelType.Action }) + + const styleBuilder = mock() + + expect(() => list.buildFeedbackStyle(styleBuilder)).toThrow('ControlEntityList is not style feedbacks') + }) + + test('invalid for boolean feedbacks list', () => { + const { list } = createList('test01', null, { type: EntityModelType.Feedback, booleanFeedbacksOnly: true }) + + const styleBuilder = mock() + + expect(() => list.buildFeedbackStyle(styleBuilder)).toThrow('ControlEntityList is not style feedbacks') + }) + + test('disabled', () => { + list.loadStorage(cloneDeep(FeedbackTree), true, false) + // seed some values for boolean feedabcks + list.updateFeedbackValues('conn02', { '02': true }) + + // Disable all feedbacks + for (const entity of list.getAllEntities()) { + entity.setEnabled(false) + } + + const styleBuilder = mock() + list.buildFeedbackStyle(styleBuilder) + + expect(styleBuilder.applyComplexStyle).toHaveBeenCalledTimes(0) + expect(styleBuilder.applySimpleStyle).toHaveBeenCalledTimes(0) + }) + + test('basic feedback values', () => { + list.loadStorage(cloneDeep(FeedbackTree), true, false) + // seed some values for boolean feedabcks + list.updateFeedbackValues('conn02', { '02': true }) + list.updateFeedbackValues('internal', { int0: 'abcd' }) + + const fb = list.findById('02') + fb!.setStyleValue('bgcolor', 123) + + const styleBuilder = mock() + list.buildFeedbackStyle(styleBuilder) + + expect(styleBuilder.applyComplexStyle).toHaveBeenCalledTimes(1) + expect(styleBuilder.applySimpleStyle).toHaveBeenCalledTimes(1) + + expect(styleBuilder.applyComplexStyle).toHaveBeenCalledWith('abcd') + expect(styleBuilder.applySimpleStyle).toHaveBeenCalledWith({ bgcolor: 123 }) + }) +}) + +describe('updateFeedbackValues', () => { + test('no values', () => { + const { list, getEntityDefinition } = createList('test01') + + getEntityDefinition.mockImplementation(ActionTreeEntityDefinitions) + + list.loadStorage(cloneDeep(ActionTree), true, false) + + // Starts with correct length + expect(list.getAllEntities()).toHaveLength(6) + + expect(list.updateFeedbackValues('internal', {})).toBe(false) + }) + + test('try set value for action', () => { + const { list, getEntityDefinition } = createList('test01') + + getEntityDefinition.mockImplementation(ActionTreeEntityDefinitions) + + list.loadStorage(cloneDeep(ActionTree), true, false) + + // Starts with correct length + expect(list.getAllEntities()).toHaveLength(6) + + expect( + list.updateFeedbackValues('internal', { + int0: 'abc', + }) + ).toBe(false) + + // Ensure value is still undefined + const entity = list.findById('int0') + expect(entity).toBeTruthy() + expect(entity!.feedbackValue).toEqual(undefined) + }) + + test('set value for nested feedback', () => { + const { list, getEntityDefinition } = createList('test01') + + getEntityDefinition.mockImplementation(ActionTreeEntityDefinitions) + + list.loadStorage(cloneDeep(ActionTree), true, false) + + // Starts with correct length + expect(list.getAllEntities()).toHaveLength(6) + + expect( + list.updateFeedbackValues('conn04', { + int2: 'abc', + }) + ).toBe(true) + + // Ensure value is reflected + const entity = list.findById('int2') + expect(entity).toBeTruthy() + expect(entity!.feedbackValue).toEqual('abc') + }) + + test('set value unchanged', () => { + const { list, getEntityDefinition } = createList('test01') + + getEntityDefinition.mockImplementation(ActionTreeEntityDefinitions) + + list.loadStorage(cloneDeep(ActionTree), true, false) + + // Starts with correct length + expect(list.getAllEntities()).toHaveLength(6) + + // Set once + list.updateFeedbackValues('conn04', { + int2: 'abc', + }) + + // Try again + expect( + list.updateFeedbackValues('conn04', { + int2: 'abc', + }) + ).toBe(false) + + // Ensure value is reflected + const entity = list.findById('int2') + expect(entity).toBeTruthy() + expect(entity!.feedbackValue).toEqual('abc') + }) +}) + +describe('getAllEnabledConnectionIds', () => { + test('default', () => { + const { list, getEntityDefinition } = createList('test01') + + getEntityDefinition.mockImplementation(ActionTreeEntityDefinitions) + + list.loadStorage(cloneDeep(ActionTree), true, false) + + // Starts with correct length + expect(list.getAllEntities()).toHaveLength(6) + + const allConnectionIds = new Set(list.getAllEntities().map((e) => e.connectionId)) + + const connectionIds = new Set() + list.getAllEnabledConnectionIds(connectionIds) + expect(connectionIds).toHaveLength(allConnectionIds.size) + }) + + test('all disabled', () => { + const { list, getEntityDefinition } = createList('test01') + + getEntityDefinition.mockImplementation(ActionTreeEntityDefinitions) + + const actions = cloneDeep(ActionTree) + for (const entity of actions) { + entity.disabled = true + } + + list.loadStorage(actions, true, false) + + // Starts with correct length + expect(list.getAllEntities()).toHaveLength(6) + + // const allConnectionIds = new Set(list.getAllEntities().map((e) => e.connectionId)) + const connectionIds = new Set() + list.getAllEnabledConnectionIds(connectionIds) + expect(connectionIds).toHaveLength(0) + }) +}) diff --git a/companion/test/Controls/Entities/EntityListModels.ts b/companion/test/Controls/Entities/EntityListModels.ts new file mode 100644 index 0000000000..efd8a28037 --- /dev/null +++ b/companion/test/Controls/Entities/EntityListModels.ts @@ -0,0 +1,244 @@ +import { ClientEntityDefinition } from '@companion-app/shared/Model/EntityDefinitionModel.js' +import { SomeEntityModel, EntityModelType } from '@companion-app/shared/Model/EntityModel.js' + +export function getAllModelsInTree(tree: SomeEntityModel[]): SomeEntityModel[] { + const result: SomeEntityModel[] = [] + for (const entity of tree) { + result.push(entity) + if (entity.connectionId === 'internal' && entity.children) { + for (const group of Object.values(entity.children)) { + if (group) { + result.push(...getAllModelsInTree(group)) + } + } + } + } + return result +} + +export const ActionTree: SomeEntityModel[] = [ + { + type: EntityModelType.Action, + id: '01', + definitionId: 'def01', + connectionId: 'conn01', + options: { a: 1 }, + }, + { + type: EntityModelType.Action, + id: '02', + definitionId: 'def02', + connectionId: 'conn02', + options: { a: 2 }, + }, + { + type: EntityModelType.Action, + id: 'int0', + definitionId: 'action-with-children', + connectionId: 'internal', + options: { a: 3 }, + children: { + group1: [ + { + type: EntityModelType.Action, + id: 'int1', + definitionId: 'def01', + connectionId: 'internal', + options: { a: 4 }, + children: { + default: [ + { + type: EntityModelType.Action, + id: 'int1-b', + definitionId: 'def05', + connectionId: 'conn04', + options: { a: 5 }, + }, + ], + }, + }, + ], + group2: [ + { + type: EntityModelType.Feedback, + id: 'int2', + definitionId: 'def01', + connectionId: 'conn04', + options: { a: 5 }, + children: { + default: [ + { + type: EntityModelType.Feedback, + id: 'int2-a', + definitionId: 'def05', + connectionId: 'conn05', + options: { a: 6 }, + }, + ], + }, + }, + ], + }, + }, +] + +export function ActionTreeEntityDefinitions( + entityType: EntityModelType, + connectionId: string, + definitionId: string +): ClientEntityDefinition | undefined { + if (entityType !== EntityModelType.Action) return undefined + + if (connectionId === 'internal' && definitionId === 'action-with-children') { + return { + entityType: EntityModelType.Action, + supportsChildGroups: [ + { + type: EntityModelType.Action, + groupId: 'group1', + entityTypeLabel: 'Action', + label: 'Action', + }, + { + type: EntityModelType.Feedback, + groupId: 'group2', + entityTypeLabel: 'Feedback', + label: 'Feedback', + }, + ], + } as Partial as any + } + if (connectionId === 'internal' && definitionId === 'def01') { + return { + entityType: EntityModelType.Action, + supportsChildGroups: [ + { + type: EntityModelType.Action, + groupId: 'default', + entityTypeLabel: 'Action', + label: 'Action', + }, + ], + } as Partial as any + } + + // Fallback to a valid action + return { + entityType: EntityModelType.Action, + } as Partial as any +} + +export const FeedbackTree: SomeEntityModel[] = [ + { + type: EntityModelType.Feedback, + id: '01', + definitionId: 'def01', + connectionId: 'conn01', + options: { a: 1 }, + }, + { + type: EntityModelType.Feedback, + id: '02', + definitionId: 'def02', + connectionId: 'conn02', + options: { a: 2 }, + }, + { + type: EntityModelType.Feedback, + id: 'int0', + definitionId: 'feedback-with-children', + connectionId: 'internal', + options: { a: 3 }, + children: { + group1: [ + { + type: EntityModelType.Feedback, + id: 'int1', + definitionId: 'def01', + connectionId: 'internal', + options: { a: 4 }, + children: { + default: [ + { + type: EntityModelType.Feedback, + id: 'int1-b', + definitionId: 'def05', + connectionId: 'conn04', + options: { a: 5 }, + }, + ], + }, + }, + ], + group2: [ + { + type: EntityModelType.Action, + id: 'int2', + definitionId: 'def01', + connectionId: 'conn04', + options: { a: 5 }, + children: { + default: [ + { + type: EntityModelType.Action, + id: 'int2-a', + definitionId: 'def05', + connectionId: 'conn05', + options: { a: 6 }, + }, + ], + }, + }, + ], + }, + }, +] + +export function FeedbackTreeEntityDefinitions( + entityType: EntityModelType, + connectionId: string, + definitionId: string +): ClientEntityDefinition | undefined { + if (entityType !== EntityModelType.Feedback) return undefined + + if (connectionId === 'internal' && definitionId === 'feedback-with-children') { + return { + entityType: EntityModelType.Feedback, + feedbackType: 'advanced', + supportsChildGroups: [ + { + type: EntityModelType.Feedback, + groupId: 'group1', + entityTypeLabel: 'Feedback', + label: 'Feedback', + }, + { + type: EntityModelType.Action, + groupId: 'group2', + entityTypeLabel: 'Action', + label: 'Action', + }, + ], + } as Partial as any + } + if (connectionId === 'internal' && definitionId === 'def01') { + return { + entityType: EntityModelType.Feedback, + feedbackType: 'boolean', + supportsChildGroups: [ + { + type: EntityModelType.Feedback, + groupId: 'default', + entityTypeLabel: 'Feedback', + label: 'Feedback', + }, + ], + } as Partial as any + } + + // Fallback to a valid feedback + return { + entityType: EntityModelType.Feedback, + feedbackType: 'boolean', + } as Partial as any +} diff --git a/companion/test/Controls/Entities/EntityListPool.test.ts b/companion/test/Controls/Entities/EntityListPool.test.ts new file mode 100644 index 0000000000..f68e46bf38 --- /dev/null +++ b/companion/test/Controls/Entities/EntityListPool.test.ts @@ -0,0 +1,44 @@ +import { describe, test, expect, vi } from 'vitest' +import type { ControlEntityListPoolProps } from '../../../lib/Controls/Entities/EntityListPoolBase.js' +import { ControlEntityListPoolButton } from '../../../lib/Controls/Entities/EntityListPoolButton.js' + +describe('EntityListPool', () => { + function createMockDependencies(controlId: string): ControlEntityListPoolProps { + return { + instanceDefinitions: null as any, + internalModule: null as any, + moduleHost: null as any, + controlId, + commitChange: vi.fn(), + triggerRedraw: vi.fn(), + } + } + + test('construction', () => { + const deps = createMockDependencies('test01') + const sendRuntimeProps = vi.fn() + const pool = new ControlEntityListPoolButton(deps, sendRuntimeProps) + + expect(pool.getActiveStepIndex()).toBe(0) + expect(pool.getStepIds()).toEqual(['0']) + expect(pool.getAllEntities()).toHaveLength(0) + expect(sendRuntimeProps).toHaveBeenCalledTimes(0) + expect(deps.commitChange).toHaveBeenCalledTimes(0) + expect(deps.triggerRedraw).toHaveBeenCalledTimes(0) + }) + + test('add step from empty', () => { + const deps = createMockDependencies('test02') + const sendRuntimeProps = vi.fn() + const pool = new ControlEntityListPoolButton(deps, sendRuntimeProps) + + pool.stepAdd() + expect(pool.getActiveStepIndex()).toBe(0) + expect(pool.getStepIds()).toEqual(['0', '1']) + expect(pool.getAllEntities()).toHaveLength(0) + expect(sendRuntimeProps).toHaveBeenCalledTimes(0) + expect(deps.commitChange).toHaveBeenCalledTimes(1) + expect(deps.commitChange).toHaveBeenCalledWith(true) + expect(deps.triggerRedraw).toHaveBeenCalledTimes(0) + }) +}) diff --git a/companion/test/Service/HttpApi.test.ts b/companion/test/Service/HttpApi.test.ts index 361fd0be7b..aec08f955b 100644 --- a/companion/test/Service/HttpApi.test.ts +++ b/companion/test/Service/HttpApi.test.ts @@ -8,6 +8,7 @@ import { rgb } from '../../lib/Resources/Util' import type { Registry } from '../../lib/Registry' import type { ControlButtonNormal } from '../../lib/Controls/ControlTypes/Button/Normal' import type { UIExpress } from '../../lib/UI/Express' +import type { ControlEntityListPoolButton } from '../../lib/Controls/Entities/EntityListPoolButton' const mockOptions = { fallbackMockImplementation: () => { @@ -863,12 +864,18 @@ describe('HttpApi', () => { const { app, registry } = createService() registry.page.getControlIdAt.mockReturnValue('test') - const mockControl = mock( + const mockControlEntities = mock( { stepMakeCurrent: vi.fn(), }, mockOptions ) + const mockControl = mock( + { + actionSets: mockControlEntities, + }, + mockOptions + ) registry.controls.getControl.mockReturnValue(mockControl) // Perform the request @@ -885,22 +892,28 @@ describe('HttpApi', () => { expect(registry.controls.getControl).toHaveBeenCalledTimes(1) expect(registry.controls.getControl).toHaveBeenCalledWith('test') - expect(mockControl.stepMakeCurrent).toHaveBeenCalledTimes(1) - expect(mockControl.stepMakeCurrent).toHaveBeenCalledWith(NaN) + expect(mockControlEntities.stepMakeCurrent).toHaveBeenCalledTimes(1) + expect(mockControlEntities.stepMakeCurrent).toHaveBeenCalledWith(NaN) }) test('ok', async () => { const { app, registry } = createService() registry.page.getControlIdAt.mockReturnValue('control123') - const mockControl = mock( + const mockControlEntities = mock( { stepMakeCurrent: vi.fn(), }, mockOptions ) + const mockControl = mock( + { + actionSets: mockControlEntities, + }, + mockOptions + ) registry.controls.getControl.mockReturnValue(mockControl) - mockControl.stepMakeCurrent.mockReturnValue(true) + mockControlEntities.stepMakeCurrent.mockReturnValue(true) // Perform the request const res = await supertest(app).post('/api/location/1/2/3/step?step=2') @@ -912,8 +925,8 @@ describe('HttpApi', () => { row: 2, column: 3, }) - expect(mockControl.stepMakeCurrent).toHaveBeenCalledTimes(1) - expect(mockControl.stepMakeCurrent).toHaveBeenCalledWith(2) + expect(mockControlEntities.stepMakeCurrent).toHaveBeenCalledTimes(1) + expect(mockControlEntities.stepMakeCurrent).toHaveBeenCalledWith(2) }) test('bad page', async () => { diff --git a/companion/test/Service/OscApi.test.ts b/companion/test/Service/OscApi.test.ts index c3f24714fd..adb31f6d41 100644 --- a/companion/test/Service/OscApi.test.ts +++ b/companion/test/Service/OscApi.test.ts @@ -4,6 +4,7 @@ import { ServiceOscApi } from '../../lib/Service/OscApi' import { rgb } from '../../lib/Resources/Util' import type { Registry } from '../../lib/Registry' import type { ControlButtonNormal } from '../../lib/Controls/ControlTypes/Button/Normal' +import type { ControlEntityListPoolButton } from '../../lib/Controls/Entities/EntityListPoolButton' const mockOptions = { fallbackMockImplementation: () => { @@ -631,14 +632,20 @@ describe('OscApi', () => { const { router, registry } = createService() registry.page.getControlIdAt.mockReturnValue('control123') - const mockControl = mock( + const mockControlEntities = mock( { stepMakeCurrent: vi.fn(), }, mockOptions ) + const mockControl = mock( + { + actionSets: mockControlEntities, + }, + mockOptions + ) registry.controls.getControl.mockReturnValue(mockControl) - mockControl.stepMakeCurrent.mockReturnValue(true) + mockControlEntities.stepMakeCurrent.mockReturnValue(true) // Perform the request router.processMessage('/location/1/2/3/step', { args: [{ value: 2 }] }) @@ -649,22 +656,28 @@ describe('OscApi', () => { row: 2, column: 3, }) - expect(mockControl.stepMakeCurrent).toHaveBeenCalledTimes(1) - expect(mockControl.stepMakeCurrent).toHaveBeenCalledWith(2) + expect(mockControlEntities.stepMakeCurrent).toHaveBeenCalledTimes(1) + expect(mockControlEntities.stepMakeCurrent).toHaveBeenCalledWith(2) }) test('string step', async () => { const { router, registry } = createService() registry.page.getControlIdAt.mockReturnValue('control123') - const mockControl = mock( + const mockControlEntities = mock( { stepMakeCurrent: vi.fn(), }, mockOptions ) + const mockControl = mock( + { + actionSets: mockControlEntities, + }, + mockOptions + ) registry.controls.getControl.mockReturnValue(mockControl) - mockControl.stepMakeCurrent.mockReturnValue(true) + mockControlEntities.stepMakeCurrent.mockReturnValue(true) // Perform the request router.processMessage('/location/1/2/3/step', { args: [{ value: '4' }] }) @@ -675,8 +688,8 @@ describe('OscApi', () => { row: 2, column: 3, }) - expect(mockControl.stepMakeCurrent).toHaveBeenCalledTimes(1) - expect(mockControl.stepMakeCurrent).toHaveBeenCalledWith(4) + expect(mockControlEntities.stepMakeCurrent).toHaveBeenCalledTimes(1) + expect(mockControlEntities.stepMakeCurrent).toHaveBeenCalledWith(4) }) test('bad page', async () => { diff --git a/companion/test/Service/TcpUdpApi.test.ts b/companion/test/Service/TcpUdpApi.test.ts index 585dbca51c..9fc25469c7 100644 --- a/companion/test/Service/TcpUdpApi.test.ts +++ b/companion/test/Service/TcpUdpApi.test.ts @@ -4,6 +4,7 @@ import { ApiMessageError, ServiceTcpUdpApi } from '../../lib/Service/TcpUdpApi' import { rgb } from '../../lib/Resources/Util' import type { Registry } from '../../lib/Registry' import type { ControlButtonNormal } from '../../lib/Controls/ControlTypes/Button/Normal' +import type { ControlEntityListPoolButton } from '../../lib/Controls/Entities/EntityListPoolButton' const mockOptions = { fallbackMockImplementation: () => { @@ -538,12 +539,18 @@ describe('TcpUdpApi', () => { const { router, registry } = createService() registry.page.getControlIdAt.mockReturnValue('test') - const mockControl = mock( + const mockControlEntities = mock( { stepMakeCurrent: vi.fn(), }, mockOptions ) + const mockControl = mock( + { + actionSets: mockControlEntities, + }, + mockOptions + ) registry.controls.getControl.mockReturnValue(mockControl) // Perform the request @@ -551,21 +558,27 @@ describe('TcpUdpApi', () => { expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(0) expect(registry.controls.getControl).toHaveBeenCalledTimes(0) - expect(mockControl.stepMakeCurrent).toHaveBeenCalledTimes(0) + expect(mockControlEntities.stepMakeCurrent).toHaveBeenCalledTimes(0) }) test('ok', async () => { const { router, registry } = createService() registry.page.getControlIdAt.mockReturnValue('control123') - const mockControl = mock( + const mockControlEntities = mock( { stepMakeCurrent: vi.fn(), }, mockOptions ) + const mockControl = mock( + { + actionSets: mockControlEntities, + }, + mockOptions + ) registry.controls.getControl.mockReturnValue(mockControl) - mockControl.stepMakeCurrent.mockReturnValue(true) + mockControlEntities.stepMakeCurrent.mockReturnValue(true) // Perform the request router.processMessage('location 1/2/3 set-step 2') @@ -576,8 +589,8 @@ describe('TcpUdpApi', () => { row: 2, column: 3, }) - expect(mockControl.stepMakeCurrent).toHaveBeenCalledTimes(1) - expect(mockControl.stepMakeCurrent).toHaveBeenCalledWith(2) + expect(mockControlEntities.stepMakeCurrent).toHaveBeenCalledTimes(1) + expect(mockControlEntities.stepMakeCurrent).toHaveBeenCalledWith(2) }) test('bad page', async () => { diff --git a/companion/test/Upgrade/v1tov2-upgradeStartup.test.ts b/companion/test/Upgrade/v1tov2-upgradeStartup.test.ts index d63312639e..46211633c7 100644 --- a/companion/test/Upgrade/v1tov2-upgradeStartup.test.ts +++ b/companion/test/Upgrade/v1tov2-upgradeStartup.test.ts @@ -1,9 +1,10 @@ -import { describe, it, expect } from 'vitest' +import { describe, it, expect, beforeEach } from 'vitest' import { DataStoreBase } from '../../lib/Data/StoreBase.js' import LogController from '../../lib/Log/Controller.js' import v1tov2 from '../../lib/Data/Upgrades/v1tov2.js' import { createTables } from '../../lib/Data/Schema/v1.js' import fs from 'fs-extra' +import { SuppressLogging } from '../Util.js' function CreateDataDatabase() { const db = new DataDatabase() @@ -29,6 +30,8 @@ class DataDatabase extends DataStoreBase { } describe('upgrade', () => { + SuppressLogging() + it('empty', () => { const db = CreateDataDatabase() v1tov2.upgradeStartup(db, LogController.createLogger('test-logger')) diff --git a/companion/test/Upgrade/v1tov5-upgradeStartup.test.ts b/companion/test/Upgrade/v1tov5-upgradeStartup.test.ts index 1825f7b15a..245e1ace7b 100644 --- a/companion/test/Upgrade/v1tov5-upgradeStartup.test.ts +++ b/companion/test/Upgrade/v1tov5-upgradeStartup.test.ts @@ -7,6 +7,7 @@ import v3tov4 from '../../lib/Data/Upgrades/v3tov4.js' import v4tov5 from '../../lib/Data/Upgrades/v4tov5.js' import { createTables } from '../../lib/Data/Schema/v1.js' import fs from 'fs-extra' +import { SuppressLogging } from '../Util.js' function CreateDataDatabase() { const db = new DataDatabase() @@ -32,6 +33,8 @@ class DataDatabase extends DataStoreBase { } describe('upgrade', () => { + SuppressLogging() + const db = CreateDataDatabase() v1tov2.upgradeStartup(db, LogController.createLogger('test-logger')) v2tov3.upgradeStartup(db, LogController.createLogger('test-logger')) diff --git a/companion/test/Upgrade/v2tov3-upgradeStartup.test.ts b/companion/test/Upgrade/v2tov3-upgradeStartup.test.ts index ee08a5db6d..5116cb3c60 100644 --- a/companion/test/Upgrade/v2tov3-upgradeStartup.test.ts +++ b/companion/test/Upgrade/v2tov3-upgradeStartup.test.ts @@ -4,6 +4,7 @@ import LogController from '../../lib/Log/Controller.js' import v2tov3 from '../../lib/Data/Upgrades/v2tov3.js' import { createTables } from '../../lib/Data/Schema/v1.js' import fs from 'fs-extra' +import { SuppressLogging } from '../Util.js' let nano = 0 @@ -35,6 +36,8 @@ class DataDatabase extends DataStoreBase { } describe('upgrade', () => { + SuppressLogging() + it('empty', () => { const db = CreateDataDatabase() v2tov3.upgradeStartup(db, LogController.createLogger('test-logger')) diff --git a/companion/test/Upgrade/v3tov4-upgradeStartup.test.ts b/companion/test/Upgrade/v3tov4-upgradeStartup.test.ts index 1872667912..3febcd977d 100644 --- a/companion/test/Upgrade/v3tov4-upgradeStartup.test.ts +++ b/companion/test/Upgrade/v3tov4-upgradeStartup.test.ts @@ -4,6 +4,7 @@ import LogController from '../../lib/Log/Controller.js' import v3tov4 from '../../lib/Data/Upgrades/v3tov4.js' import { createTables } from '../../lib/Data/Schema/v1.js' import fs from 'fs-extra' +import { SuppressLogging } from '../Util.js' function CreateDataDatabase() { const db = new DataDatabase() @@ -29,6 +30,8 @@ class DataDatabase extends DataStoreBase { } describe('upgrade', () => { + SuppressLogging() + it('empty', () => { const db = CreateDataDatabase() v3tov4.upgradeStartup(db, LogController.createLogger('test-logger')) diff --git a/companion/test/Upgrade/v4tov5-upgradeStartup.test.ts b/companion/test/Upgrade/v4tov5-upgradeStartup.test.ts index a21a34abd9..eec85b3eab 100644 --- a/companion/test/Upgrade/v4tov5-upgradeStartup.test.ts +++ b/companion/test/Upgrade/v4tov5-upgradeStartup.test.ts @@ -4,6 +4,7 @@ import LogController from '../../lib/Log/Controller.js' import v4tov5 from '../../lib/Data/Upgrades/v4tov5.js' import { createTables } from '../../lib/Data/Schema/v1.js' import fs from 'fs-extra' +import { SuppressLogging } from '../Util.js' function CreateDataDatabase() { const db = new DataDatabase() @@ -29,6 +30,8 @@ class DataDatabase extends DataStoreBase { } describe('upgrade', () => { + SuppressLogging() + const db = CreateDataDatabase() let data = fs.readFileSync('./companion/test/Upgrade/v4tov5/db.v5.json', 'utf8') data = JSON.parse(data) diff --git a/companion/test/Util.ts b/companion/test/Util.ts new file mode 100644 index 0000000000..764db61942 --- /dev/null +++ b/companion/test/Util.ts @@ -0,0 +1,13 @@ +import LogController from '../lib/Log/Controller.js' +import { afterAll, beforeAll } from 'vitest' + +export function SuppressLogging() { + let originalLogLevel: string = 'silly' + beforeAll(() => { + originalLogLevel = LogController.getLogLevel() + LogController.setLogLevel('error') + }) + afterAll(() => { + LogController.setLogLevel(originalLogLevel) + }) +} diff --git a/package.json b/package.json index bb68712f64..ff79607cbf 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,8 @@ "devDependencies": { "@inquirer/prompts": "^7.2.0", "@types/ps-tree": "^1.1.6", + "@vitest/coverage-v8": "2.1.8", + "@vitest/ui": "2.1.8", "chokidar": "^3.6.0", "concurrently": "^9.1.0", "dotenv": "^16.4.7", diff --git a/shared-lib/lib/Model/ActionDefinitionModel.ts b/shared-lib/lib/Model/ActionDefinitionModel.ts deleted file mode 100644 index bcab657b27..0000000000 --- a/shared-lib/lib/Model/ActionDefinitionModel.ts +++ /dev/null @@ -1,35 +0,0 @@ -import type { InternalActionInputField } from './Options.js' -import type { ObjectsDiff } from './Common.js' - -export interface ActionDefinition { - label: string - description: string | undefined - options: InternalActionInputField[] - hasLearn: boolean - learnTimeout: number | undefined - - showButtonPreview: boolean - supportsChildActionGroups: string[] -} - -export interface ClientActionDefinition extends ActionDefinition {} - -export type ActionDefinitionUpdate = - | ActionDefinitionUpdateForgetConnection - | ActionDefinitionUpdateAddConnection - | ActionDefinitionUpdateUpdateConnection - -export interface ActionDefinitionUpdateForgetConnection { - type: 'forget-connection' - connectionId: string -} -export interface ActionDefinitionUpdateAddConnection { - type: 'add-connection' - connectionId: string - - actions: Record -} -export interface ActionDefinitionUpdateUpdateConnection extends ObjectsDiff { - type: 'update-connection' - connectionId: string -} diff --git a/shared-lib/lib/Model/ActionModel.ts b/shared-lib/lib/Model/ActionModel.ts index 1a087925b3..c6e63b6e16 100644 --- a/shared-lib/lib/Model/ActionModel.ts +++ b/shared-lib/lib/Model/ActionModel.ts @@ -1,17 +1,4 @@ -export interface ActionInstance { - id: string - instance: string - headline?: string - action: string - options: Record - disabled?: boolean - upgradeIndex?: number - - /** - * Some internal actions can have children, one or more set of them - */ - children?: Record -} +import type { SomeEntityModel } from './EntityModel.js' export interface ActionStepOptions { runWhileHeld: number[] @@ -19,21 +6,4 @@ export interface ActionStepOptions { } export type ActionSetId = 'down' | 'up' | 'rotate_left' | 'rotate_right' | number - -// TODO - type better? -export type ActionSetsModel = Record -// { -// down: ActionInstance[] -// up: ActionInstance[] -// rotate_left?: ActionInstance[] -// rotate_right?: ActionInstance[] - -// [duration: number]: ActionInstance[] | undefined -// } - -// export type ActionSetId = 'down' | 'up' | 'rotate_left' | 'rotate_right' | number - -export interface ActionOwner { - parentActionId: string - childGroup: string -} +export type ActionSetsModel = Record diff --git a/shared-lib/lib/Model/ActionRecorderModel.ts b/shared-lib/lib/Model/ActionRecorderModel.ts index 50441b4399..d19eae105e 100644 --- a/shared-lib/lib/Model/ActionRecorderModel.ts +++ b/shared-lib/lib/Model/ActionRecorderModel.ts @@ -1,9 +1,11 @@ +import { ActionEntityModel } from './EntityModel.js' + export interface RecordSessionInfo { id: string connectionIds: string[] isRunning: boolean actionDelay: number - actions: RecordActionTmp[] + actions: RecordActionEntityModel[] } export interface RecordSessionListInfo { @@ -11,11 +13,7 @@ export interface RecordSessionListInfo { } // TODO - consolidate -export interface RecordActionTmp { - id: string - instance: string - action: string +export interface RecordActionEntityModel extends ActionEntityModel { delay: number - options: Record uniquenessId: string | undefined } diff --git a/shared-lib/lib/Model/ButtonModel.ts b/shared-lib/lib/Model/ButtonModel.ts index 7cf77963c5..5185450c61 100644 --- a/shared-lib/lib/Model/ButtonModel.ts +++ b/shared-lib/lib/Model/ButtonModel.ts @@ -1,5 +1,5 @@ import type { ActionSetsModel, ActionStepOptions } from './ActionModel.js' -import type { FeedbackInstance } from './FeedbackModel.js' +import { SomeEntityModel } from './EntityModel.js' import type { ButtonStyleProperties } from './StyleModel.js' export type SomeButtonModel = PageNumberButtonModel | PageUpButtonModel | PageDownButtonModel | NormalButtonModel @@ -22,7 +22,7 @@ export interface NormalButtonModel { style: ButtonStyleProperties - feedbacks: FeedbackInstance[] + feedbacks: SomeEntityModel[] steps: NormalButtonSteps } diff --git a/shared-lib/lib/Model/EntityDefinitionModel.ts b/shared-lib/lib/Model/EntityDefinitionModel.ts new file mode 100644 index 0000000000..2d72416680 --- /dev/null +++ b/shared-lib/lib/Model/EntityDefinitionModel.ts @@ -0,0 +1,39 @@ +import type { EntityModelType, EntitySupportedChildGroupDefinition } from './EntityModel.js' +import type { InternalActionInputField, InternalFeedbackInputField } from './Options.js' +import type { CompanionButtonStyleProps } from '@companion-module/base' +import type { ObjectsDiff } from './Common.js' + +export interface ClientEntityDefinition { + entityType: EntityModelType + label: string + description: string | undefined + options: (InternalActionInputField | InternalFeedbackInputField)[] + feedbackType: 'advanced' | 'boolean' | null + feedbackStyle: Partial | undefined + hasLearn: boolean + learnTimeout: number | undefined + showInvert: boolean + + showButtonPreview: boolean + supportsChildGroups: EntitySupportedChildGroupDefinition[] +} + +export type EntityDefinitionUpdate = + | EntityDefinitionUpdateForgetConnection + | EntityDefinitionUpdateAddConnection + | EntityDefinitionUpdateUpdateConnection + +export interface EntityDefinitionUpdateForgetConnection { + type: 'forget-connection' + connectionId: string +} +export interface EntityDefinitionUpdateAddConnection { + type: 'add-connection' + connectionId: string + + entities: Record +} +export interface EntityDefinitionUpdateUpdateConnection extends ObjectsDiff { + type: 'update-connection' + connectionId: string +} diff --git a/shared-lib/lib/Model/EntityModel.ts b/shared-lib/lib/Model/EntityModel.ts new file mode 100644 index 0000000000..fb268e1a2c --- /dev/null +++ b/shared-lib/lib/Model/EntityModel.ts @@ -0,0 +1,73 @@ +import { ActionSetId } from './ActionModel.js' +import type { ButtonStyleProperties } from './StyleModel.js' + +export type SomeEntityModel = ActionEntityModel | FeedbackEntityModel +export type SomeReplaceableEntityModel = + | Pick + | Pick + +export enum EntityModelType { + Action = 'action', + Feedback = 'feedback', +} + +export interface ActionEntityModel extends EntityModelBase { + readonly type: EntityModelType.Action +} + +export interface FeedbackEntityModel extends EntityModelBase { + readonly type: EntityModelType.Feedback + + isInverted?: boolean + style?: Partial +} + +export interface EntityModelBase { + readonly type: EntityModelType + + id: string + definitionId: string + connectionId: string + headline?: string + options: Record + disabled?: boolean + upgradeIndex?: number + + /** + * Some internal entities can have children, one or more set of them + */ + children?: Record +} + +export interface EntityOwner { + parentId: string + childGroup: string +} + +export interface EntitySupportedChildGroupDefinition { + type: EntityModelType + groupId: string + /** Display type of the entity (eg condition, feedback or action) */ + entityTypeLabel: string + label: string + hint?: string + + /** Only valid for feedback entities */ + booleanFeedbacksOnly?: boolean +} + +// TODO: confirm this is sensible +export type SomeSocketEntityLocation = + // | 'trigger_events' + | 'feedbacks' + | 'trigger_actions' + | { + // button actions + stepId: string + setId: ActionSetId + } + +export function stringifySocketEntityLocation(location: SomeSocketEntityLocation): string { + if (typeof location === 'string') return location + return `${location.stepId}_${location.setId}` +} diff --git a/shared-lib/lib/Model/ExportModel.ts b/shared-lib/lib/Model/ExportModel.ts index 6d6819b7df..1b446425be 100644 --- a/shared-lib/lib/Model/ExportModel.ts +++ b/shared-lib/lib/Model/ExportModel.ts @@ -5,7 +5,7 @@ import type { CustomVariablesModel } from './CustomVariableModel.js' export type SomeExportv6 = ExportFullv6 | ExportPageModelv6 | ExportTriggersListv6 export interface ExportBase { - readonly version: 6 + readonly version: 6 | 7 readonly type: Type } diff --git a/shared-lib/lib/Model/FeedbackDefinitionModel.ts b/shared-lib/lib/Model/FeedbackDefinitionModel.ts deleted file mode 100644 index 6cdb43b80a..0000000000 --- a/shared-lib/lib/Model/FeedbackDefinitionModel.ts +++ /dev/null @@ -1,39 +0,0 @@ -import type { CompanionButtonStyleProps } from '@companion-module/base' -import type { ObjectsDiff } from './Common.js' -import type { InternalFeedbackInputField } from './Options.js' - -export interface FeedbackDefinition { - label: string - description: string | undefined - options: InternalFeedbackInputField[] - type: 'advanced' | 'boolean' - style: Partial | undefined - hasLearn: boolean - learnTimeout: number | undefined - showInvert: boolean - - showButtonPreview: boolean - supportsChildFeedbacks: boolean -} - -export type ClientFeedbackDefinition = FeedbackDefinition - -export type FeedbackDefinitionUpdate = - | FeedbackDefinitionUpdateForgetConnection - | FeedbackDefinitionUpdateAddConnection - | FeedbackDefinitionUpdateUpdateConnection - -export interface FeedbackDefinitionUpdateForgetConnection { - type: 'forget-connection' - connectionId: string -} -export interface FeedbackDefinitionUpdateAddConnection { - type: 'add-connection' - connectionId: string - - feedbacks: Record -} -export interface FeedbackDefinitionUpdateUpdateConnection extends ObjectsDiff { - type: 'update-connection' - connectionId: string -} diff --git a/shared-lib/lib/Model/FeedbackModel.ts b/shared-lib/lib/Model/FeedbackModel.ts deleted file mode 100644 index b7db7a549d..0000000000 --- a/shared-lib/lib/Model/FeedbackModel.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type { ButtonStyleProperties } from './StyleModel.js' - -export interface FeedbackInstance { - id: string - instance_id: string - headline?: string - type: string - options: Record - disabled?: boolean - upgradeIndex?: number - isInverted?: boolean - style?: Partial - - children?: FeedbackInstance[] -} diff --git a/shared-lib/lib/Model/TriggerModel.ts b/shared-lib/lib/Model/TriggerModel.ts index 55291bf5e9..ead1f107db 100644 --- a/shared-lib/lib/Model/TriggerModel.ts +++ b/shared-lib/lib/Model/TriggerModel.ts @@ -1,14 +1,13 @@ import type { Operation as JsonPatchOperation } from 'fast-json-patch' -import type { ActionSetsModel } from './ActionModel.js' import type { EventInstance } from './EventModel.js' -import type { FeedbackInstance } from './FeedbackModel.js' +import type { SomeEntityModel } from './EntityModel.js' export interface TriggerModel { readonly type: 'trigger' options: TriggerOptions - action_sets: ActionSetsModel - condition: FeedbackInstance[] + actions: SomeEntityModel[] + condition: SomeEntityModel[] events: EventInstance[] } diff --git a/shared-lib/lib/SocketIO.ts b/shared-lib/lib/SocketIO.ts index 057a674d0b..481dfbc713 100644 --- a/shared-lib/lib/SocketIO.ts +++ b/shared-lib/lib/SocketIO.ts @@ -35,16 +35,16 @@ import type { import type { ClientPagesInfo, PageModelChanges } from './Model/PageModel.js' import type { ClientTriggerData, TriggersUpdate } from './Model/TriggerModel.js' import type { CustomVariableUpdate, CustomVariablesModel } from './Model/CustomVariableModel.js' -import type { FeedbackDefinitionUpdate, ClientFeedbackDefinition } from './Model/FeedbackDefinitionModel.js' import type { AllVariableDefinitions, VariableDefinitionUpdate } from './Model/Variables.js' import type { CompanionVariableValues } from '@companion-module/base' import type { UIPresetDefinition } from './Model/Presets.js' import type { RecordSessionInfo, RecordSessionListInfo } from './Model/ActionRecorderModel.js' -import type { ActionDefinitionUpdate, ClientActionDefinition } from './Model/ActionDefinitionModel.js' import type { CloudControllerState, CloudRegionState } from './Model/Cloud.js' import type { ModuleInfoUpdate, ModuleDisplayInfo } from './Model/ModuleInfo.js' import type { ClientConnectionsUpdate, ClientConnectionConfig } from './Model/Connections.js' -import type { ActionOwner, ActionSetId } from './Model/ActionModel.js' +import type { ActionSetId } from './Model/ActionModel.js' +import type { EntityModelType, EntityOwner, SomeSocketEntityLocation } from './Model/EntityModel.js' +import { ClientEntityDefinition, EntityDefinitionUpdate } from './Model/EntityDefinitionModel.js' export interface ClientToBackendEventsMap { disconnect: () => never // Hack because type is missing @@ -85,12 +85,9 @@ export interface ClientToBackendEventsMap { 'modules:unsubscribe': () => void 'connections:subscribe': () => Record 'connections:unsubscribe': () => void - 'action-definitions:subscribe': () => Record | undefined> + 'action-definitions:subscribe': () => Record | undefined> 'action-definitions:unsubscribe': () => void - 'feedback-definitions:subscribe': () => Record< - string, - Record | undefined - > + 'feedback-definitions:subscribe': () => Record | undefined> 'feedback-definitions:unsubscribe': () => void 'variable-definitions:subscribe': () => AllVariableDefinitions 'variable-definitions:unsubscribe': () => void @@ -108,83 +105,68 @@ export interface ClientToBackendEventsMap { 'controls:swap': (from: ControlLocation, to: ControlLocation) => boolean 'controls:reset': (location: ControlLocation, newType?: string) => void - 'controls:feedback:set-headline': (controlId: string, feedbackId: string, headline: string) => boolean - 'controls:feedback:enabled': (controlId: string, feedbackId: string, enabled: boolean) => boolean - 'controls:feedback:set-style-selection': (controlId: string, feedbackId: string, selected: string[]) => boolean - 'controls:feedback:set-style-value': (controlId: string, feedbackId: string, key: string, value: any) => boolean - 'controls:feedback:learn': (controlId: string, feedbackId: string) => boolean - 'controls:feedback:duplicate': (controlId: string, feedbackId: string) => boolean - 'controls:feedback:remove': (controlId: string, feedbackId: string) => boolean - 'controls:feedback:set-connection': (controlId: string, feedbackId: string, connectionId: string | number) => boolean - 'controls:feedback:set-inverted': (controlId: string, feedbackId: string, isInverted: boolean) => boolean - 'controls:feedback:set-option': (controlId: string, feedbackId: string, key: string, val: any) => boolean - 'controls:feedback:move': ( + 'controls:entity:set-headline': ( controlId: string, - dragFeedbackId: string, - hoverParentId: string | null, - hoverIndex: number + entityLocation: SomeSocketEntityLocation, + id: string, + headline: string ) => boolean - 'controls:feedback:add': ( + 'controls:entity:enabled': ( controlId: string, - parentId: string | null, - connectionId: string, - feedbackType: string + entityLocation: SomeSocketEntityLocation, + id: string, + enabled: boolean ) => boolean - - 'controls:action:set-headline': ( + 'controls:entity:set-style-selection': ( controlId: string, - stepId: string, - setId: ActionSetId, - actionId: string, - headline: string + entityLocation: SomeSocketEntityLocation, + id: string, + selected: string[] ) => boolean - 'controls:action:enabled': ( + 'controls:entity:set-style-value': ( controlId: string, - stepId: string, - setId: ActionSetId, - actionId: string, - enabled: boolean + entityLocation: SomeSocketEntityLocation, + id: string, + key: string, + value: any ) => boolean - 'controls:action:learn': (controlId: string, stepId: string, setId: ActionSetId, actionId: string) => boolean - 'controls:action:duplicate': ( + 'controls:entity:learn': (controlId: string, entityLocation: SomeSocketEntityLocation, id: string) => boolean + 'controls:entity:duplicate': (controlId: string, entityLocation: SomeSocketEntityLocation, id: string) => boolean + 'controls:entity:remove': (controlId: string, entityLocation: SomeSocketEntityLocation, id: string) => boolean + 'controls:entity:set-connection': ( controlId: string, - stepId: string, - setId: ActionSetId, - actionId: string - ) => string | null - 'controls:action:remove': (controlId: string, stepId: string, setId: ActionSetId, actionId: string) => boolean - 'controls:action:set-connection': ( + entityLocation: SomeSocketEntityLocation, + id: string, + connectionId: string | number + ) => boolean + 'controls:entity:set-inverted': ( controlId: string, - stepId: string, - setId: ActionSetId, - actionId: string, - connectionId: string + entityLocation: SomeSocketEntityLocation, + id: string, + isInverted: boolean ) => boolean - 'controls:action:set-option': ( + 'controls:entity:set-option': ( controlId: string, - stepId: string, - setId: ActionSetId, - actionId: string, + entityLocation: SomeSocketEntityLocation, + id: string, key: string, val: any ) => boolean - 'controls:action:move': ( + 'controls:entity:move': ( controlId: string, - dragStepId: string, - dragSetId: ActionSetId, - dragActionId: string, - hoverStepId: string, - hoverSetId: ActionSetId, - hoverOwnerId: ActionOwner | null, + dragEntityLocation: SomeSocketEntityLocation, + dragEntityId: string, + hoverOwnerId: EntityOwner | null, + hoverEntityLocation: SomeSocketEntityLocation, hoverIndex: number ) => boolean - 'controls:action:add': ( + 'controls:entity:add': ( controlId: string, - stepId: string, - setId: ActionSetId, - ownerId: ActionOwner | null, + entityLocation: SomeSocketEntityLocation, + ownerId: EntityOwner | null, connectionId: string, - actionType: string + entityTypeLabel: EntityModelType, + entityDefinition: string ) => boolean 'controls:action-set:set-run-while-held': ( @@ -374,8 +356,8 @@ export interface BackendToClientEventsMap { 'surfaces:update': (patch: SurfacesUpdate[]) => void 'surfaces:outbound:update': (patch: OutboundSurfacesUpdate[]) => void 'triggers:update': (change: TriggersUpdate) => void - 'action-definitions:update': (change: ActionDefinitionUpdate) => void - 'feedback-definitions:update': (change: FeedbackDefinitionUpdate) => void + 'action-definitions:update': (change: EntityDefinitionUpdate) => void + 'feedback-definitions:update': (change: EntityDefinitionUpdate) => void 'custom-variables:update': (changes: CustomVariableUpdate[]) => void 'variable-definitions:update': (label: string, changes: VariableDefinitionUpdate | null) => void 'presets:update': (id: string, patch: JsonPatchOperation[] | Record | null) => void diff --git a/vitest.config.ts b/vitest.config.ts index 7c095dec4c..009452a330 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -2,6 +2,26 @@ import { defineConfig } from 'vitest/config' export default defineConfig({ test: { - exclude: ['**/module-local-dev/**', '**/node_modules/**', '**/dist/**', '**/build/**', '**/coverage/**'], + reporters: ['default', 'html'], + exclude: [ + '**/module-local-dev/**', + '**/bundled-modules/**', + '**/node_modules/**', + '**/dist/**', + '**/build/**', + '**/coverage/**', + ], + coverage: { + reporter: ['text', 'json', 'html'], + include: ['companion/**', 'shared-lib/**'], + exclude: [ + '**/module-local-dev/**', + '**/bundled-modules/**', + '**/node_modules/**', + '**/dist/**', + '**/build/**', + '**/coverage/**', + ], + }, }, }) diff --git a/webui/src/Buttons/ActionRecorder/RecorderSessionHeading.tsx b/webui/src/Buttons/ActionRecorder/RecorderSessionHeading.tsx index 7f5317379f..302ef8bd85 100644 --- a/webui/src/Buttons/ActionRecorder/RecorderSessionHeading.tsx +++ b/webui/src/Buttons/ActionRecorder/RecorderSessionHeading.tsx @@ -1,15 +1,16 @@ -import React, { useCallback, useContext, ChangeEvent, RefObject } from 'react' +import React, { useCallback, useContext, ChangeEvent, RefObject, useMemo } from 'react' import { LoadingRetryOrError, PreventDefaultHandler, useComputed } from '../../util.js' import { CButton, CButtonGroup, CCol, CRow, CForm, CFormLabel, CFormSwitch, CCallout } from '@coreui/react' import { DropdownInputField } from '../../Components/index.js' -import { ActionsList } from '../../Controls/ActionSetEditor.js' -import { usePanelCollapseHelper } from '../../Helpers/CollapseHelper.js' +import { PanelCollapseHelperProvider } from '../../Helpers/CollapseHelper.js' import type { DropdownChoice, DropdownChoiceId } from '@companion-module/base' import type { RecordSessionInfo } from '@companion-app/shared/Model/ActionRecorderModel.js' import { useActionRecorderActionService } from '../../Services/Controls/ControlActionsService.js' import { GenericConfirmModalRef } from '../../Components/GenericConfirmModal.js' import { observer } from 'mobx-react-lite' import { RootAppStoreContext } from '../../Stores/RootAppStore.js' +import { MinimalEntityList } from '../../Controls/Components/EntityList.js' +import { EntityModelType } from '@companion-app/shared/Model/EntityModel.js' interface RecorderSessionHeadingProps { confirmRef: RefObject @@ -140,24 +141,25 @@ interface RecorderSessionProps { export const RecorderSession = observer(function RecorderSession({ sessionId, sessionInfo }: RecorderSessionProps) { const actionsService = useActionRecorderActionService(sessionId) - const panelCollapseHelper = usePanelCollapseHelper('action_recorder', sessionInfo?.actions?.map((a) => a.id) ?? []) + const actionIds = useMemo(() => sessionInfo?.actions?.map((a) => a.id) ?? [], [sessionInfo?.actions]) if (!sessionInfo || !sessionInfo.actions) return return ( - + + + {sessionInfo.actions.length === 0 ? No actions have been recorded : ''} ) diff --git a/webui/src/Buttons/EditButton.tsx b/webui/src/Buttons/EditButton.tsx index 47f1a23a45..48092b7b98 100644 --- a/webui/src/Buttons/EditButton.tsx +++ b/webui/src/Buttons/EditButton.tsx @@ -45,26 +45,20 @@ import { nanoid } from 'nanoid' import { ButtonPreviewBase } from '../Components/ButtonPreview.js' import { GenericConfirmModal, GenericConfirmModalRef } from '../Components/GenericConfirmModal.js' import { KeyReceiver, LoadingRetryOrError, SocketContext, MyErrorBoundary } from '../util.js' -import { ControlActionSetEditor } from '../Controls/ActionSetEditor.js' +import { ControlEntitiesEditor } from '../Controls/EntitiesEditor.js' import jsonPatch from 'fast-json-patch' import { ButtonStyleConfig } from '../Controls/ButtonStyleConfig.js' import { ControlOptionsEditor } from '../Controls/ControlOptionsEditor.js' -import { ControlFeedbacksEditor } from '../Controls/FeedbackEditor.js' import { cloneDeep } from 'lodash-es' import { GetStepIds } from '@companion-app/shared/Controls.js' import { formatLocation } from '@companion-app/shared/ControlId.js' import { ControlLocation } from '@companion-app/shared/Model/Common.js' -import { - ActionInstance, - ActionSetId, - ActionSetsModel, - ActionStepOptions, -} from '@companion-app/shared/Model/ActionModel.js' -import { FeedbackInstance } from '@companion-app/shared/Model/FeedbackModel.js' +import { ActionSetId, ActionSetsModel, ActionStepOptions } from '@companion-app/shared/Model/ActionModel.js' import { NormalButtonSteps, SomeButtonModel } from '@companion-app/shared/Model/ButtonModel.js' import { RootAppStoreContext } from '../Stores/RootAppStore.js' import { observer } from 'mobx-react-lite' import { CModalExt } from '../Components/CModalExt.js' +import { EntityModelType, SomeEntityModel } from '@companion-app/shared/Model/EntityModel.js' interface EditButtonProps { location: ControlLocation @@ -376,7 +370,7 @@ interface TabsSectionProps { steps: NormalButtonSteps runtimeProps: Record rotaryActions: boolean - feedbacks: FeedbackInstance[] + feedbacks: SomeEntityModel[] } function TabsSection({ style, controlId, location, steps, runtimeProps, rotaryActions, feedbacks }: TabsSectionProps) { @@ -550,17 +544,21 @@ function TabsSection({ style, controlId, location, steps, runtimeProps, rotaryAc >

{selectedStep === 'feedbacks' && ( - - - +
+ {/* Wrap the entity-category, for :first-child to work */} + + + +
)} {selectedKey && selectedStep && ( @@ -617,31 +615,35 @@ function TabsSection({ style, controlId, location, steps, runtimeProps, rotaryAc
- {/* Wrap the action-category, for :first-child to work */} + {/* Wrap the entity-category, for :first-child to work */} {rotaryActions && selectedStep2 && ( <> - - @@ -650,14 +652,16 @@ function TabsSection({ style, controlId, location, steps, runtimeProps, rotaryAc {selectedStep2 && (
- @@ -820,7 +824,7 @@ function EditActionsRelease({ ) const candidate_sets = Object.entries(action_sets) - .map((o): [number, ActionInstance[] | undefined] => [Number(o[0]), o[1]]) + .map((o): [number, SomeEntityModel[] | undefined] => [Number(o[0]), o[1]]) .filter(([id]) => !isNaN(id)) candidate_sets.sort((a, b) => a[0] - b[0]) @@ -829,7 +833,7 @@ function EditActionsRelease({ const ident = runWhileHeld ? `Held for ${id}ms` : `Release after ${id}ms` return ( - ) @@ -856,14 +862,16 @@ function EditActionsRelease({ - diff --git a/webui/src/Buttons/Pages.tsx b/webui/src/Buttons/Pages.tsx index 3406e52454..d2545159dd 100644 --- a/webui/src/Buttons/Pages.tsx +++ b/webui/src/Buttons/Pages.tsx @@ -212,7 +212,7 @@ const PageListRow = observer(function PageListRow({ preview(drop(ref)) return ( - + diff --git a/webui/src/ContextData.tsx b/webui/src/ContextData.tsx index 82f8a292c2..2a6705360b 100644 --- a/webui/src/ContextData.tsx +++ b/webui/src/ContextData.tsx @@ -6,12 +6,10 @@ import { usePagesInfoSubscription } from './Hooks/usePagesInfoSubscription.js' import { useActionDefinitionsSubscription } from './Hooks/useActionDefinitionsSubscription.js' import { useActiveLearnRequests } from './_Model/ActiveLearn.js' import { RootAppStore, RootAppStoreContext } from './Stores/RootAppStore.js' -import { RecentlyUsedIdsStore } from './Stores/RecentlyUsedIdsStore.js' import { observable } from 'mobx' import { PagesStore } from './Stores/PagesStore.js' import { EventDefinitionsStore } from './Stores/EventDefinitionsStore.js' -import { ActionDefinitionsStore } from './Stores/ActionDefinitionsStore.js' -import { FeedbackDefinitionsStore } from './Stores/FeedbackDefinitionsStore.js' +import { EntityDefinitionsStore } from './Stores/EntityDefinitionsStore.js' import { useFeedbackDefinitionsSubscription } from './Hooks/useFeedbackDefinitionsSubscription.js' import { ModuleInfoStore } from './Stores/ModuleInfoStore.js' import { useModuleInfoSubscription } from './Hooks/useModuleInfoSubscription.js' @@ -46,12 +44,8 @@ export function ContextData({ children }: Readonly) { activeLearns: observable.set(), - recentlyAddedActions: new RecentlyUsedIdsStore('recent_actions', 20), - recentlyAddedFeedbacks: new RecentlyUsedIdsStore('recent_feedbacks', 20), - - actionDefinitions: new ActionDefinitionsStore(), + entityDefinitions: new EntityDefinitionsStore(), eventDefinitions: new EventDefinitionsStore(), - feedbackDefinitions: new FeedbackDefinitionsStore(), pages: new PagesStore(), surfaces: new SurfacesStore(), @@ -65,8 +59,8 @@ export function ContextData({ children }: Readonly) { const [loadedEventDefinitions, setLoadedEventDefinitions] = useState(false) - const actionDefinitionsReady = useActionDefinitionsSubscription(socket, rootStore.actionDefinitions) - const feedbackDefinitionsReady = useFeedbackDefinitionsSubscription(socket, rootStore.feedbackDefinitions) + const actionDefinitionsReady = useActionDefinitionsSubscription(socket, rootStore.entityDefinitions.actions) + const feedbackDefinitionsReady = useFeedbackDefinitionsSubscription(socket, rootStore.entityDefinitions.feedbacks) const moduleInfoReady = useModuleInfoSubscription(socket, rootStore.modules) const connectionsReady = useConnectionsConfigSubscription(socket, rootStore.connections) const triggersListReady = useTriggersListSubscription(socket, rootStore.triggersList) diff --git a/webui/src/Controls/ActionSetEditor.tsx b/webui/src/Controls/ActionSetEditor.tsx deleted file mode 100644 index 827433f30f..0000000000 --- a/webui/src/Controls/ActionSetEditor.tsx +++ /dev/null @@ -1,591 +0,0 @@ -import { CButton, CForm, CButtonGroup, CFormSwitch } from '@coreui/react' -import { - faSort, - faTrash, - faExpandArrowsAlt, - faCompressArrowsAlt, - faCopy, - faFolderOpen, - faPencil, -} from '@fortawesome/free-solid-svg-icons' -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' -import React, { memo, useCallback, useContext, useDeferredValue, useMemo, useRef, useState } from 'react' -import { DropdownInputField, TextInputField } from '../Components/index.js' -import { DragState, MyErrorBoundary, PreventDefaultHandler, checkDragState } from '../util.js' -import { OptionsInputField } from './OptionsInputField.js' -import { useDrag, useDrop } from 'react-dnd' -import { GenericConfirmModal, GenericConfirmModalRef } from '../Components/GenericConfirmModal.js' -import { AddActionsModal, AddActionsModalRef } from './AddModal.js' -import { PanelCollapseHelper, usePanelCollapseHelper } from '../Helpers/CollapseHelper.js' -import { OptionButtonPreview } from './OptionButtonPreview.js' -import { ActionInstance, ActionSetId } from '@companion-app/shared/Model/ActionModel.js' -import { ControlLocation } from '@companion-app/shared/Model/Common.js' -import { useOptionsAndIsVisible } from '../Hooks/useOptionsAndIsVisible.js' -import { LearnButton } from '../Components/LearnButton.js' -import { AddActionDropdown } from './AddActionDropdown.js' -import { - IActionEditorService, - useControlActionService, - useControlActionsEditorService, -} from '../Services/Controls/ControlActionsService.js' -import { RootAppStoreContext } from '../Stores/RootAppStore.js' -import { observer } from 'mobx-react-lite' -import classNames from 'classnames' - -function findAllActionIdsDeep(actions: ActionInstance[]): string[] { - const result: string[] = actions.map((f) => f.id) - - for (const action of actions) { - if (!action.children) continue - for (const actionGroup of Object.values(action.children)) { - if (!actionGroup) continue - result.push(...findAllActionIdsDeep(actionGroup)) - } - } - - return result -} - -interface ControlActionSetEditorProps { - controlId: string - location: ControlLocation | undefined - stepId: string - setId: ActionSetId - actions: ActionInstance[] | undefined - addPlaceholder: string - heading: JSX.Element | string - headingActions?: JSX.Element[] -} - -export const ControlActionSetEditor = observer(function ControlActionSetEditor({ - controlId, - location, - stepId, - setId, - actions, - addPlaceholder, - heading, - headingActions, -}: ControlActionSetEditorProps) { - const confirmModal = useRef(null) - - const actionsService = useControlActionsEditorService(controlId, stepId, setId, confirmModal) - - const actionIds = useMemo(() => findAllActionIdsDeep(actions ?? []), [actions]) - const panelCollapseHelper = usePanelCollapseHelper(`actions_${controlId}_${stepId}_${setId}`, actionIds) - - return ( -
- - - -
- ) -}) - -interface InlineActionListProps { - controlId: string - heading: JSX.Element | string | null - headingActions?: JSX.Element[] - actions: ActionInstance[] | undefined - location: ControlLocation | undefined - stepId: string - setId: ActionSetId - addPlaceholder: string - actionsService: IActionEditorService - parentId: string | null - panelCollapseHelper: PanelCollapseHelper -} -const InlineActionList = observer(function InlineActionList({ - controlId, - heading, - headingActions, - actions, - location, - stepId, - setId, - addPlaceholder, - actionsService, - parentId, - panelCollapseHelper, -}: InlineActionListProps) { - const addAction = useCallback( - (actionType: string) => actionsService.addAction(actionType, parentId), - [actionsService, parentId] - ) - - const childActionIds = actions?.map((f) => f.id) ?? [] - - return ( - <> -
- {heading} - - - {actions && actions.length >= 1 && panelCollapseHelper.canExpandAll(parentId, childActionIds) && ( - panelCollapseHelper.setAllExpanded(parentId, childActionIds)} - title="Expand all" - > - - - )} - {actions && actions.length >= 1 && panelCollapseHelper.canCollapseAll(parentId, childActionIds) && ( - panelCollapseHelper.setAllCollapsed(parentId, childActionIds)} - title="Collapse all" - > - - - )} - {headingActions || ''} - -
- - - - - ) -}) - -interface AddActionsPanelProps { - addPlaceholder: string - addAction: (actionType: string) => void -} - -const AddActionsPanel = memo(function AddActionsPanel({ addPlaceholder, addAction }: AddActionsPanelProps) { - const addActionsRef = useRef(null) - const showAddModal = useCallback(() => { - addActionsRef.current?.show() - }, []) - - return ( -
- - - - - - - - -
- ) -}) - -interface ActionsListProps { - location: ControlLocation | undefined - controlId: string - parentId: string | null - dragId: string - stepId: string - setId: ActionSetId - actions: ActionInstance[] | undefined - actionsService: IActionEditorService - readonly?: boolean - panelCollapseHelper: PanelCollapseHelper -} - -export function ActionsList({ - location, - controlId, - parentId, - dragId, - stepId, - setId, - actions, - actionsService, - readonly, - panelCollapseHelper, -}: ActionsListProps) { - return ( - - - {actions && - actions.map((a, i) => ( - - - - ))} - - - -
- ) -} - -interface ActionRowDropPlaceholderProps { - setId: ActionSetId - parentId: string | null - dragId: string - actionCount: number - moveCard: (stepId: string, setId: ActionSetId, actionId: string, parentId: string | null, targetIndex: number) => void -} - -function ActionRowDropPlaceholder({ setId, parentId, dragId, actionCount, moveCard }: ActionRowDropPlaceholderProps) { - const [isDragging, drop] = useDrop({ - accept: dragId, - collect: (monitor) => { - return monitor.canDrop() - }, - hover(item, _monitor) { - moveCard(item.stepId, item.setId, item.actionId, parentId, 0) - - item.setId = setId - item.index = 0 - }, - }) - - // Defer the isDragging value to ensure dragend doesn't fire prematurely - // See https://github.com/bitfocus/companion/issues/3115 - // https://bugs.webkit.org/show_bug.cgi?id=134212 - // https://issues.chromium.org/issues/41150279 - const isDraggingDeferred = useDeferredValue(isDragging) - - if (!isDraggingDeferred || actionCount > 0) return null - - return ( - - -

Drop action here

- - - ) -} - -interface ActionTableRowDragItem { - actionId: string - stepId: string - setId: ActionSetId - index: number - parentId: string | null - dragState: DragState | null -} -interface ActionTableRowDragStatus { - isDragging: boolean -} - -interface ActionTableRowProps { - action: ActionInstance - controlId: string - parentId: string | null - stepId: string - setId: ActionSetId - location: ControlLocation | undefined - index: number - dragId: string - serviceFactory: IActionEditorService - - readonly: boolean - panelCollapseHelper: PanelCollapseHelper -} - -const ActionTableRow = observer(function ActionTableRow({ - action, - controlId, - parentId, - stepId, - setId, - location, - index, - dragId, - serviceFactory, - readonly, - panelCollapseHelper, -}: ActionTableRowProps): JSX.Element | null { - const { actionDefinitions, connections } = useContext(RootAppStoreContext) - - const service = useControlActionService(serviceFactory, action) - - const innerSetEnabled = useCallback( - (e: React.ChangeEvent) => service.setEnabled && service.setEnabled(e.target.checked), - [service.setEnabled] - ) - - const actionSpec = actionDefinitions.connections.get(action.instance)?.get(action.action) - - const [actionOptions, optionVisibility] = useOptionsAndIsVisible(actionSpec?.options, action?.options) - - const ref = useRef(null) - const [, drop] = useDrop({ - accept: dragId, - hover(item, monitor) { - if (!ref.current) { - return - } - - // Ensure the hover targets this element, and not a child element - if (!monitor.isOver({ shallow: true })) return - - const dragParentId = item.parentId - const dragIndex = item.index - - const hoverParentId = parentId - const hoverIndex = index - const hoverId = action.id - - if (!checkDragState(item, monitor, hoverId)) return - - // Don't replace items with themselves - if ( - item.actionId === hoverId || - (dragIndex === hoverIndex && dragParentId === hoverParentId && item.setId === setId && item.stepId === stepId) - ) { - return - } - - // Time to actually perform the action - serviceFactory.moveCard(item.stepId, item.setId, item.actionId, hoverParentId, index) - - // Note: we're mutating the monitor item here! - // Generally it's better to avoid mutations, - // but it's good here for the sake of performance - // to avoid expensive index searches. - item.index = hoverIndex - item.setId = setId - item.parentId = hoverParentId - }, - drop(item, _monitor) { - item.dragState = null - }, - }) - const [{ isDragging }, drag, preview] = useDrag({ - type: dragId, - canDrag: !readonly, - item: { - actionId: action.id, - stepId: stepId, - setId: setId, - index: index, - parentId: parentId, - // ref: ref, - dragState: null, - }, - collect: (monitor) => ({ - isDragging: monitor.isDragging(), - }), - }) - preview(drop(ref)) - - const doCollapse = useCallback( - () => panelCollapseHelper.setPanelCollapsed(action.id, true), - [panelCollapseHelper, action.id] - ) - const doExpand = useCallback( - () => panelCollapseHelper.setPanelCollapsed(action.id, false), - [panelCollapseHelper, action.id] - ) - const isCollapsed = panelCollapseHelper.isPanelCollapsed(parentId, action.id) - - const canSetHeadline = !!service.setHeadline - const headline = action.headline - const [headlineExpanded, setHeadlineExpanded] = useState(canSetHeadline && !!headline) - const doEditHeadline = useCallback(() => setHeadlineExpanded(true), []) - - if (!action) { - // Invalid action, so skip - return null - } - - const connectionInfo = connections.getInfo(action.instance) - // const module = instance ? modules[instance.instance_type] : undefined - const connectionLabel = connectionInfo?.label ?? action.instance - const connectionsWithSameType = connectionInfo ? connections.getAllOfType(connectionInfo.instance_type) : [] - - const showButtonPreview = action?.instance === 'internal' && actionSpec?.showButtonPreview - - const name = actionSpec - ? `${connectionLabel}: ${actionSpec.label}` - : `${connectionLabel}: ${action.action} (undefined)` - - return ( - - - - - -
-
- {!service.setHeadline || !headlineExpanded || isCollapsed ? ( - headline || name - ) : ( - - )} -
- -
- - {canSetHeadline && !headlineExpanded && !isCollapsed && ( - - - - )} - {isCollapsed ? ( - - - - ) : ( - - - - )} - - - - - - - {!!service.setEnabled && ( - <> -   - - - )} - -
-
- - {!isCollapsed && ( -
-
- {headlineExpanded &&

{name}

} -
{actionSpec?.description}
-
- - {showButtonPreview && ( -
- -
- )} - -
- {connectionsWithSameType.length > 1 && ( -
- connectionA[1].sortOrder - connectionB[1].sortOrder) - .map((connection) => { - const [id, info] = connection - return { id, label: info.label } - })} - multiple={false} - value={action.instance} - setValue={service.setConnection} - /> -
- )} -
- -
- {actionSpec?.hasLearn && service.performLearn && ( - - )} -
- -
- - {actionOptions.map((opt, i) => ( - - - - ))} - -
- - {action.instance === 'internal' && actionSpec?.supportsChildActionGroups.includes('default') && ( -
0 && (action.children ?? []).length > 0, - })} - > - - - -
- )} -
- )} - - - ) -}) diff --git a/webui/src/Controls/AddActionDropdown.tsx b/webui/src/Controls/AddActionDropdown.tsx deleted file mode 100644 index 8ba2eb8195..0000000000 --- a/webui/src/Controls/AddActionDropdown.tsx +++ /dev/null @@ -1,113 +0,0 @@ -import React, { useCallback, useContext } from 'react' -import { useComputed } from '../util.js' -import Select, { createFilter } from 'react-select' -import { MenuPortalContext } from '../Components/DropdownInputField.js' -import { observer } from 'mobx-react-lite' -import { RootAppStoreContext } from '../Stores/RootAppStore.js' -import { prepare as fuzzyPrepare, single as fuzzySingle } from 'fuzzysort' - -const filterOptions: ReturnType> = (candidate, input): boolean => { - if (input) { - return !candidate.data.isRecent && (fuzzySingle(input, candidate.data.fuzzy)?.score ?? 0) >= 0.5 - } else { - return candidate.data.isRecent - } -} -const noOptionsMessage = ({ inputValue }: { inputValue: string }) => { - if (inputValue) { - return 'No actions found' - } else { - return 'No recently used actions' - } -} -interface AddActionOption { - isRecent: boolean - value: string - label: string - fuzzy: ReturnType -} -interface AddActionGroup { - label: string - options: AddActionOption[] -} -interface AddActionDropdownProps { - onSelect: (actionType: string) => void - placeholder: string -} -export const AddActionDropdown = observer(function AddActionDropdown({ - onSelect, - placeholder, -}: AddActionDropdownProps) { - const { actionDefinitions, connections, recentlyAddedActions } = useContext(RootAppStoreContext) - const menuPortal = useContext(MenuPortalContext) - - const options = useComputed(() => { - const options: Array = [] - for (const [connectionId, connectionActions] of actionDefinitions.connections.entries()) { - for (const [actionId, action] of connectionActions.entries()) { - const connectionLabel = connections.getLabel(connectionId) ?? connectionId - const optionLabel = `${connectionLabel}: ${action.label}` - options.push({ - isRecent: false, - value: `${connectionId}:${actionId}`, - label: optionLabel, - fuzzy: fuzzyPrepare(optionLabel), - }) - } - } - - const recents: AddActionOption[] = [] - for (const actionType of recentlyAddedActions.recentIds) { - if (actionType) { - const [connectionId, actionId] = actionType.split(':', 2) - const actionInfo = actionDefinitions.connections.get(connectionId)?.get(actionId) - if (actionInfo) { - const connectionLabel = connections.getLabel(connectionId) ?? connectionId - const optionLabel = `${connectionLabel}: ${actionInfo.label}` - recents.push({ - isRecent: true, - value: `${connectionId}:${actionId}`, - label: optionLabel, - fuzzy: fuzzyPrepare(optionLabel), - }) - } - } - } - options.push({ - label: 'Recently Used', - options: recents, - }) - - return options - }, [actionDefinitions, connections, recentlyAddedActions.recentIds]) - - const innerChange = useCallback( - (e: AddActionOption | null) => { - if (e?.value) { - recentlyAddedActions.trackId(e.value) - - onSelect(e.value) - } - }, - [onSelect, recentlyAddedActions] - ) - - return ( - - ) -}) diff --git a/webui/src/Controls/AddModal.tsx b/webui/src/Controls/AddModal.tsx deleted file mode 100644 index 4e15b834ad..0000000000 --- a/webui/src/Controls/AddModal.tsx +++ /dev/null @@ -1,300 +0,0 @@ -import { CButton, CCard, CCardBody, CCollapse, CFormInput, CModalBody, CModalFooter, CModalHeader } from '@coreui/react' -import React, { forwardRef, useCallback, useContext, useImperativeHandle, useState } from 'react' -import { useComputed } from '../util.js' -import { RootAppStoreContext } from '../Stores/RootAppStore.js' -import { observer } from 'mobx-react-lite' -import { capitalize } from 'lodash-es' -import { CModalExt } from '../Components/CModalExt.js' -import { go as fuzzySearch } from 'fuzzysort' -import { ObservableMap } from 'mobx' -import { ClientActionDefinition } from '@companion-app/shared/Model/ActionDefinitionModel.js' -import { ClientFeedbackDefinition } from '@companion-app/shared/Model/FeedbackDefinitionModel.js' - -interface AddActionsModalProps { - addAction: (actionType: string) => void -} -export interface AddActionsModalRef { - show(): void -} - -export const AddActionsModal = observer( - forwardRef(function AddActionsModal({ addAction }, ref) { - const { recentlyAddedActions, actionDefinitions } = useContext(RootAppStoreContext) - - const [show, setShow] = useState(false) - - const doClose = useCallback(() => setShow(false), []) - const onClosed = useCallback(() => { - setFilter('') - }, []) - - useImperativeHandle( - ref, - () => ({ - show() { - setShow(true) - setFilter('') - }, - }), - [] - ) - - const [expanded, setExpanded] = useState>({}) - const toggleExpanded = useCallback((id: string) => { - setExpanded((oldVal) => { - return { - ...oldVal, - [id]: !oldVal[id], - } - }) - }, []) - const [filter, setFilter] = useState('') - - const addAction2 = useCallback( - (actionType: string) => { - recentlyAddedActions.trackId(actionType) - - addAction(actionType) - }, - [recentlyAddedActions, addAction] - ) - - return ( - - -
Browse Actions
-
- - setFilter(e.currentTarget.value)} - value={filter} - autoFocus={true} - style={{ fontSize: '1.5em' }} - /> - - - {Array.from(actionDefinitions.connections.entries()).map(([connectionId, actions]) => ( - - ))} - - - - Done - - -
- ) - }) -) - -interface AddFeedbacksModalProps { - addFeedback: (feedbackType: string) => void - booleanOnly: boolean - entityType: string -} -export interface AddFeedbacksModalRef { - show(): void -} - -export const AddFeedbacksModal = observer( - forwardRef(function AddFeedbacksModal( - { addFeedback, booleanOnly, entityType }, - ref - ) { - const { feedbackDefinitions, recentlyAddedFeedbacks } = useContext(RootAppStoreContext) - - const [show, setShow] = useState(false) - - const doClose = useCallback(() => setShow(false), []) - const onClosed = useCallback(() => { - setFilter('') - }, []) - - useImperativeHandle( - ref, - () => ({ - show() { - setShow(true) - setFilter('') - }, - }), - [] - ) - - const [expanded, setExpanded] = useState>({}) - const toggleExpanded = useCallback((id: string) => { - setExpanded((oldVal) => { - return { - ...oldVal, - [id]: !oldVal[id], - } - }) - }, []) - const [filter, setFilter] = useState('') - - const addFeedback2 = useCallback( - (feedbackType: string) => { - recentlyAddedFeedbacks.trackId(feedbackType) - - addFeedback(feedbackType) - }, - [recentlyAddedFeedbacks, addFeedback] - ) - - return ( - - -
Browse {capitalize(entityType)}s
-
- - setFilter(e.currentTarget.value)} - value={filter} - style={{ fontSize: '1.2em' }} - /> - - - {Array.from(feedbackDefinitions.connections.entries()).map(([connectionId, items]) => ( - - ))} - - - - Done - - -
- ) - }) -) - -interface ConnectionItem { - fullId: string - label: string - description: string | undefined -} - -type TDefBase = ClientActionDefinition | ClientFeedbackDefinition - -interface ConnectionCollapseProps { - connectionId: string - items: ObservableMap | undefined - itemName: string - expanded: boolean - filter: string - booleanOnly?: boolean - doToggle: (connectionId: string) => void - doAdd: (itemId: string) => void -} - -const ConnectionCollapse = observer(function ConnectionCollapse({ - connectionId, - items, - itemName, - expanded, - filter, - booleanOnly, - doToggle, - doAdd, -}: ConnectionCollapseProps) { - const { connections } = useContext(RootAppStoreContext) - - const connectionInfo = connections.getInfo(connectionId) - - const doToggle2 = useCallback(() => doToggle(connectionId), [doToggle, connectionId]) - - const allValues: ConnectionItem[] = useComputed(() => { - if (!items) return [] - - return Array.from(items.entries()) - .map(([id, info]) => { - if (!info || !info.label || (booleanOnly && (!('type' in info) || info.type !== 'boolean'))) return null - - return { - fullId: `${connectionId}:${id}`, - label: info.label, - description: info.description, - } - }) - .filter((v): v is ConnectionItem => !!v) - }, [items, booleanOnly]) - - const searchResults = filter - ? fuzzySearch(filter, allValues, { - keys: ['label'], - threshold: -10_000, - }).map((x) => x.obj) - : allValues - - searchResults.sort((a, b) => a.label.localeCompare(b.label)) - - if (!items || items.size === 0) { - // Hide card if there are no actions which match - return null - } else { - return ( - -
- {connectionInfo?.label || connectionId} -
- - - {searchResults.length > 0 ? ( - - - {searchResults.map((info) => ( - - ))} - -
- ) : ( -

No {itemName} match the search

- )} -
-
-
- ) - } -}) - -interface AddRowProps { - info: ConnectionItem - id: string - doAdd: (itemId: string) => void -} -function AddRow({ info, id, doAdd }: AddRowProps) { - const doAdd2 = useCallback(() => doAdd(id), [doAdd, id]) - - return ( - - - {info.label} -
- {info.description || ''} - - - ) -} diff --git a/webui/src/Controls/Components/AddEntitiesModal.tsx b/webui/src/Controls/Components/AddEntitiesModal.tsx new file mode 100644 index 0000000000..03add84ab9 --- /dev/null +++ b/webui/src/Controls/Components/AddEntitiesModal.tsx @@ -0,0 +1,216 @@ +import { CButton, CCard, CCardBody, CCollapse, CFormInput, CModalBody, CModalFooter, CModalHeader } from '@coreui/react' +import React, { forwardRef, useCallback, useContext, useImperativeHandle, useState } from 'react' +import { useComputed } from '../../util.js' +import { RootAppStoreContext } from '../../Stores/RootAppStore.js' +import { observer } from 'mobx-react-lite' +import { capitalize } from 'lodash-es' +import { CModalExt } from '../../Components/CModalExt.js' +import { go as fuzzySearch } from 'fuzzysort' +import { ObservableMap } from 'mobx' +import { EntityModelType } from '@companion-app/shared/Model/EntityModel.js' +import { ClientEntityDefinition } from '@companion-app/shared/Model/EntityDefinitionModel.js' + +interface AddEntitiesModalProps { + addEntity: (connectionId: string, definitionId: string) => void + onlyFeedbackType: 'boolean' | 'advanced' | null + entityType: EntityModelType + entityTypeLabel: string +} +export interface AddEntitiesModalRef { + show(): void +} + +export const AddEntitiesModal = observer( + forwardRef(function AddFeedbacksModal( + { addEntity, onlyFeedbackType, entityType, entityTypeLabel }, + ref + ) { + const { entityDefinitions } = useContext(RootAppStoreContext) + + const definitions = entityDefinitions.getEntityDefinitionsStore(entityType) + const recentlyUsed = entityDefinitions.getRecentlyUsedEntityDefinitionsStore(entityType) + + const [show, setShow] = useState(false) + + const doClose = useCallback(() => setShow(false), []) + const onClosed = useCallback(() => { + setFilter('') + }, []) + + useImperativeHandle( + ref, + () => ({ + show() { + setShow(true) + setFilter('') + }, + }), + [] + ) + + const [expanded, setExpanded] = useState>({}) + const toggleExpanded = useCallback((id: string) => { + setExpanded((oldVal) => { + return { + ...oldVal, + [id]: !oldVal[id], + } + }) + }, []) + const [filter, setFilter] = useState('') + + const addAndTrackRecentUsage = useCallback( + (connectionAndDefinitionId: string) => { + recentlyUsed.trackId(connectionAndDefinitionId) + + const [connectionId, definitionId] = connectionAndDefinitionId.split(':', 2) + addEntity(connectionId, definitionId) + }, + [recentlyUsed, addEntity] + ) + + return ( + + +
Browse {capitalize(entityTypeLabel)}s
+
+ + setFilter(e.currentTarget.value)} + value={filter} + style={{ fontSize: '1.2em' }} + /> + + + {Array.from(definitions.connections.entries()).map(([connectionId, items]) => ( + + ))} + + + + Done + + +
+ ) + }) +) + +interface ConnectionItem { + fullId: string + label: string + description: string | undefined +} + +interface ConnectionCollapseProps { + connectionId: string + items: ObservableMap | undefined + itemName: string + expanded: boolean + filter: string + onlyFeedbackType: string | null + doToggle: (connectionId: string) => void + doAdd: (itemId: string) => void +} + +const ConnectionCollapse = observer(function ConnectionCollapse({ + connectionId, + items, + itemName, + expanded, + filter, + onlyFeedbackType: onlyType, + doToggle, + doAdd, +}: ConnectionCollapseProps) { + const { connections } = useContext(RootAppStoreContext) + + const connectionInfo = connections.getInfo(connectionId) + + const doToggleClick = useCallback(() => doToggle(connectionId), [doToggle, connectionId]) + + const allValues: ConnectionItem[] = useComputed(() => { + if (!items) return [] + + return Array.from(items.entries()) + .map(([id, info]) => { + if (!info || !info.label) return null + if (onlyType && (!('type' in info) || info.type !== onlyType)) return null + + return { + fullId: `${connectionId}:${id}`, + label: info.label, + description: info.description, + } + }) + .filter((v): v is ConnectionItem => !!v) + }, [items, onlyType]) + + const searchResults = filter + ? fuzzySearch(filter, allValues, { + keys: ['label'], + threshold: -10_000, + }).map((x) => x.obj) + : allValues + + searchResults.sort((a, b) => a.label.localeCompare(b.label)) + + if (!items || items.size === 0) { + // Hide card if there are no actions which match + return null + } else { + return ( + +
+ {connectionInfo?.label || connectionId} +
+ + + {searchResults.length > 0 ? ( + + + {searchResults.map((info) => ( + + ))} + +
+ ) : ( +

No {itemName} match the search

+ )} +
+
+
+ ) + } +}) + +interface AddRowProps { + info: ConnectionItem + id: string + doAdd: (itemId: string) => void +} +function AddRow({ info, id, doAdd }: AddRowProps) { + const doAddClick = useCallback(() => doAdd(id), [doAdd, id]) + + return ( + + + {info.label} +
+ {info.description || ''} + + + ) +} diff --git a/webui/src/Controls/Components/AddEntityDropdown.tsx b/webui/src/Controls/Components/AddEntityDropdown.tsx new file mode 100644 index 0000000000..38f64a6b8c --- /dev/null +++ b/webui/src/Controls/Components/AddEntityDropdown.tsx @@ -0,0 +1,132 @@ +import React, { useCallback, useContext } from 'react' +import { useComputed } from '../../util.js' +import Select, { createFilter } from 'react-select' +import { MenuPortalContext } from '../../Components/DropdownInputField.js' +import { observer } from 'mobx-react-lite' +import { RootAppStoreContext } from '../../Stores/RootAppStore.js' +import { prepare as fuzzyPrepare, single as fuzzySingle } from 'fuzzysort' +import { EntityModelType } from '@companion-app/shared/Model/EntityModel.js' +import { ClientEntityDefinition } from '@companion-app/shared/Model/EntityDefinitionModel.js' + +const filterOptions: ReturnType> = (candidate, input): boolean => { + if (input) { + return !candidate.data.isRecent && (fuzzySingle(input, candidate.data.fuzzy)?.score ?? 0) >= 0.5 + } else { + return candidate.data.isRecent + } +} + +interface AddEntityOption { + isRecent: boolean + value: string + label: string + fuzzy: ReturnType +} +interface AddEntityGroup { + label: string + options: AddEntityOption[] +} +interface AddEntityDropdownProps { + onSelect: (connectionId: string, definitionId: string) => void + entityType: EntityModelType + entityTypeLabel: string + onlyFeedbackType: ClientEntityDefinition['feedbackType'] +} +export const AddEntityDropdown = observer(function AddEntityDropdown({ + onSelect, + entityType, + entityTypeLabel, + onlyFeedbackType, +}: AddEntityDropdownProps) { + const { entityDefinitions, connections } = useContext(RootAppStoreContext) + const menuPortal = useContext(MenuPortalContext) + + const definitions = entityDefinitions.getEntityDefinitionsStore(entityType) + const recentlyUsedStore = entityDefinitions.getRecentlyUsedEntityDefinitionsStore(entityType) + + const options = useComputed(() => { + const options: Array = [] + for (const [connectionId, entityDefinitions] of definitions.connections.entries()) { + for (const [definitionId, definition] of entityDefinitions.entries()) { + if (onlyFeedbackType && definition.feedbackType !== onlyFeedbackType) continue + + const connectionLabel = connections.getLabel(connectionId) ?? connectionId + const optionLabel = `${connectionLabel}: ${definition.label}` + options.push({ + isRecent: false, + value: `${connectionId}:${definitionId}`, + label: optionLabel, + fuzzy: fuzzyPrepare(optionLabel), + }) + } + } + + const recents: AddEntityOption[] = [] + for (const definitionPair of recentlyUsedStore.recentIds) { + if (!definitionPair) continue + + const [connectionId, definitionId] = definitionPair.split(':', 2) + const definition = definitions.connections.get(connectionId)?.get(definitionId) + if (!definition) continue + + if (onlyFeedbackType && definition.feedbackType !== onlyFeedbackType) continue + + const connectionLabel = connections.getLabel(connectionId) ?? connectionId + const optionLabel = `${connectionLabel}: ${definition.label}` + recents.push({ + isRecent: true, + value: `${connectionId}:${definitionId}`, + label: optionLabel, + fuzzy: fuzzyPrepare(optionLabel), + }) + } + options.push({ + label: 'Recently Used', + options: recents, + }) + + return options + }, [definitions, connections, recentlyUsedStore.recentIds, onlyFeedbackType]) + + const innerChange = useCallback( + (e: AddEntityOption | null) => { + if (e?.value) { + recentlyUsedStore.trackId(e.value) + + const [connectionId, definitionId] = e.value.split(':', 2) + onSelect(connectionId, definitionId) + } + }, + [onSelect, recentlyUsedStore] + ) + + const noOptionsMessage = useCallback( + ({ inputValue }: { inputValue: string }) => { + if (inputValue) { + return `No ${entityTypeLabel}s found` + } else { + return `No recently used ${entityTypeLabel}s` + } + }, + [entityTypeLabel] + ) + + return ( +