class DataManager { constructor() { //The fetch timer interval in milliseconds. this.fetchInterval = 7000; //The retry timeout for when a request fails. this.failedRequestRetryTimeout = 5000; //A flag that is true as long as there is a fetch or store request running. //The fetch timer will skip an interval if there is still a request running. this.isRequestRunning = false; this.listeners = { }; this.storeErrorListeners = { }; this.storedCallbacks = { }; //The object that contains all data that has been loaded from the server. //This is up to date all the time. this.localData = { }; //Time in milliseconds since 1970-01-01 that indicates when the last updates were fetched from the server. this.lastUpdate = 0; this.loadDataRequest = new Request(TimeCards.REQUEST_URI + "DataManager/Fetch", "POST", this.loadedData.bind(this), this.loadDataError.bind(this)); //Will contain the data that should be stored. Will be processed one after the other. this.storeQueue = [ ]; this.storeDataRequest = new Request(TimeCards.REQUEST_URI + "DataManager/Store", "POST", this.storedData.bind(this), this.storeDataError.bind(this)); this.fetchTimer = setInterval(this.loadData.bind(this), this.fetchInterval); this.loadData(); } addDataListener(type, filters, callback, errorCallback = null, performInitialCalls = true) { if (!(type in this.listeners)) { this.listeners[type] = [ ]; } this.listeners[type].push({ filters, callback, errorCallback }); //Call the new listener with the already loaded data, but only if there is already a local data object for the given type. if (performInitialCalls) { if (type in this.localData) { //Call the listeners after the current call stack has finished. setTimeout(function() { for (var key in this.localData[type]) { var entity = this.localData[type][key]; //Call the callback if the conditions match the entity. if (!filters || this.matchesFilters(entity, filters)) { try { callback(key, entity); } catch (exception) { console.error("An error occurred while calling an initial callback (i.e. when adding a new listener) of the DataManager:"); console.error(exception); } } } }.bind(this), 0); } } } /** * Does the same as addDataListener, but without performing any calls for already existing entities. */ putDataListener(type, filters, callback, errorCallback = null) { this.addDataListener(type, filters, callback, errorCallback, false); } /** * Removes the given data listener for the given type. * The error callback will be removed as well. * Will do nothing if the listener is not found. * @param type The type on which the listener was set. * @param callback The callback to remove from the list. */ removeDataListener(type, callback) { if (!(type in this.listeners)) { return; } //Iterate over the listeners for the given type and find the ones to remove. (Yes, there might be multiple) for (var i = this.listeners[type].length - 1; i >= 0; i--) { if (this.listeners[type][i].callback == callback) { this.listeners[type].splice(i, 1); } } } /** * Adds an error listener for the store function for the given type. * @param type The type to add the listener for. * @param errorCallback The callback function for the listener. */ addStoreErrorListener(type, errorCallback) { if (!(type in this.storeErrorListeners)) { this.storeErrorListeners[type] = [ ]; } this.storeErrorListeners[type].push(errorCallback); } /** * Removes the given error listener from the store function for the given type. * @param type The type to remove the listener from. * @param errorCallback The callback function to remove. */ removeStoreErrorListener(type, errorCallback) { if (!(type in this.storeErrorListeners)) { console.warn("Trying to remove a store error listener for a type that does not have any store error listeners."); return; } var index = this.storeErrorListeners[type].indexOf(errorCallback); if (index == -1) { console.warn("Trying to remove a store error listener that is not in the list of listeners."); return; } this.storeErrorListeners[type].splice(index, 1); } loadData() { //Do not fetch the data if there is already a request running. if (this.isRequestRunning) { console.info("Skipping a fetch interval because a previous fetch or store request has not yet returned."); return; } //Set the loading flag. this.setRequestRunning(true, false); //Send the fetch request. this.loadDataRequest.send({ since_date: this.lastUpdate }); //Store the time when the fetch attempt started. //When the fetch is successful, this time is used for the next request. //EDIT: No longer used because now the server dictated the next update time. //this.fetchAttemptTime = new Date().getTime(); } loadDataError(request, status, error) { for (var type in this.listeners) { var listeners = this.listeners[type]; for (var i = 0; i < listeners.length; i++) { if (listeners[i].errorCallback) { listeners[i].errorCallback(); } } } if (error && (error.code == "request_timeout" || error.code == "request_error")) { this.showingLoadingView = true; //Get the loading view controller. if (!this.loadingViewController) { this.loadingViewController = UIKit.getViewControllerById("loading-view-controller"); } UIKit.getActiveViewController().presentModalViewController(this.loadingViewController); } //Retry the request after a timeout. //Only retry if it is not a programmatical error on the server side. //Also retry if it was a request timeout error. if (typeof error != "object" || !error.code || status === "request_timeout") { console.info("Retrying the fetch request after " + this.failedRequestRetryTimeout + " milliseconds ..."); setTimeout(function() { this.setRequestRunning(false); this.loadData(); }.bind(this), this.failedRequestRetryTimeout); } else { console.info("We do not retry the failed fetch request because it is a programmatical issue."); //Reset the loading flag. this.setRequestRunning(false); //Perform the next store request if there is one. if (this.storeQueue.length > 0) { this.storeNext(); } } } loadedData(remoteData) { //Reset the loading flag. this.setRequestRunning(false); if (this.showingLoadingView) { this.showingLoadingView = false; this.loadingViewController.dismissModally(); } //Update the last update time. //We use the time that the server recommends. this.lastUpdate = remoteData.meta.since_date; //Use the actual data from now on. remoteData = remoteData.data; //Immediately process the next store request if there is one in the queue. if (this.storeQueue.length > 0) { this.storeNext(); } //An array containing the local data copies after they are replaced by the new remote entities. //We keep the old entities to call the listeners for them as well. var oldLocalData = { }; //Determine the sorting order of all remote datasets. var orderedKeys = { }; for (var type in remoteData) { //Sort the data if a sorting is specified for this type. var sorting = this.getSortingForType(type); if (sorting) { //This array will contain the keys in the correct order. orderedKeys[type] = [ ]; //Go through all entities to sort their keys in. for (var key in remoteData[type]) { var entity = remoteData[type][key]; //Go through all other entities to find the next later one that is the closest to this one. var closestLaterEntity = null; var closestLaterEntityKey = null; for (var i = 0; i < orderedKeys[type].length; i++) { var comparingKey = orderedKeys[type][i]; //Skip this entity if it is the one we are currently sorting in. if (comparingKey == key) { continue; } var comparingEntity = remoteData[type][comparingKey]; if (this.compare(comparingEntity, entity, sorting) > 0) { if (!closestLaterEntity || this.compare(closestLaterEntity, comparingEntity, sorting) >= 0) { closestLaterEntity = comparingEntity; closestLaterEntityKey = comparingKey; } } } //Insert the entity before the closest later one if there is one. Otherwise, just add it at the end of the keys array. if (closestLaterEntityKey) { orderedKeys[type].splice(orderedKeys[type].indexOf(closestLaterEntityKey), 0, key); } else { orderedKeys[type].push(key); } } } else { //Just use the original keys array from the object if no sorting is necessary. orderedKeys[type] = Object.keys(remoteData[type]); } } //Iterate over the data to first identify deleted entities and then also added or changed ones. //The local data array is updated as well. for (var type in remoteData) { //Create the local array if it does not exist yet. if (!(type in this.localData)) { this.localData[type] = { }; } //Same for the old local data object. if (!(type in oldLocalData)) { oldLocalData[type] = { }; } //Get the list of listeners to this type or use an empty array if there are none. var typeListeners = (type in this.listeners) ? this.listeners[type] : [ ]; //Get the remote dataset for this type. var remoteTypeData = remoteData[type]; //Iterate over the remote entities for this type to find any new, changed or deleted entity. for (var i = 0; i < orderedKeys[type].length; i++) { var key = orderedKeys[type][i]; var remoteEntity = remoteTypeData[key]; //Treat the entity as deleted if it is null. if (remoteEntity === null) { this.deleteLocally(type, key); } else { //Preserve the existing entity if present. if (key in this.localData[type]) { oldLocalData[type][key] = this.localData[type][key]; } //Add or update the changed entity. this.localData[type][key] = remoteEntity; } } } //Call the added or updated listeners in a second step. for (var type in remoteData) { //Get the list of listeners to this type or use an empty array if there are none. var typeListeners = (type in this.listeners) ? this.listeners[type] : [ ]; //Get the remote dataset for this type. var remoteTypeData = remoteData[type]; //Iterate over the remote entities for this type to find any new, changed or deleted entity. for (var i = 0; i < orderedKeys[type].length; i++) { var key = orderedKeys[type][i]; var remoteEntity = remoteTypeData[key]; //If the entity is not a deleted one ... if (remoteEntity !== null) { //Call the next store listener for this entity. if (type in this.storedCallbacks && this.storedCallbacks[type].length > 0) { //Call the callback and then remove it from the list. this.storedCallbacks[type][0](key, remoteEntity); this.storedCallbacks[type].splice(0, 1); } //Call the callbacks if their conditions match the entity. for (var j = 0; j < typeListeners.length; j++) { var listener = typeListeners[j]; //Get the old local entity if there was one. var oldLocalEntity = null; if (key in oldLocalData[type]) { oldLocalEntity = oldLocalData[type][key]; } //Call the added or updated callbacks. var remoteEntityMatchesFilters = !listener.filters ? true : this.matchesFilters(remoteEntity, listener.filters); if (remoteEntityMatchesFilters) { try { listener.callback(key, remoteEntity, this.sortIn(type, remoteEntity, this.getSortingForType(type))); } catch (exception) { console.error("An error occurred while calling an update callback of the DataManager:"); console.error(exception); } } //Call the callbacks as if the entity was deleted if the old entity matched the filters before the update, but not after it. if (listener.filters && oldLocalEntity && this.matchesFilters(oldLocalEntity, listener.filters) && !remoteEntityMatchesFilters) { try { listener.callback(key, null); } catch (exception) { console.error("An error occurred while calling a callback of the DataManager for an entity that no longer matches the filters of the listener:"); console.error(exception); } } } } } } } /** * Registers the given sorting methods for the given type. * @param type The type to register the sorting methods for. * @param sorting An array containing sorting methods or one single sorting method object. */ setSortingForType(type, sorting) { if (!this.sortingsByType) { this.sortingsByType = { }; } this.sortingsByType[type] = sorting; } /** * Gets the preset sorting methods for the given type. * @param type The type for which to get the sorting methods. * @return An array containing all sorting methods for the given type or null if no sorting methods were provided for the given type. */ getSortingForType(type) { if (!(type in this.sortingsByType)) { return null; } return this.sortingsByType[type]; } /** * Determines where in the given type's dataset the given entity should be sorted in with the given sorting method. * If the given entity is determined to be the last in the dataset according to the sorting method, null is returned. * This method will not actually put the entity inside the dataset. * The sorting parameter requires an array containing objects with the keys "field" and "direction", with the latter being either "ASC" or "DESC". * The direction property may be omitted. In this case, "ASC" is used. * @param type The type of the given entity. * @param entity The entity to sort into the dataset of the given type. * @param sorting A sorting method array or a single sorting method object. If omitted, the sorting of the given type is fetched. * @return The key of the entity before which the given entity should be sorted in or null if the entity should be sorted in in the last position. */ sortIn(type, entity, sorting = null) { if (!sorting) { sorting = this.getSortingForType(type); } if (!sorting) { return null; } //Encapsule the sorting object into an array if it was provided as an object. if (!Array.isArray(sorting)) { sorting = [ sorting ]; } //Return null if the dataset was not yet loaded. if (!(type in this.localData)) { console.warn("Trying to sort an object into the dataset for \"" + type + "\", but the dataset for this type was not yet loaded."); return null; } //Go through all existing entities in the dataset. //We skip those that come later than the given one, but we remember which one was the closest after the given entity. var closestLaterEntity = null; var closestLaterEntityKey = null; for (var key in this.localData[type]) { var checkingEntity = this.localData[type][key]; if (this.compare(checkingEntity, entity, sorting) > 0) { //Use this entity as the new closest later entity if it comes before the current closest later entity. if (!closestLaterEntity || this.compare(closestLaterEntity, checkingEntity, sorting) >= 0) { closestLaterEntity = checkingEntity; closestLaterEntityKey = key; } } } return closestLaterEntityKey; } /** * Compares the two given entities with the given sorting method(s). * @param entityA The first entity. * @param entityB The second entity. * @param sorting A sorting method array or a single sorting method object. * @return 1 if entityA comes after entityB, -1 if entityA comes before entityB, 0 if the two entities are indifferent. */ compare(entityA, entityB, sorting) { //Encapsule the sorting object into an array if it was provided as an object. if (!Array.isArray(sorting)) { sorting = [ sorting ]; } //Go through the sorting method objects and compare the two entities accordingly. //We break out of the loop as soon as the first difference has been found. //Therefore, the second, third, etc. sorting method object will not be relevant if the first one already determines a difference. for (var i = 0; i < sorting.length; i++) { var fieldInformation = sorting[i]; var valueA = entityA[fieldInformation.field]; var valueB = entityB[fieldInformation.field]; //We always work with uppercase strings for sorting. var stringReplacements = { "Ä": "AE", "Ü": "UE", "Ö": "OE", "É": "E", "È": "E", "À": "A", "Å": "A", "Ñ": "N", "ç": "C" }; if (typeof valueA == "string") { valueA = valueA.toUpperCase(); for (var original in stringReplacements) { valueA = valueA.replaceAll(original, stringReplacements[original]); } } if (typeof valueB == "string") { valueB = valueB.toUpperCase(); for (var original in stringReplacements) { valueB = valueB.replaceAll(original, stringReplacements[original]); } } //Skip to the next sorting method if the field of this method is equal in both entites. if (valueA == valueB) { continue; } //Return the difference direction of the two entities. var sortingInverter = (("direction" in fieldInformation) && fieldInformation.direction == "DESC") ? -1 : 1; return sortingInverter * (valueA > valueB ? 1 : -1); } //Return 0 if none of the sorting methods recognised a difference. return 0; } /** * Setter for isRequestRunning. * Will also show or hide the visible loading inditation for the user. */ setRequestRunning(running, loadingCursor = true) { if (!this.isRequestRunning && running && loadingCursor) { document.body.classList.add("loading"); } else if (this.isRequestRunning && !running && loadingCursor) { document.body.classList.remove("loading"); } this.isRequestRunning = running; } /** * Checks whether the given entity matches the given filters or not. * It is possible to provide multiple filter groups (an array containing multiple filter objects). * It this is done, the groups are treated like OR, whle the filters inside each filter objects are treated like AND. * You can think of filter groups like individual options. Therefore the OR. * @param entity The entitiy to check. * @param filters The filters to apply to the entity. Either one filter object or an array of filter objects. * @return true if the entity matches, false otherwise. */ matchesFilters(entity, filters) { //First put the filters into an array if it is a simple object, i.e. just one single filter group. var filtersArray = null; if (!Array.isArray(filters)) { filtersArray = [ filters ]; } else { filtersArray = filters; } //Iterate over the filter objects. As soon as the first filter group returns true, we return true here. for (var i = 0; i < filtersArray.length; i++) { if (this.matchesFilterGroup(entity, filtersArray[i])) { return true; } } //If we arrive here, none of the filter groups matched. return false; } matchesFilterGroup(entity, filters) { //Go through the filters, check the conditions and return false upon the first mismatch. for (var fieldKey in filters) { //Convert the abbreviated filters to proper ones. if ((typeof filters[fieldKey]) != "object" || filters[fieldKey] === null) { filters[fieldKey] = { cond: "=", value: filters[fieldKey] }; } var condition = filters[fieldKey]; //Remove the §'s from the field key. It is possible to insert § in the key to allow for multiple identical keys in the same object. var fieldName = fieldKey.replaceAll("§", ""); //Skip the condition if the entity does not contain such a field. if (!(fieldName in entity)) { continue; } //Get the value to check. var value = entity[fieldName]; if (condition.cond == "=" && value != condition.value) { return false; } else if (condition.cond == "!=" && value == condition.value) { return false; } else if (condition.cond == ">" && value <= condition.value) { return false; } else if (condition.cond == "<" && value >= condition.value) { return false; } else if (condition.cond == ">=" && value < condition.value) { return false; } else if (condition.cond == "<=" && value > condition.value) { return false; } } return true; } /** * Returns the entity with the given primary key for the given type. * @param type The type of the desired entity. * @param key The primary key to search for. * @return The found entity object or null if the entity was not found. */ getEntity(type, key) { if (!(type in this.localData)) { console.warn("Trying to get the entity for the key " + key + " for the type " + type + ", but the dataset for this type was not yet loaded."); return null; } if (!(key in this.localData[type])) { console.warn("Trying to get the entity for the key " + key + " for the type " + type + ", but there is no such entity loaded locally."); return null; } return this.localData[type][key]; } /** * Returns the loaded entities for the given type. * It is possible to filter the results. * @param type The type of the entities. * @param filters A standard filters array. * @return The loaded entity objects or an empty object if the dataset was not yet loaded or no entities match the given filters. */ getEntities(type, filters = null) { if (!(type in this.localData)) { console.warn("Trying to get the entities for the type " + type + ", but the dataset for this type was not yet loaded."); return { }; } var dataToGet = { }; if (filters) { for (var key in this.localData[type]) { if (this.matchesFilters(this.localData[type][key], filters)) { dataToGet[key] = this.localData[type][key]; } } } else { dataToGet = this.localData[type]; } return dataToGet; } /** * Stores the given entity under the given key for the given type. * @param type The type of the entity. * @param key The key to store the entity with OR the entity itself if it is a new entity. * @param entity The entity to store OR -1 if the entity was passed as the second parameter. Pass null here to delete the entity. */ store(type, key, entity = -1, storedCallback = null) { //Parameter switching for overload. if (typeof entity === "function") { storedCallback = entity; entity = -1; } if (entity === -1) { entity = key; key = null; } //Store the stored callback if one is given. if (!(type in this.storedCallbacks)) { this.storedCallbacks[type] = [ ]; } if (storedCallback) { this.storedCallbacks[type].push(storedCallback); } //Build the request data. var requestData = { type, key, entity }; //Add the request to the queue. this.storeQueue.push(requestData); //Immediately start the request if there is no other request running at this time. if (!this.isRequestRunning) { this.storeNext(); } else { console.info("Waiting for the previous request to return before storing " + requestData.type + "#" + requestData.key + "."); } } /** * Deletes the entity for the given key for the given type. * Will actually just call store() with null as the entity. This will trigger the delete action on the server. * @param type The type of the entity to delete. * @param key The primary key value of the entity to delete. */ delete(type, key) { this.store(type, key, null); } /** * Removes the entitiy with the given key for the given type from the local dataset only. * The deletion will not be propagated to the server. * Therefore, this function must only be used for cases where the server would not normally return that entity. * @param type The type of the entity to delete. * @param key The primary key value of the entity to delete. */ deleteLocally(type, key) { if (!(type in this.localData)) { console.warn("Trying to delete " + type + "#" + key + ", but there is no dataset for that type."); return; } //Do nothing if there is no corresponding local entity. //This might be the case when an entity is deleted immediately after being created and thus this client has never seen that entity. if (!(key in this.localData[type])) { console.warn("Trying to delete " + type + "#" + key + ", but there is no such entity."); return; } //Get the local entity. var localEntity = this.localData[type][key]; //Get the list of listeners to this type or use an empty array if there are none. var typeListeners = (type in this.listeners) ? this.listeners[type] : [ ]; //Call the callbacks for the delete event if their conditions match the entity. for (var j = 0; j < typeListeners.length; j++) { var listener = typeListeners[j]; if (!listener.filters || this.matchesFilters(localEntity, listener.filters)) { try { listener.callback(key, null); } catch (exception) { console.error("An error occurred while calling a delete callback of the DataManager:"); console.error(exception); } } } //Remove the local entity. delete this.localData[type][key]; } /** * Clears the request queue so that the next store request will be performed immediately. * This is only used by the logout action. * @param forLoginRequest Pass true if you want to perform a login or logout request afterwards. By that, the next request will skip the request queue. */ clearQueue(forLoginRequest = false) { this.storeQueue = [ ]; this.isRequestRunning = false; this.awaitingLoginRequest = forLoginRequest; } /** * Performs the next store request in the queue. * Will do nothing if the queue is empty. */ storeNext() { //Do nothing if the queue is empty or there is already a request running. if (this.storeQueue.length == 0) { console.warn("Calling storeNext() in the DataManager even though there is nothing in the queue to store. This should never happen!"); return; } if (this.isRequestRunning) { console.warn("Calling storeNext() in the DataManager even though there is already a request running. This should never happen!"); return; } //Set the loading flag. this.setRequestRunning(true); //Get the request data from the queue. var requestData = this.storeQueue[0]; //Only demand the newest updates if this is the last request in the queue. if (this.storeQueue.length == 1) { requestData.get_updates_since = this.lastUpdate; //Reset the fetch timer. clearInterval(this.fetchTimer); this.fetchTimer = setInterval(this.loadData.bind(this), this.fetchInterval); } //Prevent the update if this is an attempt to delete an entity that does not exist locally. //This may happen when the data has been deleted just before the last fetch request has returned. if (!requestData.entity && !this.getEntity(requestData.type, requestData.key)) { console.info("Skipping a delete request because the corresponding entity (" + requestData.type + "#" + requestData.key + ") does not exist. It might have already been deleted."); //Remove this skipped request. this.storeQueue.splice(0, 1); //Move to the next store request if there is another one left. if (this.storeQueue.length > 0) { this.storeNext(); } else { //Otherwise just reset the loading flag and call it a day. this.setRequestRunning(false); } return; } //Send the request. this.storeDataRequest.send(requestData, (this.awaitingLoginRequest ? true : false)); this.awaitingLoginRequest = false; } storedData(remoteData) { //Do nothing if the queue has been reset in the meantime. if (this.storeQueue.length == 0) { return; } //Remove the request from the queue. this.storeQueue.splice(0, 1); //Reset the loading flag. this.setRequestRunning(false); //Perform the next request if there is one. if (this.storeQueue.length > 0) { this.storeNext(); } else if (remoteData !== true) { //Otherwise pass on the received data to the loadedData() function because it contains the updated entities. this.loadedData(remoteData); } else { console.warn("The response of the store request is just true even though this was the last request in the queue."); } } storeDataError(request, status, error) { //Call the store error listeners. for (var type in this.storeErrorListeners) { var listeners = this.storeErrorListeners[type]; for (var i = 0; i < listeners.length; i++) { try { listeners[i](this.storeQueue[0].key, this.storeQueue[0].entity, error); } catch (exception) { console.error("An error occurred while calling a store data error callback of the DataManager:"); console.error(exception); } } } //Display an error message. alert("An error occurred while storing the " + this.storeQueue[0].type + ":\r\n\r\n" + JSON.stringify(error)); //Try the request again after a timeout. //Only retry if it is not a programmatical error on the server side. //Also retry if it was a request timeout error. if (typeof error != "object" || !error.code || status === "request_timeout") { console.info("Retrying the store request for " + this.storeQueue[0].type + "#" + this.storeQueue[0].key + " after " + this.failedRequestRetryTimeout + " milliseconds ..."); setTimeout(function() { this.setRequestRunning(false); this.storeNext(); }.bind(this), this.failedRequestRetryTimeout); } else { console.info("We do not retry the failed store request because it is a programmatical issue."); //Remove the request from the queue. this.storeQueue.splice(0, 1); //Reset the loading flag. this.setRequestRunning(false); //Perform the next request if there is one. if (this.storeQueue.length > 0) { this.storeNext(); } } } }