/*********************************************************************************************************************************************************************************** * GENERIC INFO: * Created by Daniel Drießen @ DDProductions * * Date of creation: 17.07.2024 * Version: 0.1.0.0 * * DESCRIPTION: * This script synchronizes the shopping lists of 'Alexa' and 'Bring'. * It monitors changes in both shopping lists and ensures that they stay updated with each other. * The script uses events to detect changes in the shopping lists of both 'Alexa' and 'Bring'. * When changes are detected, it initiates a synchronization to reconcile any discrepancies between the lists. * The synchronization process involves comparing the items on both lists and updating each list to reflect the combined items from both sources. * * DEPENDENCIES: * - Alexa2 adapter * - Bring adapter * * LIMITATIONS: * Due to the nature of the 'Bring' adapter (which does not store an 'added' or 'updated' timestamp for items on the shopping list), a 'real' synchronization is not possible * because there is no way to detect the order of changes to the shopping lists of 'Alexa' and 'Bring'. * * TODO & FUTURE GOALS: * - Add better error handling. * - Move functions 'getItemsOnAlexaShoppingList' & 'getItemsOnBringShoppingList' into classes 'AlexaShoppingList' & 'BringShoppingList'. * - Move functions 'addItemToShoppingList', 'removeItemFromShoppingList' into classes 'AlexaShoppingList' & 'BringShoppingList'. * - Enhance the synchronization logic to minimize potential syncing errors and improve reliability given the above limitations. * - Maybe add a sync schedule for periodic syncing. * * CHANGELOG: * 18.07.2024 - Initial version completed. * ***********************************************************************************************************************************************************************************/ export default this; /*++++++++++++++++++++++++++++++++++++++++++++++++++*++++++++++++++++++++++++++++++++++++++++++++++++++*++++++++++++++++++++++++++++++++++++++++++++++++++++ + SCRIPT SETUP + *++++++++++++++++++++++++++++++++++++++++++++++++++*++++++++++++++++++++++++++++++++++++++++++++++++++*++++++++++++++++++++++++++++++++++++++++++++++++++++*/ /*---------- OBJECT-ID CONSTANTS ------------------------------------------------------------------------------------------------*/ const objectID_alexa_shoppingList_folder = 'alexa2.0.Lists.SHOPPING_LIST'; const objectID_alexa_shoppingList_addItem = 'alexa2.0.Lists.SHOPPING_LIST.#New'; const objectID_bring_shoppingList_sentenceString = 'bring.0.xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx.enumSentence'; const objectID_bring_shoppingList_addItem = 'bring.0.xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx.saveItem'; const objectID_bring_shoppingList_removeItem = 'bring.0.xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx.removeItem'; /*++++++++++++++++++++++++++++++++++++++++++++++++++*++++++++++++++++++++++++++++++++++++++++++++++++++*++++++++++++++++++++++++++++++++++++++++++++++++++++ *++++++++++++++++++++++++++++++++++++++++++++++++++*++++++++++++++++++++++++++++++++++++++++++++++++++*++++++++++++++++++++++++++++++++++++++++++++++++++++*/ /*++++++++++++++++++++++++++++++++++++++++++++++++++*++++++++++++++++++++++++++++++++++++++++++++++++++*++++++++++++++++++++++++++++++++++++++++++++++++++++ + START OF SCRIPT LOGIC + *++++++++++++++++++++++++++++++++++++++++++++++++++*++++++++++++++++++++++++++++++++++++++++++++++++++*++++++++++++++++++++++++++++++++++++++++++++++++++++*/ var initComplete = false; var syncingInProgress = false; var resyncRequests = []; startScriptLogic(); async function startScriptLogic() { try { await init(); initComplete = true; } catch (error) { console.error(error.message); console.debug(`SCRIPT WILL TERMINATE NOW!`); await this.stopScriptAsync(); } } /*++++++++++++++++++++++++++++++++++++++++++++++++++*++++++++++++++++++++++++++++++++++++++++++++++++++*++++++++++++++++++++++++++++++++++++++++++++++++++++ *++++++++++++++++++++++++++++++++++++++++++++++++++*++++++++++++++++++++++++++++++++++++++++++++++++++*++++++++++++++++++++++++++++++++++++++++++++++++++++*/ /*---------- INIT ---------------------------------------------------------------------------------------------------------------*/ async function init(): Promise { console.info(`Script initialization...`); await synchronizeShoppingLists('init', false); console.info(`Script initialization completed successfully!`); await checkForResyncRequests(); } /*---------- TRIGGER ------------------------------------------------------------------------------------------------------------*/ // Alexa on({ id: [].concat([objectID_alexa_shoppingList_folder + '.json']), change: 'ne' }, async (obj) => { let value = obj.state.val; let oldValue = obj.oldState.val; console.debug(`'Alexa' shopping list changed!`); if (initComplete && !syncingInProgress) { await synchronizeShoppingLists('alexa', false); await checkForResyncRequests(); } else { resyncRequests.push('alexa'); } }); // Bring on({ id: [].concat([objectID_bring_shoppingList_sentenceString]), change: 'ne' }, async (obj) => { let value = obj.state.val; let oldValue = obj.oldState.val; console.debug(`'Bring' shopping list changed!`); if (initComplete && !syncingInProgress) { await synchronizeShoppingLists('bring', false); await checkForResyncRequests(); } else { resyncRequests.push('bring'); } }); /*---------- SYNC ---------------------------------------------------------------------------------------------------------------*/ async function synchronizeShoppingLists(syncInitiator:string, isResync:boolean) { console.info(`Sync started`); console.debug(`Sync initiator: '${capitalizeFirstLetter(syncInitiator)}'`); console.debug(`Sync is a resync: '${isResync}'`); syncingInProgress = true; var itemsOnAlexaShoppingList = await getItemsOnAlexaShoppingList(); var itemsOnBringShoppingList = await getItemsOnBringShoppingList(); console.debug(`Items on "Alexa" shopping list (${itemsOnAlexaShoppingList.length}): ${itemsOnAlexaShoppingList.map(item => item.value)}`); console.debug(`Items on "Bring" shopping list (${itemsOnBringShoppingList.length}): ${itemsOnBringShoppingList.map(item => item.value)}`); if (syncInitiator.toLowerCase() === 'init') { // If sync initiator is "init" // create a combined item list and add each item missing on each list to each list. // Create combined shopping list var itemsOnCombinedShoppingList = []; for (const itemOnAlexaShoppingList of itemsOnAlexaShoppingList) { if (!await ShoppingListUtils.checkIfShoppingListArrayContainsShoppingListItem(itemsOnCombinedShoppingList, itemOnAlexaShoppingList)) { itemsOnCombinedShoppingList.push(itemOnAlexaShoppingList); } } for (const itemOnBringShoppingList of itemsOnBringShoppingList) { if (!await ShoppingListUtils.checkIfShoppingListArrayContainsShoppingListItem(itemsOnCombinedShoppingList, itemOnBringShoppingList)) { itemsOnCombinedShoppingList.push(itemOnBringShoppingList); } } //console.debug(`Items on "Combined" shopping list: ${itemsOnCombinedShoppingList.map(item => item.value)}`); // Add each missing item on each list to each list var itemsToAddToAlexaShoppingList = []; var itemsToAddToBringShoppingList = []; for (const itemOnCombinedShoppingList of itemsOnCombinedShoppingList) { if (!await ShoppingListUtils.checkIfShoppingListArrayContainsShoppingListItem(itemsOnAlexaShoppingList, itemOnCombinedShoppingList)) { itemsToAddToAlexaShoppingList.push(itemOnCombinedShoppingList); } if (!await ShoppingListUtils.checkIfShoppingListArrayContainsShoppingListItem(itemsOnBringShoppingList, itemOnCombinedShoppingList)) { itemsToAddToBringShoppingList.push(itemOnCombinedShoppingList); } } if (itemsToAddToAlexaShoppingList.length > 0) { console.debug(`Items to add to "Alexa" shopping list (${itemsToAddToAlexaShoppingList.length}): ${itemsToAddToAlexaShoppingList.map(item => item.value)}`); } if (itemsToAddToBringShoppingList.length > 0) { console.debug(`Items to add to "Bring" shopping list (${itemsToAddToBringShoppingList.length}): ${itemsToAddToBringShoppingList.map(item => item.value)}`); } for (const itemToAddToAlexaShoppingList of itemsToAddToAlexaShoppingList) { await AlexaShoppingListItem.addItemToShoppingList(itemToAddToAlexaShoppingList); } for (const itemToAddToBringShoppingList of itemsToAddToBringShoppingList) { await BringShoppingListItem.addItemToShoppingList(itemToAddToBringShoppingList); } } else if (syncInitiator.toLowerCase() === 'alexa') { // If sync initiator is "alexa" // add each item from the alexa shopping list that is missing on the bring shopping list to the bring shopping list. // Then remove each item from the bring shopping list that is not on the alexa shopping list. // Add each item from the alexa shopping list that is missing on the bring shopping list to the bring shopping list. var itemsToAddToBringShoppingList = []; for (const itemOnAlexaShoppingList of itemsOnAlexaShoppingList) { if (!await ShoppingListUtils.checkIfShoppingListArrayContainsShoppingListItem(itemsOnBringShoppingList, itemOnAlexaShoppingList)) { itemsToAddToBringShoppingList.push(itemOnAlexaShoppingList); } } if (itemsToAddToBringShoppingList.length > 0) { console.debug(`Items to add to "Bring" shopping list (${itemsToAddToBringShoppingList.length}): ${itemsToAddToBringShoppingList.map(item => item.value)}`); } for (const itemToAddToBringShoppingList of itemsToAddToBringShoppingList) { await BringShoppingListItem.addItemToShoppingList(itemToAddToBringShoppingList); } // Get an update of the bring shopping list itemsOnBringShoppingList = await getItemsOnBringShoppingList(); // Remove each item from the bring shopping list that is not on the alexa shopping list. var itemsToRemoveFromBringShoppingList = []; for (const itemOnBringShoppingList of itemsOnBringShoppingList) { if (!await ShoppingListUtils.checkIfShoppingListArrayContainsShoppingListItem(itemsOnAlexaShoppingList, itemOnBringShoppingList)) { itemsToRemoveFromBringShoppingList.push(itemOnBringShoppingList); } } if (itemsToRemoveFromBringShoppingList.length > 0) { console.debug(`Items to remove from "Bring" shopping list (${itemsToRemoveFromBringShoppingList.length}): ${itemsToRemoveFromBringShoppingList.map(item => item.value)}`); } for (const itemToRemoveFromBringShoppingList of itemsToRemoveFromBringShoppingList) { await BringShoppingListItem.removeItemFromShoppingList(itemToRemoveFromBringShoppingList); } } else if (syncInitiator.toLowerCase() === 'bring') { // If sync initiator is "bring" // add each item from the bring shopping list that is missing on the alexa shopping list to the alexa shopping list. // Then remove each item from the alexa shopping list that is not on the bring shopping list. // Add each item from the bring shopping list that is missing on the alexa shopping list to the alexa shopping list. var itemsToAddToAlexaShoppingList = []; for (const itemOnBringShoppingList of itemsOnBringShoppingList) { if (!await ShoppingListUtils.checkIfShoppingListArrayContainsShoppingListItem(itemsOnAlexaShoppingList, itemOnBringShoppingList)) { itemsToAddToAlexaShoppingList.push(itemOnBringShoppingList); } } if (itemsToAddToAlexaShoppingList.length > 0) { console.debug(`Items to add to "Alexa" shopping list (${itemsToAddToAlexaShoppingList.length}): ${itemsToAddToAlexaShoppingList.map(item => item.value)}`); } for (const itemToAddToAlexaShoppingList of itemsToAddToAlexaShoppingList) { await AlexaShoppingListItem.addItemToShoppingList(itemToAddToAlexaShoppingList); } // Get an update of the alexa shopping list itemsOnAlexaShoppingList = await getItemsOnAlexaShoppingList(); // Remove each item from the alexa shopping list that is not on the bring shopping list. var itemsToRemoveFromAlexaShoppingList = []; for (const itemOnAlexaShoppingList of itemsOnAlexaShoppingList) { if (!await ShoppingListUtils.checkIfShoppingListArrayContainsShoppingListItem(itemsOnBringShoppingList, itemOnAlexaShoppingList)) { itemsToRemoveFromAlexaShoppingList.push(itemOnAlexaShoppingList); } } if (itemsToRemoveFromAlexaShoppingList.length > 0) { console.debug(`Items to remove from "Alexa" shopping list (${itemsToRemoveFromAlexaShoppingList.length}): ${itemsToRemoveFromAlexaShoppingList.map(item => item.value)}`); } for (const itemToRemoveFromAlexaShoppingList of itemsToRemoveFromAlexaShoppingList) { await AlexaShoppingListItem.removeItemFromShoppingList(itemToRemoveFromAlexaShoppingList); } } console.info(`Sync completed!`); syncingInProgress = false; } async function checkForResyncRequests() { if (resyncRequests.length > 0) { // Get the first resync request. var firstResyncRequest = resyncRequests[0]; // Remove the first resync request from the array. resyncRequests.shift(); // Remove all values from the array that match the firstResyncRequest value resyncRequests = resyncRequests.filter(request => request !== firstResyncRequest); // Perform resync await synchronizeShoppingLists(firstResyncRequest, true); } } /*---------- GET SHOPPING LIST ITEMS --------------------------------------------------------------------------------------------*/ // Alexa async function getItemsOnAlexaShoppingList(): Promise > { var alexaShoppingListItems = []; var completedAlexaShoppingListItemsToRemove = []; var jsonShoppingListString = await getStateAsync(objectID_alexa_shoppingList_folder + '.json'); var jsonShoppingList = JSON.parse(jsonShoppingListString.val); jsonShoppingList.forEach((item, index) => { var item_id = item.id; var item_listID = item.listId; var item_customerID = item.customerId; var item_shoppingListItem = item.shoppingListItem; var item_value = item.value; var item_completed = item.completed; var item_version = item.version; var item_createdDateTime = item.createdDateTime; var item_updatedDateTime = item.updatedDateTime; var item_idOfFolderObject = objectID_alexa_shoppingList_folder + '.items.' + item.id; var item_idOfDeleteDatapoint = objectID_alexa_shoppingList_folder + '.items.' + item.id + '.#delete'; /* console.debug(`item_id: ${item_id}`); console.debug(`item_listID: ${item_listID}`); console.debug(`item_customerID: ${item_customerID}`); console.debug(`item_shoppingListItem: ${item_shoppingListItem}`); console.debug(`item_value: ${item_value}`); console.debug(`item_completed: ${item_completed}`); console.debug(`item_version: ${item_version}`); console.debug(`item_createdDateTime: ${item_createdDateTime}`); console.debug(`item_updatedDateTime: ${item_updatedDateTime}`); console.debug(`item_idOfFolderObject: ${item_idOfFolderObject}`); console.debug(`item_idOfDeleteDatapoint: ${item_idOfDeleteDatapoint}`); */ try { const newAlexaShoppingListItem = new AlexaShoppingListItem( item_id, item_listID, item_customerID, item_shoppingListItem, item_value, item_completed, item_version, item_createdDateTime, item_updatedDateTime, item_idOfFolderObject, item_idOfDeleteDatapoint); if (!newAlexaShoppingListItem.completed) { alexaShoppingListItems.push(newAlexaShoppingListItem); } else { completedAlexaShoppingListItemsToRemove.push(newAlexaShoppingListItem); } } catch (error) { console.error(`Error while creating Alexa Shopping-List Item Object! - Original Error: ${error.message}`); } }); if (completedAlexaShoppingListItemsToRemove.length > 0) { for (const completedAlexaShoppingListItemToRemove of completedAlexaShoppingListItemsToRemove) { await AlexaShoppingListItem.removeItemFromShoppingList(completedAlexaShoppingListItemToRemove); } } // Sort the array of alexa shopping list items (case insensitive sorting) alexaShoppingListItems.sort((a, b) => a.value.toLowerCase().localeCompare(b.value.toLowerCase())); return alexaShoppingListItems; } // Bring async function getItemsOnBringShoppingList(): Promise > { var bringShoppingListItems = []; try { var humanReadableShoppingListObject = await getStateAsync(objectID_bring_shoppingList_sentenceString); var humanReadableShoppingList = humanReadableShoppingListObject.val; if (humanReadableShoppingList !== null && humanReadableShoppingList !== '' && humanReadableShoppingList.length > 0) { humanReadableShoppingList = replaceAllOccurrencesOfASubstringWithinAString(humanReadableShoppingList, ' und ', ', '); humanReadableShoppingList = replaceAllOccurrencesOfASubstringWithinAString(humanReadableShoppingList, ', ', ';'); const bringShoppingListItemStrings = splitStringIntoArray(humanReadableShoppingList, ';'); for (const bringShoppingListItemString of bringShoppingListItemStrings) { try { const newBringShoppingListItem = new BringShoppingListItem(bringShoppingListItemString); bringShoppingListItems.push(newBringShoppingListItem); } catch (error) { console.error(`Error while creating Alexa Shopping-List Item Object! - Original Error: ${error.message}`); continue; } } } } catch (error) { console.error(`Error while getting Bring Shopping-List Items! - Original Error: ${error.message}`); } // Sort the array of alexa shopping list items (case insensitive sorting) bringShoppingListItems.sort((a, b) => a.value.toLowerCase().localeCompare(b.value.toLowerCase())); return bringShoppingListItems; } /*++++++++++++++++++++++++++++++++++++++++++++++++++*++++++++++++++++++++++++++++++++++++++++++++++++++*++++++++++++++++++++++++++++++++++++++++++++++++++++ + CLASSES + *++++++++++++++++++++++++++++++++++++++++++++++++++*++++++++++++++++++++++++++++++++++++++++++++++++++*++++++++++++++++++++++++++++++++++++++++++++++++++++*/ class AlexaShoppingListItem { public id: string; public listID: string; public customerID: string; public shoppingListItem: boolean; public value: string; public completed: boolean; public version: number; public createdDateTime: number; public updatedDateTime: number; public idOfFolderObject: string; public idOfDeleteDatapoint: string; constructor( id:string, listID:string, customerID:string, shoppingListItem:boolean, value:string, completed:boolean, version:number, createdDateTime:number, updatedDateTime:number, idOfFolderObject:string, idOfDeleteDatapoint:string) { this.id = id; this.listID = listID; this.customerID = customerID; this.shoppingListItem = shoppingListItem; this.value = value; this.completed = completed; this.version = version; this.createdDateTime = createdDateTime; this.updatedDateTime = updatedDateTime; this.idOfFolderObject = idOfFolderObject; this.idOfDeleteDatapoint = idOfDeleteDatapoint; } /******************** * Adds an item to the Alexa shopping list. * * @param item - The item to save to the Alexa shopping list. Must be either an instance of 'AlexaShoppingListItem' or 'BringShoppingListItem'. * @return Promise - True if the function completed running its code. ********************/ static async addItemToShoppingList(item:AlexaShoppingListItem | BringShoppingListItem): Promise { // Parameter validation if (!(item instanceof AlexaShoppingListItem || item instanceof BringShoppingListItem)) { throw new Error("Invalid parameter: Parameter 'item' must be an instance of 'AlexaShoppingListItem' or 'BringShoppingListItem'!"); } await setStateAsync(objectID_alexa_shoppingList_addItem, capitalizeFirstLetter(item.value), false); return true; } /******************** * Removes an item from the Alexa shopping list. * * @param item - The item to remove from the Alexa shopping list. Must be an instance of 'AlexaShoppingListItem'. * @return Promise - True if the function completed running its code. ********************/ static async removeItemFromShoppingList(item:AlexaShoppingListItem): Promise { // Parameter validation if (!(item instanceof AlexaShoppingListItem)) { throw new Error("Invalid parameter: Parameter 'item' must be an instance of 'AlexaShoppingListItem'!"); } await setStateAsync(item.idOfDeleteDatapoint, true, false); return true; } } class BringShoppingListItem { public value: string; constructor(value:string) { this.value = value; } /******************** * Adds an item to the Bring shopping list. * * @param item - The item to save to the Bring shopping list. Must be either an instance of 'AlexaShoppingListItem' or 'BringShoppingListItem'. * @return Promise - True if the function completed running its code. ********************/ static async addItemToShoppingList(item:AlexaShoppingListItem | BringShoppingListItem): Promise { // Parameter validation if (!(item instanceof AlexaShoppingListItem || item instanceof BringShoppingListItem)) { throw new Error("Invalid parameter: Parameter 'item' must be an instance of 'AlexaShoppingListItem' or 'BringShoppingListItem'!"); } await setStateAsync(objectID_bring_shoppingList_addItem, capitalizeFirstLetter(item.value), false); return true; } /******************** * Removes an item from the Bring shopping list. * * @param item - The item to remove from the Bring shopping list. Must be an instance of 'BringShoppingListItem'. * @return Promise - True if the function completed running its code. ********************/ static async removeItemFromShoppingList(item:BringShoppingListItem): Promise { // Parameter validation if (!(item instanceof BringShoppingListItem)) { throw new Error("Invalid parameter: Parameter 'item' must be an instance of 'BringShoppingListItem'!"); } await setStateAsync(objectID_bring_shoppingList_removeItem, item.value, false); return true; } } class ShoppingListUtils { /******************** * Checks if a given shopping list array contains a specific shopping list item. * Herefore it compares all items in the given shopping list array with the given shopping list item * (either an 'AlexaShoppingListItem' or a 'BringShoppingListItem') * in a case-insensitive way. * * @param shoppingListArray - Array of AlexaShoppingListItem or BringShoppingListItem objects to check within. * @param shoppingListItem - The shopping list item to find in the array. * @returns Promise - True if the item is found, otherwise false. ********************/ static async checkIfShoppingListArrayContainsShoppingListItem(shoppingListArray: Array, shoppingListItem: AlexaShoppingListItem | BringShoppingListItem): Promise { // Parameter validation if (!Array.isArray(shoppingListArray)) { throw new Error("Invalid parameter: 'shoppingListArray' must be an array."); } if (!(shoppingListItem instanceof AlexaShoppingListItem || shoppingListItem instanceof BringShoppingListItem)) { throw new Error("Invalid parameter: 'shoppingListItem' must be an instance of 'AlexaShoppingListItem' or 'BringShoppingListItem'."); } for (const item of shoppingListArray) { if (!(item instanceof AlexaShoppingListItem || item instanceof BringShoppingListItem)) { throw new Error("Invalid parameter: All elements in 'shoppingListArray' must be instances of 'AlexaShoppingListItem' or 'BringShoppingListItem'."); } } // Normalize the value of the shopping list item to lower case for case-insensitive comparison const itemValueToCheck = shoppingListItem.value.toLowerCase(); // Check if any item in the array matches the shopping list item value for (const item of shoppingListArray) { if (item.value.toLowerCase() === itemValueToCheck) { return true; } } return false; } } /*++++++++++++++++++++++++++++++++++++++++++++++++++*++++++++++++++++++++++++++++++++++++++++++++++++++*++++++++++++++++++++++++++++++++++++++++++++++++++++ *++++++++++++++++++++++++++++++++++++++++++++++++++*++++++++++++++++++++++++++++++++++++++++++++++++++*++++++++++++++++++++++++++++++++++++++++++++++++++++*/ /*---------- HELPER FUNCTIONS ---------------------------------------------------------------------------------------------------*/ function capitalizeFirstLetter(input: string): string { if (input.length === 0) { return input; // Return the empty string if input is empty } return input.charAt(0).toUpperCase() + input.slice(1); } function replaceAllOccurrencesOfASubstringWithinAString(originalString: string, searchValue: string, replaceValue: string): string { const regex = new RegExp(searchValue, 'g'); return originalString.replace(regex, replaceValue); } function splitStringIntoArray(input: string, delimiter: string): string[] { return input.split(delimiter); }