|
@@ -5,13 +5,12 @@ import type App from "../components/App";
|
|
import { ImportedDataState } from "./types";
|
|
import { ImportedDataState } from "./types";
|
|
import { atom } from "jotai";
|
|
import { atom } from "jotai";
|
|
import { jotaiStore } from "../jotai";
|
|
import { jotaiStore } from "../jotai";
|
|
-import { isPromiseLike } from "../utils";
|
|
|
|
-import { t } from "../i18n";
|
|
|
|
|
|
|
|
-export const libraryItemsAtom = atom<
|
|
|
|
- | { status: "loading"; libraryItems: null; promise: Promise<LibraryItems> }
|
|
|
|
- | { status: "loaded"; libraryItems: LibraryItems }
|
|
|
|
->({ status: "loaded", libraryItems: [] });
|
|
|
|
|
|
+export const libraryItemsAtom = atom<{
|
|
|
|
+ status: "loading" | "loaded";
|
|
|
|
+ isInitialized: boolean;
|
|
|
|
+ libraryItems: LibraryItems;
|
|
|
|
+}>({ status: "loaded", isInitialized: true, libraryItems: [] });
|
|
|
|
|
|
const cloneLibraryItems = (libraryItems: LibraryItems): LibraryItems =>
|
|
const cloneLibraryItems = (libraryItems: LibraryItems): LibraryItems =>
|
|
JSON.parse(JSON.stringify(libraryItems));
|
|
JSON.parse(JSON.stringify(libraryItems));
|
|
@@ -40,12 +39,28 @@ const isUniqueItem = (
|
|
});
|
|
});
|
|
};
|
|
};
|
|
|
|
|
|
|
|
+/** Merges otherItems into localItems. Unique items in otherItems array are
|
|
|
|
+ sorted first. */
|
|
|
|
+export const mergeLibraryItems = (
|
|
|
|
+ localItems: LibraryItems,
|
|
|
|
+ otherItems: LibraryItems,
|
|
|
|
+): LibraryItems => {
|
|
|
|
+ const newItems = [];
|
|
|
|
+ for (const item of otherItems) {
|
|
|
|
+ if (isUniqueItem(localItems, item)) {
|
|
|
|
+ newItems.push(item);
|
|
|
|
+ }
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ return [...newItems, ...localItems];
|
|
|
|
+};
|
|
|
|
+
|
|
class Library {
|
|
class Library {
|
|
- /** cache for currently active promise when initializing/updating libaries
|
|
|
|
- asynchronously */
|
|
|
|
- private libraryItemsPromise: Promise<LibraryItems> | null = null;
|
|
|
|
- /** last resolved libraryItems */
|
|
|
|
|
|
+ /** latest libraryItems */
|
|
private lastLibraryItems: LibraryItems = [];
|
|
private lastLibraryItems: LibraryItems = [];
|
|
|
|
+ /** indicates whether library is initialized with library items (has gone
|
|
|
|
+ * though at least one update) */
|
|
|
|
+ private isInitialized = false;
|
|
|
|
|
|
private app: App;
|
|
private app: App;
|
|
|
|
|
|
@@ -53,95 +68,138 @@ class Library {
|
|
this.app = app;
|
|
this.app = app;
|
|
}
|
|
}
|
|
|
|
|
|
- resetLibrary = async () => {
|
|
|
|
- this.saveLibrary([]);
|
|
|
|
|
|
+ private updateQueue: Promise<LibraryItems>[] = [];
|
|
|
|
+
|
|
|
|
+ private getLastUpdateTask = (): Promise<LibraryItems> | undefined => {
|
|
|
|
+ return this.updateQueue[this.updateQueue.length - 1];
|
|
|
|
+ };
|
|
|
|
+
|
|
|
|
+ private notifyListeners = () => {
|
|
|
|
+ if (this.updateQueue.length > 0) {
|
|
|
|
+ jotaiStore.set(libraryItemsAtom, {
|
|
|
|
+ status: "loading",
|
|
|
|
+ libraryItems: this.lastLibraryItems,
|
|
|
|
+ isInitialized: this.isInitialized,
|
|
|
|
+ });
|
|
|
|
+ } else {
|
|
|
|
+ this.isInitialized = true;
|
|
|
|
+ jotaiStore.set(libraryItemsAtom, {
|
|
|
|
+ status: "loaded",
|
|
|
|
+ libraryItems: this.lastLibraryItems,
|
|
|
|
+ isInitialized: this.isInitialized,
|
|
|
|
+ });
|
|
|
|
+ try {
|
|
|
|
+ this.app.props.onLibraryChange?.(
|
|
|
|
+ cloneLibraryItems(this.lastLibraryItems),
|
|
|
|
+ );
|
|
|
|
+ } catch (error) {
|
|
|
|
+ console.error(error);
|
|
|
|
+ }
|
|
|
|
+ }
|
|
|
|
+ };
|
|
|
|
+
|
|
|
|
+ resetLibrary = () => {
|
|
|
|
+ return this.setLibrary([]);
|
|
};
|
|
};
|
|
|
|
|
|
- /** imports library (currently merges, removing duplicates) */
|
|
|
|
- async importLibrary(
|
|
|
|
|
|
+ /**
|
|
|
|
+ * imports library (from blob or libraryItems), merging with current library
|
|
|
|
+ * (attempting to remove duplicates)
|
|
|
|
+ */
|
|
|
|
+ importLibrary(
|
|
library:
|
|
library:
|
|
| Blob
|
|
| Blob
|
|
| Required<ImportedDataState>["libraryItems"]
|
|
| Required<ImportedDataState>["libraryItems"]
|
|
| Promise<Required<ImportedDataState>["libraryItems"]>,
|
|
| Promise<Required<ImportedDataState>["libraryItems"]>,
|
|
defaultStatus: LibraryItem["status"] = "unpublished",
|
|
defaultStatus: LibraryItem["status"] = "unpublished",
|
|
- ) {
|
|
|
|
- return this.saveLibrary(
|
|
|
|
- new Promise<LibraryItems>(async (resolve, reject) => {
|
|
|
|
- try {
|
|
|
|
- let libraryItems: LibraryItems;
|
|
|
|
- if (library instanceof Blob) {
|
|
|
|
- libraryItems = await loadLibraryFromBlob(library, defaultStatus);
|
|
|
|
- } else {
|
|
|
|
- libraryItems = restoreLibraryItems(await library, defaultStatus);
|
|
|
|
- }
|
|
|
|
-
|
|
|
|
- const existingLibraryItems = this.lastLibraryItems;
|
|
|
|
-
|
|
|
|
- const filteredItems = [];
|
|
|
|
- for (const item of libraryItems) {
|
|
|
|
- if (isUniqueItem(existingLibraryItems, item)) {
|
|
|
|
- filteredItems.push(item);
|
|
|
|
|
|
+ ): Promise<LibraryItems> {
|
|
|
|
+ return this.setLibrary(
|
|
|
|
+ () =>
|
|
|
|
+ new Promise<LibraryItems>(async (resolve, reject) => {
|
|
|
|
+ try {
|
|
|
|
+ let libraryItems: LibraryItems;
|
|
|
|
+ if (library instanceof Blob) {
|
|
|
|
+ libraryItems = await loadLibraryFromBlob(library, defaultStatus);
|
|
|
|
+ } else {
|
|
|
|
+ libraryItems = restoreLibraryItems(await library, defaultStatus);
|
|
}
|
|
}
|
|
- }
|
|
|
|
|
|
|
|
- resolve([...filteredItems, ...existingLibraryItems]);
|
|
|
|
- } catch (error) {
|
|
|
|
- reject(new Error(t("errors.importLibraryError")));
|
|
|
|
- }
|
|
|
|
- }),
|
|
|
|
|
|
+ resolve(mergeLibraryItems(this.lastLibraryItems, libraryItems));
|
|
|
|
+ } catch (error) {
|
|
|
|
+ reject(error);
|
|
|
|
+ }
|
|
|
|
+ }),
|
|
);
|
|
);
|
|
}
|
|
}
|
|
|
|
|
|
- loadLibrary = (): Promise<LibraryItems> => {
|
|
|
|
|
|
+ /**
|
|
|
|
+ * @returns latest cloned libraryItems. Awaits all in-progress updates first.
|
|
|
|
+ */
|
|
|
|
+ getLatestLibrary = (): Promise<LibraryItems> => {
|
|
return new Promise(async (resolve) => {
|
|
return new Promise(async (resolve) => {
|
|
try {
|
|
try {
|
|
- resolve(
|
|
|
|
- cloneLibraryItems(
|
|
|
|
- await (this.libraryItemsPromise || this.lastLibraryItems),
|
|
|
|
- ),
|
|
|
|
- );
|
|
|
|
|
|
+ const libraryItems = await (this.getLastUpdateTask() ||
|
|
|
|
+ this.lastLibraryItems);
|
|
|
|
+ if (this.updateQueue.length > 0) {
|
|
|
|
+ resolve(this.getLatestLibrary());
|
|
|
|
+ } else {
|
|
|
|
+ resolve(cloneLibraryItems(libraryItems));
|
|
|
|
+ }
|
|
} catch (error) {
|
|
} catch (error) {
|
|
return resolve(this.lastLibraryItems);
|
|
return resolve(this.lastLibraryItems);
|
|
}
|
|
}
|
|
});
|
|
});
|
|
};
|
|
};
|
|
|
|
|
|
- saveLibrary = async (items: LibraryItems | Promise<LibraryItems>) => {
|
|
|
|
- const prevLibraryItems = this.lastLibraryItems;
|
|
|
|
- try {
|
|
|
|
- let nextLibraryItems;
|
|
|
|
- if (isPromiseLike(items)) {
|
|
|
|
- const promise = items.then((items) => cloneLibraryItems(items));
|
|
|
|
- this.libraryItemsPromise = promise;
|
|
|
|
- jotaiStore.set(libraryItemsAtom, {
|
|
|
|
- status: "loading",
|
|
|
|
- promise,
|
|
|
|
- libraryItems: null,
|
|
|
|
- });
|
|
|
|
- nextLibraryItems = await promise;
|
|
|
|
- } else {
|
|
|
|
- nextLibraryItems = cloneLibraryItems(items);
|
|
|
|
- }
|
|
|
|
|
|
+ setLibrary = (
|
|
|
|
+ /**
|
|
|
|
+ * LibraryItems that will replace current items. Can be a function which
|
|
|
|
+ * will be invoked after all previous tasks are resolved
|
|
|
|
+ * (this is the prefered way to update the library to avoid race conditions,
|
|
|
|
+ * but you'll want to manually merge the library items in the callback
|
|
|
|
+ * - which is what we're doing in Library.importLibrary()).
|
|
|
|
+ *
|
|
|
|
+ * If supplied promise is rejected with AbortError, we swallow it and
|
|
|
|
+ * do not update the library.
|
|
|
|
+ */
|
|
|
|
+ libraryItems:
|
|
|
|
+ | LibraryItems
|
|
|
|
+ | Promise<LibraryItems>
|
|
|
|
+ | ((
|
|
|
|
+ latestLibraryItems: LibraryItems,
|
|
|
|
+ ) => LibraryItems | Promise<LibraryItems>),
|
|
|
|
+ ): Promise<LibraryItems> => {
|
|
|
|
+ const task = new Promise<LibraryItems>(async (resolve, reject) => {
|
|
|
|
+ try {
|
|
|
|
+ await this.getLastUpdateTask();
|
|
|
|
|
|
- this.lastLibraryItems = nextLibraryItems;
|
|
|
|
- this.libraryItemsPromise = null;
|
|
|
|
|
|
+ if (typeof libraryItems === "function") {
|
|
|
|
+ libraryItems = libraryItems(this.lastLibraryItems);
|
|
|
|
+ }
|
|
|
|
|
|
- jotaiStore.set(libraryItemsAtom, {
|
|
|
|
- status: "loaded",
|
|
|
|
- libraryItems: nextLibraryItems,
|
|
|
|
- });
|
|
|
|
- await this.app.props.onLibraryChange?.(
|
|
|
|
- cloneLibraryItems(nextLibraryItems),
|
|
|
|
- );
|
|
|
|
- } catch (error: any) {
|
|
|
|
- this.lastLibraryItems = prevLibraryItems;
|
|
|
|
- this.libraryItemsPromise = null;
|
|
|
|
- jotaiStore.set(libraryItemsAtom, {
|
|
|
|
- status: "loaded",
|
|
|
|
- libraryItems: prevLibraryItems,
|
|
|
|
|
|
+ this.lastLibraryItems = cloneLibraryItems(await libraryItems);
|
|
|
|
+
|
|
|
|
+ resolve(this.lastLibraryItems);
|
|
|
|
+ } catch (error: any) {
|
|
|
|
+ reject(error);
|
|
|
|
+ }
|
|
|
|
+ })
|
|
|
|
+ .catch((error) => {
|
|
|
|
+ if (error.name === "AbortError") {
|
|
|
|
+ console.warn("Library update aborted by user");
|
|
|
|
+ return this.lastLibraryItems;
|
|
|
|
+ }
|
|
|
|
+ throw error;
|
|
|
|
+ })
|
|
|
|
+ .finally(() => {
|
|
|
|
+ this.updateQueue = this.updateQueue.filter((_task) => _task !== task);
|
|
|
|
+ this.notifyListeners();
|
|
});
|
|
});
|
|
- throw error;
|
|
|
|
- }
|
|
|
|
|
|
+
|
|
|
|
+ this.updateQueue.push(task);
|
|
|
|
+ this.notifyListeners();
|
|
|
|
+
|
|
|
|
+ return task;
|
|
};
|
|
};
|
|
}
|
|
}
|
|
|
|
|