mirror of
				https://github.com/mealie-recipes/mealie.git
				synced 2025-11-03 18:53:17 -05:00 
			
		
		
		
	
		
			
				
	
	
		
			251 lines
		
	
	
		
			7.7 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
			
		
		
	
	
			251 lines
		
	
	
		
			7.7 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
import { useLocalStorage, useOnline } from "@vueuse/core";
 | 
						|
import { useUserApi } from "~/composables/api";
 | 
						|
import type { ShoppingListItemOut, ShoppingListOut } from "~/lib/api/types/household";
 | 
						|
import type { RequestResponse } from "~/lib/api/types/non-generated";
 | 
						|
 | 
						|
const localStorageKey = "shopping-list-queue";
 | 
						|
const queueTimeout = 5 * 60 * 1000; // 5 minutes
 | 
						|
 | 
						|
type ItemQueueType = "create" | "update" | "delete";
 | 
						|
 | 
						|
interface ShoppingListQueue {
 | 
						|
  create: ShoppingListItemOut[];
 | 
						|
  update: ShoppingListItemOut[];
 | 
						|
  delete: ShoppingListItemOut[];
 | 
						|
 | 
						|
  lastUpdate: number;
 | 
						|
}
 | 
						|
 | 
						|
interface Storage {
 | 
						|
  [key: string]: ShoppingListQueue;
 | 
						|
}
 | 
						|
 | 
						|
export function useShoppingListItemActions(shoppingListId: string) {
 | 
						|
  const isOnline = useOnline();
 | 
						|
  const api = useUserApi();
 | 
						|
  const storage = useLocalStorage(localStorageKey, {} as Storage, { deep: true });
 | 
						|
  const queue = reactive(getQueue());
 | 
						|
  const queueEmpty = computed(() => !queue.create.length && !queue.update.length && !queue.delete.length);
 | 
						|
  if (queueEmpty.value) {
 | 
						|
    queue.lastUpdate = Date.now();
 | 
						|
  }
 | 
						|
 | 
						|
  storage.value[shoppingListId] = { ...queue };
 | 
						|
  watch(
 | 
						|
    () => queue,
 | 
						|
    (value) => {
 | 
						|
      storage.value[shoppingListId] = { ...value };
 | 
						|
    },
 | 
						|
    {
 | 
						|
      deep: true,
 | 
						|
      immediate: true,
 | 
						|
    },
 | 
						|
  );
 | 
						|
 | 
						|
  function isValidQueueObject(obj: any): obj is ShoppingListQueue {
 | 
						|
    if (typeof obj !== "object" || obj === null) {
 | 
						|
      return false;
 | 
						|
    }
 | 
						|
 | 
						|
    const hasRequiredProps = "create" in obj && "update" in obj && "delete" in obj && "lastUpdate" in obj;
 | 
						|
    if (!hasRequiredProps) {
 | 
						|
      return false;
 | 
						|
    }
 | 
						|
 | 
						|
    const arraysValid = Array.isArray(obj.create) && Array.isArray(obj.update) && Array.isArray(obj.delete);
 | 
						|
 | 
						|
    const lastUpdateValid = typeof obj.lastUpdate === "number" && !isNaN(new Date(obj.lastUpdate).getTime());
 | 
						|
 | 
						|
    return arraysValid && lastUpdateValid;
 | 
						|
  }
 | 
						|
 | 
						|
  function createEmptyQueue(): ShoppingListQueue {
 | 
						|
    const newQueue = { create: [], update: [], delete: [], lastUpdate: Date.now() };
 | 
						|
    return newQueue;
 | 
						|
  }
 | 
						|
 | 
						|
  function getQueue(): ShoppingListQueue {
 | 
						|
    try {
 | 
						|
      const fetchedQueue = storage.value[shoppingListId];
 | 
						|
      if (!isValidQueueObject(fetchedQueue)) {
 | 
						|
        console.log("Invalid queue object in local storage; resetting queue.");
 | 
						|
        return createEmptyQueue();
 | 
						|
      }
 | 
						|
      else {
 | 
						|
        return fetchedQueue;
 | 
						|
      }
 | 
						|
    }
 | 
						|
    catch (error) {
 | 
						|
      console.log("Error validating queue object in local storage; resetting queue.", error);
 | 
						|
      return createEmptyQueue();
 | 
						|
    }
 | 
						|
  }
 | 
						|
 | 
						|
  function removeFromQueue(itemQueue: ShoppingListItemOut[], item: ShoppingListItemOut): boolean {
 | 
						|
    const index = itemQueue.findIndex(i => i.id === item.id);
 | 
						|
    if (index === -1) {
 | 
						|
      return false;
 | 
						|
    }
 | 
						|
 | 
						|
    itemQueue.splice(index, 1);
 | 
						|
    return true;
 | 
						|
  }
 | 
						|
 | 
						|
  function mergeListItemsByLatest(
 | 
						|
    list1: ShoppingListItemOut[],
 | 
						|
    list2: ShoppingListItemOut[],
 | 
						|
  ) {
 | 
						|
    const mergedList = [...list1];
 | 
						|
    list2.forEach((list2Item) => {
 | 
						|
      const conflictingItem = mergedList.find(item => item.id === list2Item.id);
 | 
						|
      if (conflictingItem
 | 
						|
        && list2Item.updatedAt && conflictingItem.updatedAt
 | 
						|
        && list2Item.updatedAt > conflictingItem.updatedAt) {
 | 
						|
        mergedList.splice(mergedList.indexOf(conflictingItem), 1, list2Item);
 | 
						|
      }
 | 
						|
      else if (!conflictingItem) {
 | 
						|
        mergedList.push(list2Item);
 | 
						|
      }
 | 
						|
    });
 | 
						|
    return mergedList;
 | 
						|
  }
 | 
						|
 | 
						|
  async function getList() {
 | 
						|
    const response = await api.shopping.lists.getOne(shoppingListId);
 | 
						|
    if (!isOnline.value && response.data) {
 | 
						|
      const createAndUpdateQueues = mergeListItemsByLatest(queue.update, queue.create);
 | 
						|
      response.data.listItems = mergeListItemsByLatest(response.data.listItems ?? [], createAndUpdateQueues);
 | 
						|
    }
 | 
						|
    return response.data;
 | 
						|
  }
 | 
						|
 | 
						|
  function createItem(item: ShoppingListItemOut) {
 | 
						|
    removeFromQueue(queue.create, item);
 | 
						|
    queue.create.push(item);
 | 
						|
  }
 | 
						|
 | 
						|
  function updateItem(item: ShoppingListItemOut) {
 | 
						|
    const removedFromCreate = removeFromQueue(queue.create, item);
 | 
						|
    if (removedFromCreate) {
 | 
						|
      // this item hasn't been created yet, so we don't need to update it
 | 
						|
      queue.create.push(item);
 | 
						|
      return;
 | 
						|
    }
 | 
						|
 | 
						|
    removeFromQueue(queue.update, item);
 | 
						|
    queue.update.push(item);
 | 
						|
  }
 | 
						|
 | 
						|
  function deleteItem(item: ShoppingListItemOut) {
 | 
						|
    const removedFromCreate = removeFromQueue(queue.create, item);
 | 
						|
    if (removedFromCreate) {
 | 
						|
      // this item hasn't been created yet, so we don't need to delete it
 | 
						|
      return;
 | 
						|
    }
 | 
						|
 | 
						|
    removeFromQueue(queue.update, item);
 | 
						|
    removeFromQueue(queue.delete, item);
 | 
						|
    queue.delete.push(item);
 | 
						|
  }
 | 
						|
 | 
						|
  function getQueueItems(itemQueueType: ItemQueueType) {
 | 
						|
    return queue[itemQueueType];
 | 
						|
  }
 | 
						|
 | 
						|
  function clearQueueItems(itemQueueType: ItemQueueType | "all", itemIds: string[] | null = null) {
 | 
						|
    if (itemQueueType === "create" || itemQueueType === "all") {
 | 
						|
      queue.create = itemIds ? queue.create.filter(item => !itemIds.includes(item.id)) : [];
 | 
						|
    }
 | 
						|
    if (itemQueueType === "update" || itemQueueType === "all") {
 | 
						|
      queue.update = itemIds ? queue.update.filter(item => !itemIds.includes(item.id)) : [];
 | 
						|
    }
 | 
						|
    if (itemQueueType === "delete" || itemQueueType === "all") {
 | 
						|
      queue.delete = itemIds ? queue.delete.filter(item => !itemIds.includes(item.id)) : [];
 | 
						|
    }
 | 
						|
    if (queueEmpty.value) {
 | 
						|
      queue.lastUpdate = Date.now();
 | 
						|
    }
 | 
						|
  }
 | 
						|
 | 
						|
  function checkUpdateState(list: ShoppingListOut) {
 | 
						|
    const cutoffDate = new Date(queue.lastUpdate + queueTimeout).toISOString();
 | 
						|
    if (list.updatedAt && list.updatedAt > cutoffDate) {
 | 
						|
      // If the queue is too far behind the shopping list to reliably do updates, we clear the queue
 | 
						|
      console.log("Out of sync with server; clearing queue");
 | 
						|
      clearQueueItems("all");
 | 
						|
    }
 | 
						|
  }
 | 
						|
 | 
						|
  /**
 | 
						|
   * Processes the queue items and returns whether the processing was successful.
 | 
						|
   */
 | 
						|
  async function processQueueItems(
 | 
						|
    action: (items: ShoppingListItemOut[]) => Promise<RequestResponse<any>>,
 | 
						|
    itemQueueType: ItemQueueType,
 | 
						|
  ): Promise<boolean> {
 | 
						|
    let queueItems: ShoppingListItemOut[];
 | 
						|
    try {
 | 
						|
      queueItems = getQueueItems(itemQueueType);
 | 
						|
      if (!queueItems.length) {
 | 
						|
        return true;
 | 
						|
      }
 | 
						|
    }
 | 
						|
    catch (error) {
 | 
						|
      console.log(`Error fetching queue items of type ${itemQueueType}:`, error);
 | 
						|
      clearQueueItems(itemQueueType);
 | 
						|
      return false;
 | 
						|
    }
 | 
						|
 | 
						|
    try {
 | 
						|
      const itemsToProcess = [...queueItems];
 | 
						|
      await action(itemsToProcess)
 | 
						|
        .then(() => {
 | 
						|
          if (isOnline.value) {
 | 
						|
            clearQueueItems(itemQueueType, itemsToProcess.map(item => item.id));
 | 
						|
          }
 | 
						|
        });
 | 
						|
    }
 | 
						|
    catch (error) {
 | 
						|
      console.log(`Error processing queue items of type ${itemQueueType}:`, error);
 | 
						|
      clearQueueItems(itemQueueType);
 | 
						|
      return false;
 | 
						|
    }
 | 
						|
 | 
						|
    return true;
 | 
						|
  }
 | 
						|
 | 
						|
  async function process() {
 | 
						|
    if (queueEmpty.value) {
 | 
						|
      queue.lastUpdate = Date.now();
 | 
						|
      return;
 | 
						|
    }
 | 
						|
 | 
						|
    const data = await getList();
 | 
						|
    if (!data) {
 | 
						|
      return;
 | 
						|
    }
 | 
						|
    checkUpdateState(data);
 | 
						|
 | 
						|
    // We send each bulk request one at a time, since the backend may merge items
 | 
						|
    // "failures" here refers to an actual error, rather than failing to reach the backend
 | 
						|
    let failures = 0;
 | 
						|
    if (!(await processQueueItems(items => api.shopping.items.deleteMany(items), "delete"))) failures++;
 | 
						|
    if (!(await processQueueItems(items => api.shopping.items.updateMany(items), "update"))) failures++;
 | 
						|
    if (!(await processQueueItems(items => api.shopping.items.createMany(items), "create"))) failures++;
 | 
						|
 | 
						|
    // If we're online, or the queue is empty, the queue is fully processed, so we're up to date
 | 
						|
    // Otherwise, if all three queue processes failed, we've already reset the queue, so we need to reset the date
 | 
						|
    if (isOnline.value || queueEmpty.value || failures === 3) {
 | 
						|
      queue.lastUpdate = Date.now();
 | 
						|
    }
 | 
						|
  }
 | 
						|
 | 
						|
  return {
 | 
						|
    getList,
 | 
						|
    createItem,
 | 
						|
    updateItem,
 | 
						|
    deleteItem,
 | 
						|
    process,
 | 
						|
  };
 | 
						|
}
 |