mirror of
https://github.com/hay-kot/mealie.git
synced 2025-07-12 08:07:14 -07:00
Some checks are pending
CodeQL / Analyze (push) Waiting to run
Docker Nightly Production / Backend Server Tests (push) Waiting to run
Docker Nightly Production / Frontend Tests (push) Waiting to run
Docker Nightly Production / Build Package (push) Waiting to run
Docker Nightly Production / Build Tagged Release (push) Blocked by required conditions
Docker Nightly Production / Notify Discord (push) Blocked by required conditions
Release Drafter / ✏️ Draft release (push) Waiting to run
Co-authored-by: Michael Genson <71845777+michael-genson@users.noreply.github.com> Co-authored-by: Kuchenpirat <24235032+Kuchenpirat@users.noreply.github.com>
250 lines
7.7 KiB
TypeScript
250 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,
|
|
};
|
|
}
|