datastores/dom/DOMDataStore.js

/**
 *  DOMDataStore - Store as DOM elements
 * 
 *  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 general purpose storage that serializes Concepts and Properties into DOM-elements and their attributes
 * and stores them in the document.
 * 
 * For webstrates-based documents this allows synchronizing with other programs 
 * across the web as modifications made to the storage structure also make notifications
 * back into the program.
 *
 * ### Options
 * * storageName - The name of the element to store that data below (Default: "varv-data")
 * * storageWebstrate - The name of the webstrate to store the data at (Default: the current webstrate)
 *
 * @memberOf Datastores
 */
class DOMDataStore extends DirectDatastore {
    constructor(name, options = {}) {
        super(name, options);

        this.deleteCallbacks = [];
    }

    destroy() {
        if(this.iframeTransient != null) {
            this.iframeTransient.remove();
        }

        this.stopObserver();

        this.deleteCallbacks.forEach((deleteCallback)=>{
            deleteCallback.delete();
        });
    }

    async init() {
        const self = this;

        let storageName = "varv-data";
        if(this.options.storageName != null) {
            storageName = this.options.storageName;
        }

        let topElement = document;

        //If webstrate is specified, find topElement inside iframe
        if(this.options.storageWebstrate != null) {
            if(DOMDataStore.DEBUG) {
                console.log("Opening storage webstrate:", this.options.storageWebstrate);
            }
            let transient = document.createElement("transient");
            transient.style.display = "none";
            transient.setAttribute("name", "storageWebstrate-"+this.options.storageWebstrate);
            let iframe = document.createElement("iframe");

            transient.appendChild(iframe);
            document.body.appendChild(transient);

            iframe.src = "/"+this.options.storageWebstrate;
            await Observer.waitForTransclude(iframe);

            if(DOMDataStore.DEBUG) {
                console.log("Storage webstrate ready:", this.options.storageWebstrate);
            }

            topElement = iframe.contentDocument;

            this.iframeTransient = transient;
        }

        // Try to find an existing one
        this.backingElement = topElement.querySelector(storageName);

        this.queryCache = new QuerySelectorCache(this.backingElement);

        // None exists, create one
        if (!this.backingElement) {
            this.backingElement = topElement.createElement(storageName, {approved: true});
            topElement.body.appendChild(this.backingElement);

            // TODO: Check if webstrates race condition happened here and remedy it
        }

        // Add an observer to data backing element
        this.observer = new MutationObserver((mutations)=>{
            self.mutationCallback(mutations);
        });
        self.startObserver();

        //Setup disappeared listener?
        this.deleteCallbacks.push(VarvEngine.registerEventCallback("disappeared", async (context)=>{
            if(DOMDataStore.DEBUG) {
                console.log("Saw disappeared UUID (DOMDataStore):", context.target);
            }
            let conceptDom = this.backingElement.querySelector("concept[uuid='"+context.target+"']");
            if(conceptDom !== null) {
                self.executeObserverless(()=>{
                    conceptDom.remove();
                });
            }
        }));

        this.deleteCallbacks.push(VarvEngine.registerEventCallback("appeared", async (context)=>{
            if(DOMDataStore.DEBUG) {
                console.log("Saw appeared UUID (DOMDataStore):", context.target);
            }

            let mark = VarvPerformance.start();
            if (self.isConceptMapped(context.concept)) {
                this.executeObserverless(() => {
                    self.getConceptElementOrCreate(context.target, context.concept);
                });
            }
            VarvPerformance.stop("DOMDataStore.registerEventCallback.appeared", mark);
        }));
    }

    async addConcept(node, uuid) {
        let propertyChangedNodes = [];

        let self = this;

        // Check if already exists (this would be a bit weird but could happen in multi-backed concepts where the other backing already registered their part)
        let conceptByUUID = await VarvEngine.getConceptFromUUID(uuid);

        // Check if a duplicate already exists in the DOM marshalled data, since that is definitely a mistake
        let foundConcepts = self.backingElement.querySelectorAll('concept[uuid="'+uuid+'"]');
        if (foundConcepts.length > 1){
            console.warn("Warning: More than one DOM concept node found for "+conceptByUUID.name +" - only one element is allowed per uuid, this is bad, deleting extra");

            self.executeObserverless(()=>{
                //Clean everything but the first occurrence?
                Array.from(foundConcepts).slice(1).forEach((conceptElement)=>{
                    conceptElement.remove();
                });
            });

            return;
        }

        // Check if the concept type is available and mapped
        let conceptType = node.getAttribute("type");
        if (conceptType===null) {
            console.warn("DOM concept node added without type, ignoring for now - not sure how to handle it");
            return;
        }

        let concept = VarvEngine.getConceptFromType(conceptType);
        if (!concept){
            console.warn("Warning: DOM concept node added for concept of unknown type '"+conceptType+"', ignoring");
            return;
        }

        if (conceptByUUID && concept.name !== conceptByUUID.name){
            console.warn("Warning: DOM concept node added which specified different type than the one registered in the current mapping, ignoring it");
            return;
        }

        if (!self.isConceptMapped(concept)){
            console.warn("Warning: DOM concept node added for concept for which there are no DOM-mapped properties in the current mapping, ignoring it");
            return;
        }

        // Everything checks out, let's add it then'
        if(DOMDataStore.DEBUG) {
            console.log("DOM saw " + uuid + " of type "+conceptType);
        }
        self.registerConceptFromUUID(uuid, concept);

        // Concepts can only exist as top-level but when added they can already carry properties as children nodes
        Array.from(node.children).forEach((childNode)=>{
            if (childNode.tagName==="PROPERTY"){
                // Make sure to import those property values if they exist
                propertyChangedNodes.push(childNode);
            }
        });

        // Signal that someone made a new concept instance appear for the first time
        if(conceptByUUID == null) {
            await concept.appeared(uuid);
        }

        return propertyChangedNodes;
    }

    async removeConcept(node, uuid) {
        let self = this;

        let concept =  self.getConceptFromUUID(uuid);
        if (!concept ){
            console.warn("Notice: DOM concept node removed for concept with uuid "+uuid+" that we didn't know about, this inconsistency is odd");
            return;
        }

        let foundConcepts = self.backingElement.querySelectorAll('concept[uuid="'+uuid+'"]');
        if(foundConcepts.length > 0) {
            console.warn("Notice: Node with uuid "+uuid+" still exists in DOM, not calling disappear");
            return;
        }

        // Someone deleted this concept instance, let's tell everyone to delete it here too
        await concept.disappeared(uuid);
    }

    async mutationCallback(mutationList) {
        const self = this;

        if(DOMDataStore.DEBUG) {
            console.log("Got remote mutation", mutationList);
        }

        let addedConcepts = new Map();
        let removedConcepts = new Map();
        let propertyChangedNodes = [];

        for(let mutation of mutationList) {
            switch (mutation.type) {
                case 'childList':
                    // Look for newly added concept instances first
                    for(let node of mutation.addedNodes) {
                        try {
                            if (node.tagName==="CONCEPT"){
                                let uuid = node.getAttribute("uuid");
                                if (uuid===null) {
                                    console.warn("DOM concept node added without uuid, ignored for now - not sure what to do about it");
                                    continue;
                                }

                                addedConcepts.set(uuid, node);
                                //Since we saw this node added, it is no longer in queue to be removed
                                removedConcepts.delete(uuid);
                            }

                            // A property could also be added (set) directly by someone for the first time
                            if (node.tagName==="PROPERTY"){
                                propertyChangedNodes.push(node);
                            }                      
                        } catch (ex){
                            console.error("Unhandled exception in DOM node adding handler", ex);
                        }
                    }

                    // Removals
                    for(let node of mutation.removedNodes) {
                        try {
                            // Concepts can be removed (deleted) and they appear only as top-level nodes here
                            if (node.tagName==="CONCEPT"){
                                let uuid = node.getAttribute("uuid");
                                if (uuid===null) {
                                    console.warn("DOM concept node removed without uuid, ignored for now - not sure what to do about it");
                                    return;
                                }

                                removedConcepts.set(uuid, node);
                                //Since we just removed this concept, it is no longer added
                                addedConcepts.delete(uuid);
                            }
                        } catch (ex){
                            console.error("Unhandled exception in DOM node remove handler", ex);
                        }                        
                    }

                    // Array property had one or more new child nodes added to it
                    if (mutation.target.tagName==="PROPERTY"){
                        propertyChangedNodes.push(mutation.target);
                    }

                    break;
                case 'attributes':
                    // - Simple property value change
                    if (mutation.attributeName==="value" && mutation.target.tagName==="PROPERTY"){
                        propertyChangedNodes.push(mutation.target);
                    }
                    
                    // TODO: uuid and/or type added to concept that was previously missing it and was thus ignored
                    break;
            }
        }

        for(let [uuid, node] of addedConcepts.entries()) {
            let possibleChangedPropertyNodes = await self.addConcept(node, uuid);
            if(possibleChangedPropertyNodes != null) {
                propertyChangedNodes.push(...possibleChangedPropertyNodes);
            }
        }

        for(let entry of removedConcepts.entries()) {
            await self.removeConcept(entry[1], entry[0]);

            //Filter property changes, don't run the ones where we removed the concept!
            propertyChangedNodes = propertyChangedNodes.filter((propertyNode)=>{
                let conceptElement = propertyNode.parentElement;
                let conceptUUID = conceptElement.getAttribute("uuid");

                return conceptUUID != entry[0];
            });
        }
        
        DOMDataStore.isSyncingPropertyNodes = true;
        for(let propertyNode of propertyChangedNodes) {
            try {
                await self.synchronizePropertyElementFromDOM(propertyNode);
            } catch(e) {
                console.error("Error synchronizing property element from dom: ", e);
            }
        }
        DOMDataStore.isSyncingPropertyNodes = false;
    }

    createBackingStore(concept, property) {
        if(DOMDataStore.DEBUG) {
            console.log("DOMDataStore Mapping "+concept.name+"."+property.name);
        }

        const self = this;
        if (!concept)
            throw new Error('Cannot map invalid concept to DOM: ' + concept);
        if (!property)
            throw new Error('Cannot map invalid property to DOM for concept: ' + concept + "." + property);
        if (this.isPropertyMapped(concept, property))
            throw new Error('Already mapped a DOM backing store for: ' + concept.name + "." + property.name);
        
        if (!this.isConceptMapped(concept)){
            // TODO: This is the first time we hear about this concept, add some create/delete or appear/disappear events as well
        }
        let getter = (uuid) => {
            if(DOMDataStore.DEBUG) {
                console.log("DOMDataStore getter: "+concept.name+"."+property.name);
            }

            let mark = VarvPerformance.start();

            let conceptElement = self.queryCache.querySelector("concept[uuid='" + uuid + "']");
            if (!conceptElement)
                throw new Error("No DOM data stored at all for "+concept.name+" with UUID "+uuid+" while getting "+concept.name+"."+property.name);
            let propertyElement = conceptElement.querySelector("property[name='" + property.name + "']");
            if (!propertyElement)
                throw new Error('No DOM data for property ' + concept.name + "." + property.name + " stored yet with UUID "+uuid);

            let result = self.getPropertyFromDOM(concept, propertyElement, property);

            VarvPerformance.stop("DOMDataStore.getter.nonCached", mark);

            return result;
        }

        let setter = (uuid, value) => {
            let mark = VarvPerformance.start();

            if(DOMDataStore.DEBUG) {
                console.log("DOMDataStore setter: "+concept.name+"."+property.name);
            }

            this.executeObserverless(()=>{
                let conceptElement = self.getConceptElement(uuid, concept);
                if (!conceptElement){
                    throw new Error("Cannot set property "+property.name+" on "+concept.name+" "+uuid+" because it has not appeared or was deleted");
                }

                let propertyElement = conceptElement.querySelector("property[name='" + property.name + "']");
                if (!propertyElement) {
                    propertyElement = document.createElement("property", { approved: true });
                    propertyElement.setAttribute("name", property.name, { approved: true });
                    conceptElement.appendChild(propertyElement);
                }

                let oldValue;

                try {
                    oldValue = property.typeCast(getter(uuid));
                } catch(e) {
                    //Ignore
                }

                if(property.isSame(value, oldValue)) {
                    //This value was already set in DOM, dont set it again
                    if(DOMDataStore.DEBUG) {
                        console.log("Skipping because same value...");
                    }
                    return;
                }

                if (Array.isArray(value)) {
                    let entryElement = document.createElement("temp");
                    value.forEach((entryValue) => {
                        let entry = document.createElement("entry", {approved: true});
                        if (Array.isArray(entryValue))
                            throw new Error('Nested arrays not supported yet'); // TODO
                        entry.setAttribute("value", entryValue);
                        entryElement.appendChild(entry);
                    });
                    propertyElement.innerHTML = entryElement.innerHTML;
                } else {
                    propertyElement.setAttribute("value", value, { approved: true });
                }
            });
            VarvPerformance.stop("DOMDataStore.setter", mark);
        }
        property.addSetCallback(setter);
        property.addGetCallback(getter);

        // Check if concept already is mapped, if not, register it
        this.internalAddPropertyMapping(concept, property, {setter: setter, getter: getter});
    }

    getConceptElement(uuid){
        return this.queryCache.querySelector("concept[uuid='" + uuid + "']");
    }
    getConceptElementOrCreate(uuid, concept) {
        let mark = VarvPerformance.start();
        let conceptElement = this.getConceptElement(uuid);
        if (!conceptElement) {
            conceptElement = document.createElement("concept", {approved: true});
            conceptElement.setAttribute("type", concept.name, { approved: true });
            conceptElement.setAttribute("uuid", uuid, { approved: true });
            this.backingElement.appendChild(conceptElement);
        }
        VarvPerformance.stop("DOMDataStore.getConceptElementOrCreate", mark);
        return conceptElement;
    }

    removeBackingStore(concept, property) {
        if (!concept)
            throw new Error('Cannot unmap invalid concept from DOM: ' + concept);
        if (!property)
            throw new Error('Cannot unmap invalid property from DOM for concept: ' + concept + "." + property);
        if (!this.isConceptMapped(concept))
            throw new Error('Cannot unmap property from concept not managed by DOM: ' + concept.name);
        if (!this.isPropertyMapped(concept, property))
            throw new Error('Cannot unmap property on managed DOM concept because the property was not mapped: ' + concept.name + "." + property.name);

        let trackingData = this.internalPropertyTrackingData(concept, property);
        property.removeSetCallback(trackingData.setter);
        property.removeGetCallback(trackingData.getter);
        
        // TODO: If this was the last mapping for this concept, also remove delete/create or appear/disappear events, we no longer care
        this.internalRemovePropertyMapping(concept, property);
    }

    /** 
     * Loads all concept instances currently registered as backed from serialized state
     * 
     * @returns {undefined}
     */
    async loadBackingStore() {
        // We restore the state by faking that someone else just added all the contents of the
        // backing element to our DOM
        let fakeAddMutationList = [{
            type: "childList",
            target: this.backingElement,
            addedNodes: Array.from(this.backingElement.children),
            removedNodes: []
        }];
        await this.mutationCallback(fakeAddMutationList);
    }

    /**
     * Starts this DOM datastore's mutation observer
     * @ignore
     * @protected
     */
    startObserver() {
        this.observer.observe(this.backingElement, {
            attributes: true,
            childList: true,
            subtree: true,
            attributeOldValue: true,
            characterData: false,
            characterDataOldValue: false
        });
    }

    /**
     * Stops this DOM datastore's mutation observer, handling any mutations that is queued before stopping.
     * @ignore
     * @protected
     */
    stopObserver() {
        let mutations = this.observer.takeRecords();
        if (mutations.length > 0) {
            this.mutationCallback(mutations);
        }
        this.observer.disconnect();
    }

    /**
     * Run the given method without triggering the mutation observer
     * @ignore
     * @protected
     * @param {Function} method - Method to call. Important: must not be async, the observer will be restarted as soon as this promise returns.
     */
    executeObserverless(method) {
        this.stopObserver();

        //Run our method, potentially adding mutations
        method();

        this.startObserver();
    }
    
    /**
     * Reconstruct a value from DOM
     * @param {Concept} concept
     * @param {Element} propertyElement
     * @param {Property} propertyObject
     * @returns {any}
     */
    getPropertyFromDOM(concept, propertyElement, propertyObject){
        // Reconstruct the value
        if (propertyObject.type === "array") {
            // Unpack as array property
            let value = [];

            propertyElement.querySelectorAll(":scope > entry").forEach((childNode) => {
                let entryValue = childNode.getAttribute("value");
                if (entryValue === null)
                    throw new Error("Illegal array entry stored in DOM, cannot unmarshal " + concept.name + "." + propertyObject.name);
                value.push(entryValue);
            });

            return value;
        } else {
            // Unpack as flat property
            let value = propertyElement.getAttribute("value");
            if (value === null)
                throw new Error('No actual value stored in DOM backed property for ' + concept.name + "." + propertyObject.name);

            return value;
        }
    }
    
    /** 
     * Takes the element and looks up everything else from that and pushes its state
     * to the concept
     * @param {element} propertyElement The element with the property to push to concept
     */
    synchronizePropertyElementFromDOM(propertyElement){
        const self = this;

        // Lookup concept
        let conceptElement = propertyElement.parentElement;

        if(conceptElement.parentElement == null) {
            console.warn("Parent was not inside dom, skipping synchronize!");
            return;
        }

        if(DOMDataStore.DEBUG) {
            console.log("Synchronizing:", conceptElement, propertyElement);
        }

        let conceptInstance = this.getConceptInstanceFromConceptElement(conceptElement);
        
        // Lookup property
        let propertyName = propertyElement.getAttribute("name");
        if (propertyName === null) throw new Error("No property name on DOM property node "+conceptInstance.concept.name+" "+propertyElement);
        let propertyObject = conceptInstance.concept.getProperty(propertyName);

        return new Promise((resolve)=>{
            // Fire a set on the property which in turn calls our setter method while pausing our observer to sync with other datastores
            let value = self.getPropertyFromDOM(conceptInstance.concept, propertyElement, propertyObject);
            if(DOMDataStore.DEBUG) {
                console.log("DOM: Pushing remote change to " + conceptInstance.uuid + " " + conceptInstance.concept.name + "." + propertyObject.name + "=" + value);
            }
            propertyObject.setValue(conceptInstance.uuid, propertyObject.typeCast(value)).then(()=>{
                resolve();
            }).catch(()=>{
                //Unable to synchronize from dom, as dom did not validate
                resolve();
            });
        });
    }
    
    /**
     * Lookup concept and uuid from a concept element
     * @param {Element} conceptElement
     * @returns {any}
     */
    getConceptInstanceFromConceptElement(conceptElement){
        if (!conceptElement) throw new Error("Cannot get instance from undefined/null element");
        
        // TODO: Consider if manually added elements should get autogenerated uuid somehow, for now ignore it
        let uuid = conceptElement.getAttribute("uuid");
        if (uuid===null) throw new Error("Incomplete null concept instance in DOM");

        let type = conceptElement.getAttribute("type");
        if (type===null) throw new Error("Incomplete concept instance in DOM ignored, missing type: "+conceptElement.innerHTML);
        if (!this.isConceptTypeMapped(type)) throw new Error("DOM storage contains data for unmapped type, ignoring: "+type);

        // Lookup Concept by type through registry
        let concept = VarvEngine.getConceptFromType(type);
        if (!concept) throw new Error("DOM storage contains data for mapped type that is not registered in the system: "+type);
        
        return {concept: concept, uuid: uuid};
    }
}
DOMDataStore.DEBUG = false;
window.DOMDataStore = DOMDataStore;

//Register default dom datastore
Datastore.registerDatastoreType("dom", DOMDataStore);

class QuerySelectorCache {
    constructor(optionalParent) {
        this.parent = optionalParent!=null?optionalParent:document;
        this.cache = new Map();
        this.reverseLookup = new Map();

        this.setupObserver();
    }

    setupObserver() {
        const self = this;

        this.observer = new MutationObserver((mutations)=>{
            mutations.forEach((mutation)=>{
                mutation.removedNodes.forEach((node)=>{
                    let selectors = self.reverseLookup.get(node);
                    if(selectors != null) {
                        selectors.forEach((selector)=>{
                            self.cache.delete(selector);
                        });
                        self.reverseLookup.delete(node);
                        if(DOMDataStore.DEBUG) {
                            console.log("Updated cache for: ", node);
                        }
                    }
                });
            });
        });

        this.observer.observe(this.parent, {
            childList: true
        });
    }

    querySelector(selector) {
        let mark = VarvPerformance.start();
        let cacheEntry = this.cache.get(selector);
        if(cacheEntry != null) {
            VarvPerformance.stop("DOMDataStore.querySelector.cached", mark);
            return cacheEntry;
        }

        let result = this.parent.querySelector(selector);

        if(result != null) {
            this.cache.set(selector, result);
            let selectors = this.reverseLookup.get(result);
            if(selectors == null) {
                selectors = new Set();
                this.reverseLookup.set(result, selectors);
            }
            selectors.add(selector);
        }

        VarvPerformance.stop("DOMDataStore.querySelector.nonCached", mark);

        return result;
    }
}