core/Action.js

/**
 *  Action - The super class for all Actions
 * 
 *  This code is licensed under the MIT License (MIT).
 *  
 *  Copyright 2020, 2021, 2022 Rolf Bagge, Janus B. Kristensen, CAVI,
 *  Center for Advanced Visualization and Interaction, Aarhus University
 *  
 *  Permission is hereby granted, free of charge, to any person obtaining a copy
 *  of this software and associated documentation files (the “Software”), to deal
 *  in the Software without restriction, including without limitation the rights 
 *  to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 
 *  copies of the Software, and to permit persons to whom the Software is 
 *  furnished to do so, subject to the following conditions:
 *  
 *  The above copyright notice and this permission notice shall be included in 
 *  all copies or substantial portions of the Software.
 *  
 *  THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 
 *  IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 
 *  FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 
 *  AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 
 *  LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 
 *  OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 
 *  THE SOFTWARE.
 *  
 */

/**
 * A context in Varv
 * @typedef {object} VarvContext
 * @property {string} [target] - The UUID of the target this context refers to
 * @property {object} [variables] - The variables currently set to any value
 */

/**
 * Base class for all actions
 */
class Action {
    /**
     * Crate a new Action
     * @param {string} name - The name of the action
     * @param {object} options - The options of the action
     * @param {Concept} concept - The owning concept
     */
    constructor(name, options, concept) {
        this.name = name;
        this.options = options;
        this.concept = concept;

        if(this.options == null) {
            this.options = {};
        }
    }

    /**
     * Applies this Action to the given contexts, returning some resulting contexts
     * @param {VarvContext[]} contexts
     * @param {object} arguments
     * @returns {Promise<VarvContext[]>}
     */
    async apply(contexts, actionArguments = {}) {
        console.warn("Always override Action.apply in subclass!");
        return contexts;
    }

    /**
     * @callback forEachCallback
     * @param {VarvContext} context - The context currently being looked at
     * @param {object} options - The current options with variables and arguments substituted
     * @param {number} index - The index of the current context in the context array
     * @returns {VarvContext|VarvContext[]}
     */

    /**
     * Loops through the given contexts, and calls the given callback for each, complete with substituted options and the index of the context
     * @param {VarvContext[]} contexts - The contexts to handle
     * @param {object} actionArguments - The arguments to use for this action
     * @param {forEachCallback} callback - The callback to call for each context
     * @returns {Promise<VarvContext[]>}
     */
    async forEachContext(contexts, actionArguments={}, callback) {
        if(arguments.length < 2) {
            throw new Error("forEachContext can be called either as (contexts, actionArguments, callback) or (contexts, callback)");
        }

        //If called with 2 arguments options is the callback
        if(arguments.length === 2) {
            if(typeof actionArguments !== "function") {
                throw new Error("forEachContext can be called either as (contexts, actionArguments, callback) or (contexts, callback)");
            }

            // noinspection JSValidateTypes
            callback = actionArguments;
            actionArguments = {};
        }

        let results = [];

        let index = 0;

        let options = await Action.lookupArguments(this.options, actionArguments);

        for (let context of contexts) {
            //Make sure to clone context, since we change it directly, thus variables might be a shared object if not.
            let clonedContext = Action.cloneContext(context);

            let optionsWithVariables = await Action.lookupVariables(options, clonedContext);

            let result = await callback(clonedContext, optionsWithVariables, index);

            if (result != null) {
                if (Array.isArray(result)) {
                    result.forEach((entry) => {
                        results.push(entry);
                    });
                } else {
                    results.push(result);
                }
            }

            index++;
        }

        return results;
    }

    /**
     * Get the default variable name, for when no variable name is supplied
     * @param {Action} - The action asking, the name of the action is used.
     * @returns {string} - The default variable name
     */
    static defaultVariableName(action) {
        if(action == null) {
            return "result";
        }

        return action.name;
    }

    /**
     * Sets the given variable to the given value, in the given context
     * @param {VarvContext} context - The context to set the variable inside
     * @param {string} name - The name of the variable to set
     * @param {any} value - The value to set
     */
    static setVariable(context, name, value) {
        if(context.variables == null) {
            context.variables = {}
        }

        if(name === "target") {
            console.warn("Unable to set variable target!");
            return;
        }

        context.variables[name] = value;
    }

    /**
     * Retrieve the value of the given variable from the given context
     * @param {VarvContext} context
     * @param {string} name
     * @returns {any}
     */
    static getVariable(context, name) {
        if(name === "target" && context.target != null) {
            //If context.target exists use that, if not, check variables as getCommonVariables might have saved a common target
            return context.target;
        }

        if(context.variables == null) {
            context.variables = {}
        }

        if(!context.variables.hasOwnProperty(name)) {
            if(name === "target") {
                throw new Error("Context did not contain target, and no variable target existed either!");
            }

            throw new Error("No named variable ["+name+"]");
        }

        return context.variables[name];
    }

    /**
     * Extract all the variables that have the same value across all contexts
     * @param contexts
     * @returns {{}}
     */
    static getCommonVariables(contexts) {
        let common = {};

        let mark = VarvPerformance.start();

        if(contexts.length > 0) {
            let testContext = contexts[0];

            if(testContext.variables != null) {
                Object.keys(testContext.variables).forEach((variableName)=>{
                    let variableValue = testContext.variables[variableName];
                    let keep = true;

                    for(let otherContext of contexts) {
                        if(otherContext.variables != null) {
                            let otherValue = otherContext.variables[variableName];

                            if(otherValue != null && otherValue === variableValue) {
                                continue;
                            }

                            if(Array.isArray(otherValue) && Array.isArray(variableValue)) {
                                if(otherValue.length === variableValue.length) {
                                    let arrayEqual = true;
                                    for(let i = 0; i<otherValue.length; i++) {
                                        arrayEqual = arrayEqual && otherValue[i] === variableValue[i];
                                    }

                                    if(arrayEqual) {
                                        continue;
                                    }
                                }
                            }
                        }

                        keep = false;
                        break;
                    }

                    if(keep) {
                        common[variableName] = variableValue;
                    }
                });
            }

            if(testContext.target != null) {
                let keep = true;
                for(let otherContext of contexts) {
                    if (otherContext.target != testContext.target) {
                        keep = false;
                        break;
                    }
                }

                if(keep) {
                    common["target"] = testContext.target;
                }
            }
        } else {
            if(contexts.savedVariables) {
                return contexts.savedVariables;
            }
        }

        VarvPerformance.stop("Action.getCommonVariables", mark, "#contexts "+contexts.length);

        return common;
    }

    /**
     * Looks up any options that are set to an argument replacement value "@myArgumentName" and replaces it with that arguments value
     * @param {object} options - The options to do the replacement on
     * @param {object} actionArguments - The arguments to replace into the options
     * @returns {object} A clone of the options argument, with all replacement values replaced
     */
    static async lookupArguments(options, actionArguments) {
        let mark = VarvPerformance.start();

        const optionsClone = Action.clone(options);

        let regex = /@(\S+?)(?:@|\s|$)/gm;

        for(let parameter in optionsClone) {
            if(!Object.hasOwn(optionsClone, parameter)) {
                continue;
            }

            let value = optionsClone[parameter];

            optionsClone[parameter] = await Action.subParam(value, (value)=>{
                if(!value.includes("@")) {
                    return value;
                }

                //Substitute any @argumentName with the value of the argument
                for(let match of value.matchAll(regex)) {
                    let search = match[0].trim();
                    let variableName = match[1];

                    let variableValue = actionArguments[variableName];

                    if(Action.DEBUG) {
                        console.log("Replaced argument:", search, variableValue, actionArguments, variableName);
                    }

                    if(value === search) {
                        //Single value, set it directly
                        value = variableValue;
                    } else {
                        //Replace into value
                        value = value.replace(search, variableValue);
                    }
                }

                return value;
            });

        }

        VarvPerformance.stop("Action.lookupArguments", mark, {options});

        return optionsClone;
    }

    static async subParam(value, lookupCallback) {
        if (Array.isArray(value)) {
            for (let i = 0; i < value.length; i++) {
                value[i] = await Action.subParam(value[i], lookupCallback);
            }
            return value;
        } else if (typeof value === "object" && value != null && Object.getPrototypeOf(value) === Object.prototype) {
            for(let key in value) {
                if(Object.hasOwn(value, key)) {
                    value[key] = await Action.subParam(value[key], lookupCallback);
                }
            }
            return value;
        } else if (typeof value === "string") {
            return lookupCallback(value);
        } else {
            return value;
        }
    }

    static clone(obj) {
        if(Array.isArray(obj)) {
            return obj.map((arrayValue)=>{
                return Action.clone(arrayValue);
            });
        } else if(typeof obj === "object" && obj != null && Object.getPrototypeOf(obj) === Object.prototype) {

            let clone = {};

            for (let key in obj) {
                if (!Object.hasOwn(obj, key)) {
                    continue;
                }

                clone[key] = Action.clone(obj[key]);
            }

            return clone;
        }

        return obj;
    }


    /**
     * Look up any options that have a variable replacement value "$myVariable" and replaces it with the value of that variable.
     * @param {object} options
     * @param {VarvContext} context
     * @returns {object} - Returns a clone of the given options, with all replacement values replaced.
     */
    static async lookupVariables(options, context) {
        if(Action.DEBUG) {
            console.group("Looking up variables from context:", options, context);
        }

        let mark = VarvPerformance.start();

        const optionsClone = Action.clone(options);

        async function doLookup(context, variableName) {
            let variableValue = null;

            if(variableName.indexOf(".") !== -1) {
                let split = variableName.split(".");

                //concept.property lookup, not really variable
                let conceptName = split[0];
                let propertyName = split[1];

                let result = null;

                if(conceptName === "lastTarget") {
                    result = await VarvEngine.lookupProperty(context.lastTarget, null, propertyName);
                } else if(conceptName === "target") {
                    result = await VarvEngine.lookupProperty(context.target, null, propertyName);
                } else {
                    result = await VarvEngine.lookupProperty(context.target, null, variableName);
                }

                if (result != null && result.target != null) {
                    variableValue = await result.property.getValue(result.target);
                }
            } else {
                variableValue = Action.getVariable(context, variableName);
            }

            return variableValue;
        }

        let regex = /\$(\S+?)(?:\$|\s|$)/gm;

        for(let key of Object.keys(optionsClone)) {
            optionsClone[key] = await Action.subParam(optionsClone[key], async (value)=>{
                if(!value.includes("$")) {
                    return value;
                }

                //Substitute any $VariableName with the value of the variable
                for(let match of value.matchAll(regex)) {
                    let search = match[0].trim();
                    let variableName = match[1];

                    let variableValue = await doLookup(context, variableName);

                    if(Action.DEBUG) {
                        console.log("Replaced variable:", search, variableValue);
                    }

                    if(value === search) {
                        //Single value, set it directly
                        value = variableValue;
                    } else {
                        //Replace into value
                        value = value.replace(search, variableValue);
                    }
                }

                return value;
            });
        }

        if(Action.DEBUG) {
            console.groupEnd();
        }

        VarvPerformance.stop("Action.lookupVariables", mark, {context, options});

        //Handle special stuff here
        Action.substituteEvery(optionsClone, "calculate", (value)=>{
            return CalculateAction.evaluate(value);
        });

        return optionsClone;
    }

    static substituteEvery(input, nameToSubstitute, callback) {
        if(Array.isArray(input)) {
            for(let i = 0; i< input.length; i++) {
                input[i] = Action.substituteEvery(input[i], nameToSubstitute, callback);
            }
        } else if(typeof input === "object") {
            for(let key in input) {
                if(input.hasOwnProperty(key)) {
                    if(key === nameToSubstitute) {
                        if(Object.keys(input).length > 1) {
                            console.warn("Substituting on something with more than 1 entry (Stuff will be lost):", input, nameToSubstitute);
                        }

                        let origValue = input[key];
                        return callback(origValue);
                    } else {
                        input[key] = Action.substituteEvery(input[key], nameToSubstitute, callback);
                    }
                }
            }
        }

        return input;
    }

    /**
     * Registers the given action as a primitive action with the given name
     * @param {string} name
     * @param {Action} action
     */
    static registerPrimitiveAction(name, action) {
        if(Action.DEBUG) {
            console.log("Registering primitive action:", name, action);
        }

        if (Action.primitiveActions.has(name)) {
            console.warn("Overriding primitive action: ", name);
        }

        Action.primitiveActions.set(name, action);
    }

    /**
     * Gets an instance of the primitive action with the given name, using the given options
     * @param {string} name
     * @param {object} options
     * @returns {Action}
     */
    static getPrimitiveAction(name, options, concept) {
        let actionClass = Action.primitiveActions.get(name);

        if (actionClass == null) {
            throw new Error("Unknown primitive action [" + name + "]");
        }

        let action = new actionClass(name, options, concept);

        action.isPrimitive = true;

        return action;
    }

    /**
     * Checks if a primitive action with the given name exists
     * @param {string} name
     * @returns {boolean}
     */
    static hasPrimitiveAction(name) {
        return Action.primitiveActions.has(name);
    }

    /**
     * Clones the given context. (Any non JSON serializable values in the context, will be lost)
     * @param {object} context
     * @returns {object} - The cloned context
     */
    static cloneContext(context) {
        let mark = VarvPerformance.start();

        let result = null;
        if(Array.isArray(context)) {
            result = context.map(Action.cloneContextInternal);
        } else {
            result = Action.cloneContextInternal(context);
        }

        VarvPerformance.stop("Action.cloneContext", mark);

        return result;
    }

    static cloneContextInternal(context) {
        //Move over allowed properties
        let preCloneObject = {};
        preCloneObject.variables = context.variables;
        preCloneObject.target = context.target;

        if(Action.DEBUG) {
            console.log("Cloning context:", context, preCloneObject);
        }
        return Action.clone(preCloneObject);
    }
}
Action.DEBUG = false;
Action.primitiveActions = new Map();
window.Action = Action;

class ActionChain extends Action {
    constructor(name, options, concept) {
        super(name, options, concept);

        this.actions = [];
    }

    addAction(action) {
        const self = this;

        if(Array.isArray(action)) {
            action.forEach((actionElm)=>{
                self.actions.push(actionElm);
            })
        } else {
            this.actions.push(action);
        }
    }

    async apply(contexts, actionArguments = {}) {
        let currentContexts = contexts;

        for (let action of this.actions) {
            let commonVariablesBefore = Action.getCommonVariables(currentContexts);

            await ActionTrigger.before(action, currentContexts);
            let mark = VarvPerformance.start();
            currentContexts = await action.apply(currentContexts, actionArguments);
            if(action.isPrimitive) {
                VarvPerformance.stop("PrimitiveAction-"+action.name, mark);
            } else {
                VarvPerformance.stop("CustomAction-"+action.name, mark);
            }
            await ActionTrigger.after(action, currentContexts);

            if(currentContexts == null) {
                currentContexts = [];
            }

            if(currentContexts.length === 0) {
                currentContexts.savedVariables = commonVariablesBefore;
            }
        }

        return currentContexts;
    }
}

window.ActionChain = ActionChain;

class LookupActionAction extends Action {
    constructor(name, options, concept) {
        super(name, options, concept);
    }

    async apply(contexts, actionArguments = {}) {
        const self = this;

        let optionsWithArguments = await Action.lookupArguments(this.options, actionArguments);

        if(optionsWithArguments.lookupActionName == null) {
            throw new Error("[LookupActionAction] Missing option 'lookupActionName'");
        }

        //TODO: We assume that all concepts are of the same type, when/if polymorphism is introduced this breaks
        let contextConcept = null;
        if(contexts.length > 0) {
            contextConcept = await VarvEngine.getConceptFromUUID(contexts[0].target);
        }

        let action = VarvEngine.lookupAction(optionsWithArguments.lookupActionName, [contextConcept, self.concept], optionsWithArguments.lookupActionArguments);

        if(action != null) {
            await ActionTrigger.before(action, contexts);
            let mark = VarvPerformance.start();
            let lookupActionResult = await action.apply(contexts, action.isPrimitive?{}:optionsWithArguments.lookupActionArguments);
            if(action.isPrimitive) {
                VarvPerformance.stop("PrimitiveAction-"+action.name, mark);
            } else {
                VarvPerformance.stop("CustomAction-"+action.name, mark);
            }
            await ActionTrigger.after(action, lookupActionResult);
            return lookupActionResult;
        }

        return null;
    }
}

window.LookupActionAction = LookupActionAction;

class StopError extends Error {
    constructor(msg) {
        super(msg);
    }
}
window.StopError = StopError;