diff --git a/package-lock.json b/package-lock.json index dbb4c292..4561dcb4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,7 +23,7 @@ "@react-rxjs/utils": "^0.9.7", "arg": "^5.0.2", "react-markdown": "^9.0.1", - "reactjrx": "^1.74.1", + "reactjrx": "^1.79.1", "remark-gfm": "^4.0.0", "sharp": "^0.33.2" }, @@ -38491,9 +38491,9 @@ } }, "node_modules/reactjrx": { - "version": "1.79.0", - "resolved": "https://registry.npmjs.org/reactjrx/-/reactjrx-1.79.0.tgz", - "integrity": "sha512-2peUWaT4pmH5bx/yY8W5CWDbp6bcO+/VY0C7EX/m/Hd6gmRlfiyEl9LrpSSqSw+wcgXYwzNzmCoXZPx6vrxyew==", + "version": "1.79.1", + "resolved": "https://registry.npmjs.org/reactjrx/-/reactjrx-1.79.1.tgz", + "integrity": "sha512-tcDM9sDobZ4j8SF1QET11DSk9arzpQu/gx5MfMmHqHSEdJ7G2ixLLjHjfLfx/AlPIJF9vayKkh3rvY3zA71DBw==", "peerDependencies": { "react": "18", "react-dom": "18", diff --git a/package.json b/package.json index 474d51c0..862a7bcc 100644 --- a/package.json +++ b/package.json @@ -70,7 +70,7 @@ "@react-rxjs/utils": "^0.9.7", "arg": "^5.0.2", "react-markdown": "^9.0.1", - "reactjrx": "^1.74.1", + "reactjrx": "^1.79.1", "remark-gfm": "^4.0.0", "sharp": "^0.33.2" } diff --git a/packages/api/src/functions/refreshMetadataCollection/handler.ts b/packages/api/src/functions/refreshMetadataCollection/handler.ts index 360eb53b..cb110288 100644 --- a/packages/api/src/functions/refreshMetadataCollection/handler.ts +++ b/packages/api/src/functions/refreshMetadataCollection/handler.ts @@ -4,10 +4,9 @@ import { getNormalizedHeader } from "@libs/utils" import schema from "./schema" import { InvokeCommand } from "@aws-sdk/client-lambda" import { STAGE } from "src/constants" +import { COLLECTION_METADATA_LOCK_MN } from "@oboku/shared" import { lock } from "@libs/supabase/lock" -const LOCK_MAX_DURATION_MN = 5 - const lambda: ValidatedEventAPIGatewayProxyEvent = async ( event ) => { @@ -28,7 +27,7 @@ const lambda: ValidatedEventAPIGatewayProxyEvent = async ( const lockId = `metadata-collection_${event.body.collectionId}` - const { alreadyLocked } = await lock(lockId, LOCK_MAX_DURATION_MN) + const { alreadyLocked } = await lock(lockId, COLLECTION_METADATA_LOCK_MN) if (!alreadyLocked) { await client.send(command) diff --git a/packages/api/src/libs/collections/refreshMetadata.ts b/packages/api/src/libs/collections/refreshMetadata.ts index cf149352..8e7b6d6c 100644 --- a/packages/api/src/libs/collections/refreshMetadata.ts +++ b/packages/api/src/libs/collections/refreshMetadata.ts @@ -29,92 +29,125 @@ export const refreshMetadata = async ( return } - // we always get updated link data - // we take a chance to update the collection from its resource - const metadataLink = - collection.linkResourceId && collection.linkType - ? await pluginFacade.getMetadata({ - resourceId: collection.linkResourceId, - linkType: collection.linkType, - credentials - }) + try { + await atomicUpdate(db, "obokucollection", collection._id, (old) => { + const wasAlreadyInitialized = + old.metadataUpdateStatus === "fetching" && old.lastMetadataStartedAt + + if (wasAlreadyInitialized) return old + + return { + ...old, + metadataUpdateStatus: "fetching" as const, + lastMetadataStartedAt: new Date().toISOString() + } + }) + + // we always get updated link data + // we take a chance to update the collection from its resource + const metadataLink = + collection.linkResourceId && collection.linkType + ? await pluginFacade.getMetadata({ + resourceId: collection.linkResourceId, + linkType: collection.linkType, + credentials + }) + : undefined + + const linkModifiedAt = metadataLink?.modifiedAt + ? new Date(metadataLink.modifiedAt) + : undefined + const collectionMetadataUpdatedAt = collection?.lastMetadataUpdatedAt + ? new Date(collection?.lastMetadataUpdatedAt) : undefined - const linkModifiedAt = metadataLink?.modifiedAt - ? new Date(metadataLink.modifiedAt) - : undefined - const collectionMetadataUpdatedAt = collection?.lastMetadataUpdatedAt - ? new Date(collection?.lastMetadataUpdatedAt) - : undefined - - const isCollectionAlreadyUpdatedFromLink = - linkModifiedAt && - collectionMetadataUpdatedAt && - linkModifiedAt.getTime() < collectionMetadataUpdatedAt.getTime() - - /** - * @important - * In case of soft refresh, we only update if the link is updated - * or if there is no link but the collection has not yet fetched metadata. - * This soft mode is mostly used during sync. - */ - - if (soft && isCollectionAlreadyUpdatedFromLink) { - Logger.log(`${collection._id} already has metadata, ignoring it!`) - - return { - statusCode: 200, - body: JSON.stringify({}) + const isCollectionAlreadyUpdatedFromLink = + linkModifiedAt && + collectionMetadataUpdatedAt && + linkModifiedAt.getTime() < collectionMetadataUpdatedAt.getTime() + + /** + * @important + * In case of soft refresh, we only update if the link is updated + * or if there is no link but the collection has not yet fetched metadata. + * This soft mode is mostly used during sync. + */ + + if (soft && isCollectionAlreadyUpdatedFromLink) { + Logger.log(`${collection._id} already has metadata, ignoring it!`) + + return { + statusCode: 200, + body: JSON.stringify({}) + } } - } - if (soft && !metadataLink && collection.lastMetadataUpdatedAt) { - Logger.log( - `${collection._id} does not have link and is already refreshed, ignoring it!` - ) + if (soft && !metadataLink && collection.lastMetadataUpdatedAt) { + Logger.log( + `${collection._id} does not have link and is already refreshed, ignoring it!` + ) - return { - statusCode: 200, - body: JSON.stringify({}) + return { + statusCode: 200, + body: JSON.stringify({}) + } } - } - const metadataUser = collection.metadata?.find((item) => item.type === "user") - - const directivesFromLink = directives.extractDirectivesFromName( - metadataLink?.name ?? "" - ) + const metadataUser = collection.metadata?.find( + (item) => item.type === "user" + ) - const title = directives.removeDirectiveFromString( - metadataLink?.name ?? metadataUser?.title ?? "" - ) - const year = directivesFromLink.year ?? metadataUser?.startYear + const directivesFromLink = directives.extractDirectivesFromName( + metadataLink?.name ?? "" + ) - const updatedMetadataList = await fetchMetadata( - { title, year: year ? String(year) : undefined }, - { withGoogle: true, googleApiKey } - ) + const title = directives.removeDirectiveFromString( + metadataLink?.name ?? metadataUser?.title ?? "" + ) + const year = directivesFromLink.year ?? metadataUser?.startYear - await atomicUpdate(db, "obokucollection", collection._id, (old) => { - const persistentMetadataList = - old.metadata?.filter((entry) => - (["user"] as CollectionMetadata["type"][]).includes(entry.type) - ) ?? [] + const updatedMetadataList = await fetchMetadata( + { title, year: year ? String(year) : undefined }, + { withGoogle: true, googleApiKey } + ) - const linkMetadata: CollectionMetadata = { - type: "link", - ...old.metadata?.find((item) => item.type === "link"), - title: metadataLink?.name - } + await atomicUpdate(db, "obokucollection", collection._id, (old) => { + const persistentMetadataList = + old.metadata?.filter((entry) => + (["user"] as CollectionMetadata["type"][]).includes(entry.type) + ) ?? [] + + const linkMetadata: CollectionMetadata = { + type: "link", + ...old.metadata?.find((item) => item.type === "link"), + title: metadataLink?.name + } + + return { + ...old, + lastMetadataUpdatedAt: new Date().toISOString(), + metadataUpdateStatus: "idle", + lastMetadataUpdateError: null, + metadata: [ + ...persistentMetadataList, + ...updatedMetadataList, + linkMetadata + ] + } satisfies CollectionDocType + }) + } catch (error) { + await atomicUpdate( + db, + "obokucollection", + collection._id, + (old) => + ({ + ...old, + metadataUpdateStatus: "idle", + lastMetadataUpdateError: "unknown" + }) satisfies CollectionDocType + ) - return { - ...old, - lastMetadataUpdatedAt: new Date().toISOString(), - metadata: [ - ...persistentMetadataList, - ...updatedMetadataList, - linkMetadata - ] - } - }) + throw error + } } diff --git a/packages/shared/src/docTypes.ts b/packages/shared/src/docTypes.ts index 7f36874b..452e8caf 100644 --- a/packages/shared/src/docTypes.ts +++ b/packages/shared/src/docTypes.ts @@ -117,6 +117,9 @@ export type CollectionDocType = CommonBase & { syncAt?: string | null createdAt: string lastMetadataUpdatedAt?: string + lastMetadataStartedAt?: string + metadataUpdateStatus?: "fetching" | "idle" + lastMetadataUpdateError?: null | string type?: "series" | "shelve" metadata?: CollectionMetadata[] } diff --git a/packages/shared/src/metadata/index.ts b/packages/shared/src/metadata/index.ts index f1d1fa6e..5a1e4020 100644 --- a/packages/shared/src/metadata/index.ts +++ b/packages/shared/src/metadata/index.ts @@ -47,3 +47,5 @@ export type CollectionMetadata = { */ type: "googleBookApi" | "link" | "user" | "biblioreads" | "comicvine" | "mangaupdates" } + +export const COLLECTION_METADATA_LOCK_MN = 5 \ No newline at end of file diff --git a/packages/web/src/books/drawer/BookActionsDrawer.tsx b/packages/web/src/books/drawer/BookActionsDrawer.tsx index 3e0a694a..1cc6bcd0 100644 --- a/packages/web/src/books/drawer/BookActionsDrawer.tsx +++ b/packages/web/src/books/drawer/BookActionsDrawer.tsx @@ -16,7 +16,7 @@ import { import { useNavigate } from "react-router-dom" import { useRemoveDownloadFile } from "../../download/useRemoveDownloadFile" import { ROUTES } from "../../constants" -import { useAtomicUpdateBook, useRefreshBookMetadata } from "../helpers" +import { useAtomicUpdateBook } from "../helpers" import { Drawer, Divider, @@ -41,6 +41,7 @@ import { import { signal, useLiveRef, useSignalValue } from "reactjrx" import { useRemoveHandler } from "./useRemoveHandler" import { getMetadataFromBook } from "../getMetadataFromBook" +import { useRefreshBookMetadata } from "../useRefreshBookMetadata" type SignalState = { openedWith: undefined | string diff --git a/packages/web/src/books/effects.ts b/packages/web/src/books/effects.ts index 92e2aaf3..2588ee43 100644 --- a/packages/web/src/books/effects.ts +++ b/packages/web/src/books/effects.ts @@ -20,9 +20,9 @@ import { upsertBookLinkEnd, upsertBookLinkEnd$ } from "./triggers" -import { useRefreshBookMetadata } from "./helpers" import { isDefined, useSubscribeEffect } from "reactjrx" import { latestDatabase$ } from "../rxdb/useCreateDatabase" +import { useRefreshBookMetadata } from "./useRefreshBookMetadata" const useUpsertBookLinkActionEffect = () => { const { db: database } = useDatabase() diff --git a/packages/web/src/books/helpers.ts b/packages/web/src/books/helpers.ts index c99a941b..f7f6fe12 100644 --- a/packages/web/src/books/helpers.ts +++ b/packages/web/src/books/helpers.ts @@ -13,14 +13,12 @@ import { AtomicUpdateFunction } from "rxdb" import { useLock } from "../common/BlockingBackdrop" import { useNetworkState } from "react-use" import { useDialogManager } from "../dialog" -import { useSyncReplicate } from "../rxdb/replication/useSyncReplicate" -import { catchError, EMPTY, from, map, switchMap } from "rxjs" +import { from } from "rxjs" import { useRemoveBookFromDataSource } from "../plugins/useRemoveBookFromDataSource" -import { usePluginRefreshMetadata } from "../plugins/usePluginRefreshMetadata" import { useMutation } from "reactjrx" import { isPluginError } from "../plugins/plugin-front" -import { httpClient } from "../http/httpClient" import { getMetadataFromBook } from "./getMetadataFromBook" +import { useRefreshBookMetadata } from "./useRefreshBookMetadata" export const useRemoveBook = () => { const removeDownload = useRemoveDownloadFile() @@ -110,79 +108,6 @@ export const useAtomicUpdateBook = () => { return [updater] as [typeof updater] } -export const useRefreshBookMetadata = () => { - const { db: database } = useDatabase() - const [updateBook] = useAtomicUpdateBook() - const dialog = useDialogManager() - const network = useNetworkState() - const { mutateAsync: sync } = useSyncReplicate() - const refreshPluginMetadata = usePluginRefreshMetadata() - - return async (bookId: string) => { - try { - if (!network.online) { - return dialog({ preset: "OFFLINE" }) - } - - const book = await database?.book - .findOne({ selector: { _id: bookId } }) - .exec() - - const firstLink = await database?.link - .findOne({ selector: { _id: book?.links[0] } }) - .exec() - - if (!firstLink) { - Report.warn(`Trying to refresh metadata of file item ${bookId}`) - - return - } - - const { data: pluginMetadata } = await refreshPluginMetadata({ - linkType: firstLink.type - }) - - if (!database) return - - from( - updateBook(bookId, (old) => ({ - ...old, - metadataUpdateStatus: "fetching" - })) - ) - .pipe( - switchMap(() => from(sync([database.link, database.book]))), - switchMap(() => - from(httpClient.refreshBookMetadata(bookId, pluginMetadata)) - ), - catchError((e) => - from( - updateBook(bookId, (old) => ({ - ...old, - metadataUpdateStatus: null, - lastMetadataUpdateError: "unknown" - })) - ).pipe( - map((_) => { - throw e - }) - ) - ), - catchError((e) => { - Report.error(e) - - return EMPTY - }) - ) - .subscribe() - } catch (e) { - if (isPluginError(e) && e.code === "cancelled") return - - Report.error(e) - } - } -} - export const useAddCollectionToBook = () => { const { db } = useDatabase() diff --git a/packages/web/src/books/useRefreshBookMetadata.ts b/packages/web/src/books/useRefreshBookMetadata.ts new file mode 100644 index 00000000..799d01e6 --- /dev/null +++ b/packages/web/src/books/useRefreshBookMetadata.ts @@ -0,0 +1,83 @@ +import { useNetworkState } from "react-use" +import { from, switchMap, catchError, map, EMPTY } from "rxjs" +import { useDialogManager } from "../dialog" +import { httpClient } from "../http/httpClient" +import { isPluginError } from "../plugins/plugin-front" +import { usePluginRefreshMetadata } from "../plugins/usePluginRefreshMetadata" +import { useDatabase } from "../rxdb" +import { useSyncReplicate } from "../rxdb/replication/useSyncReplicate" +import { useAtomicUpdateBook } from "./helpers" +import { Report } from "../debug/report.shared" + +export const useRefreshBookMetadata = () => { + const { db: database } = useDatabase() + const [updateBook] = useAtomicUpdateBook() + const dialog = useDialogManager() + const network = useNetworkState() + const { mutateAsync: sync } = useSyncReplicate() + const refreshPluginMetadata = usePluginRefreshMetadata() + + return async (bookId: string) => { + try { + if (!network.online) { + return dialog({ preset: "OFFLINE" }) + } + + const book = await database?.book + .findOne({ selector: { _id: bookId } }) + .exec() + + const firstLink = await database?.link + .findOne({ selector: { _id: book?.links[0] } }) + .exec() + + if (!firstLink) { + Report.warn(`Trying to refresh metadata of file item ${bookId}`) + + return + } + + const { data: pluginMetadata } = await refreshPluginMetadata({ + linkType: firstLink.type + }) + + if (!database) return + + from( + updateBook(bookId, (old) => ({ + ...old, + metadataUpdateStatus: "fetching" + })) + ) + .pipe( + switchMap(() => from(sync([database.link, database.book]))), + switchMap(() => + from(httpClient.refreshBookMetadata(bookId, pluginMetadata)) + ), + catchError((e) => + from( + updateBook(bookId, (old) => ({ + ...old, + metadataUpdateStatus: null, + lastMetadataUpdateError: "unknown" + })) + ).pipe( + map((_) => { + throw e + }) + ) + ), + catchError((e) => { + Report.error(e) + + return EMPTY + }) + ) + .subscribe() + } catch (e) { + if (isPluginError(e) && e.code === "cancelled") return + + Report.error(e) + } + } +} diff --git a/packages/web/src/collections/databaseHelpers.ts b/packages/web/src/collections/databaseHelpers.ts index a35817be..1a772cf4 100644 --- a/packages/web/src/collections/databaseHelpers.ts +++ b/packages/web/src/collections/databaseHelpers.ts @@ -1,6 +1,7 @@ import { CollectionDocType } from "@oboku/shared" import { keyBy } from "lodash" import { Database } from "../rxdb" +import { Observable, ObservedValueOf, from, map, switchMap } from "rxjs" export type Collection = CollectionDocType @@ -12,3 +13,23 @@ export const getCollectionsByIds = async (database: Database) => { "_id" ) } + +export const getCollectionById = (database: Database, id: string) => { + return from(database.collections.obokucollection.findOne(id).exec()).pipe( + map((result) => result?.toJSON()) + ) +} + +type CollectionById = ObservedValueOf> + +export const withLatestCollectionById = + (database: Database, id: string) => + (stream: Observable) => { + return stream.pipe( + switchMap((value) => + getCollectionById(database, id).pipe( + map((res) => [value, res] as [T, CollectionById]) + ) + ) + ) + } diff --git a/packages/web/src/collections/getMetadataFromCollection.ts b/packages/web/src/collections/getMetadataFromCollection.ts index 80244100..ca444319 100644 --- a/packages/web/src/collections/getMetadataFromCollection.ts +++ b/packages/web/src/collections/getMetadataFromCollection.ts @@ -32,6 +32,9 @@ export const getMetadataFromCollection = ( ): Return => { const list = item?.metadata ?? [] + const userMetadata = item?.metadata?.find((item) => item.type === "user") + const linkMetadata = item?.metadata?.find((item) => item.type === "link") + const orderedList = [...list].sort((a, b) => { // mangaupdates has priority if (a.type === "mangaupdates" && b.type === "biblioreads") return 1 @@ -50,5 +53,8 @@ export const getMetadataFromCollection = ( } satisfies Return }, {} as Return) - return reducedMetadata + return { + ...reducedMetadata, + title: userMetadata?.title ?? linkMetadata?.title ?? reducedMetadata.title + } } diff --git a/packages/web/src/collections/useRefreshCollectionMetadata.ts b/packages/web/src/collections/useRefreshCollectionMetadata.ts new file mode 100644 index 00000000..f1c926f2 --- /dev/null +++ b/packages/web/src/collections/useRefreshCollectionMetadata.ts @@ -0,0 +1,83 @@ +import { catchError, from, map, of, switchMap, throwError } from "rxjs" +import { usePluginRefreshMetadata } from "../plugins/usePluginRefreshMetadata" +import { useSyncReplicate } from "../rxdb/replication/useSyncReplicate" +import { useUpdateCollection } from "./useUpdateCollection" +import { httpClient } from "../http/httpClient" +import { isPluginError } from "../plugins/plugin-front" +import { useMutation } from "reactjrx" +import { useWithNetwork } from "../network/useWithNetwork" +import { getLatestDatabase } from "../rxdb/useCreateDatabase" +import { OfflineError } from "../errors" +import { getCollectionById } from "./databaseHelpers" + +export const useRefreshCollectionMetadata = () => { + const { mutateAsync: updateCollection } = useUpdateCollection() + const { mutateAsync: sync } = useSyncReplicate() + const getRefreshMetadataPluginData = usePluginRefreshMetadata() + const withNetwork = useWithNetwork() + + return useMutation({ + mutationFn: (collectionId: string) => + getLatestDatabase().pipe( + withNetwork, + switchMap((db) => { + const collection$ = getCollectionById(db, collectionId) + + return collection$.pipe( + switchMap((collection) => { + if (!collection) throw new Error("Invalid collection id") + + const pluginData$ = from( + getRefreshMetadataPluginData({ + linkType: collection.linkType ?? "" + }) + ) + + return pluginData$.pipe( + switchMap(({ data: pluginMetadata }) => { + return from( + updateCollection({ + _id: collectionId, + metadataUpdateStatus: "fetching", + lastMetadataStartedAt: new Date().toISOString() + }) + ).pipe( + switchMap(() => from(sync([db.obokucollection]))), + switchMap(() => + from( + httpClient.refreshCollectionMetadata( + collectionId, + pluginMetadata + ) + ) + ), + catchError((error) => { + return from( + updateCollection({ + _id: collectionId, + metadataUpdateStatus: "idle" + }) + ).pipe( + map(() => { + throw error + }) + ) + }) + ) + }) + ) + }) + ) + }), + catchError((e) => { + if ( + (isPluginError(e) && e.code === "cancelled") || + e instanceof OfflineError + ) + return of(null) + + throw e + }) + ) + }) +} diff --git a/packages/web/src/errors.tsx b/packages/web/src/errors.tsx index d21107fc..1ee8b4f9 100644 --- a/packages/web/src/errors.tsx +++ b/packages/web/src/errors.tsx @@ -22,6 +22,8 @@ export const createServerError = async (response: Response) => { export class CancelError extends Error {} +export class OfflineError extends Error {} + export const isCancelError = (error: unknown) => error instanceof CancelError export const isApiError = (error: unknown): error is HttpApiError => { @@ -50,7 +52,6 @@ export const ErrorMessage = ({ error }: { error: unknown }) => { ) } - export class ServerError extends Error { constructor( public response: Response, diff --git a/packages/web/src/http/httpClient.ts b/packages/web/src/http/httpClient.ts index d9b137b4..c9a06630 100644 --- a/packages/web/src/http/httpClient.ts +++ b/packages/web/src/http/httpClient.ts @@ -76,6 +76,18 @@ class HttpClient { } }) + refreshCollectionMetadata = ( + collectionId: string, + credentials?: { [key: string]: any } + ) => + this.post({ + url: `${API_URI}/refresh-metadata-collection`, + body: { collectionId }, + headers: { + "oboku-credentials": JSON.stringify(credentials ?? {}) + } + }) + syncDataSource = ( dataSourceId: string, credentials?: { [key: string]: any } diff --git a/packages/web/src/library/collections/CollectionActionsDrawer/CollectionActionsDrawer.tsx b/packages/web/src/library/collections/CollectionActionsDrawer/CollectionActionsDrawer.tsx index bbad9ccd..a1cb140e 100644 --- a/packages/web/src/library/collections/CollectionActionsDrawer/CollectionActionsDrawer.tsx +++ b/packages/web/src/library/collections/CollectionActionsDrawer/CollectionActionsDrawer.tsx @@ -13,7 +13,8 @@ import { DeleteForeverRounded, LibraryAddRounded, ThumbDownOutlined, - ThumbUpOutlined + ThumbUpOutlined, + SyncRounded } from "@mui/icons-material" import { ManageCollectionBooksDialog } from "../../../collections/ManageCollectionBooksDialog" import { useModalNavigationControl } from "../../../navigation/useModalNavigationControl" @@ -25,6 +26,10 @@ import { } from "./useCollectionActionsDrawer" import { useUpdateCollectionBooks } from "../../../collections/useUpdateCollectionBooks" import { EditCollectionDialog } from "./EditCollectionDialog" +import { useRefreshCollectionMetadata } from "../../../collections/useRefreshCollectionMetadata" +import { useCollection } from "../../../collections/states" +import { differenceInMinutes } from "date-fns" +import { COLLECTION_METADATA_LOCK_MN } from "@oboku/shared" export const CollectionActionsDrawer: FC<{}> = () => { const { openedWith: collectionId } = useSignalValue( @@ -37,6 +42,8 @@ export const CollectionActionsDrawer: FC<{}> = () => { const { mutate: removeCollection } = useRemoveCollection() const [isManageBookDialogOpened, setIsManageBookDialogOpened] = useState(false) + const { mutate: refreshCollectionMetadata, ...rest } = + useRefreshCollectionMetadata() const subActionOpened = !!isEditCollectionDialogOpenedWithId const { mutate: updateCollectionBooks } = useUpdateCollectionBooks() const { closeModalWithNavigation } = useModalNavigationControl( @@ -49,7 +56,7 @@ export const CollectionActionsDrawer: FC<{}> = () => { }, collectionId ) - + const { data: collection } = useCollection({ id: collectionId }) const opened = !!collectionId const onRemoveCollection = (id: string) => { @@ -58,6 +65,13 @@ export const CollectionActionsDrawer: FC<{}> = () => { id && removeCollection({ _id: id }) } + const isRefreshingMetadata = !!( + collection?.metadataUpdateStatus === "fetching" && + collection.lastMetadataStartedAt && + differenceInMinutes(new Date(), collection.lastMetadataStartedAt) < + COLLECTION_METADATA_LOCK_MN + ) + if (!collectionId) return null return ( @@ -126,6 +140,25 @@ export const CollectionActionsDrawer: FC<{}> = () => { + {collection?.type === "series" && ( + { + refreshCollectionMetadata(collectionId) + }} + disabled={isRefreshingMetadata} + > + + + + + + )} diff --git a/packages/web/src/links/helpers.ts b/packages/web/src/links/helpers.ts index 0d1228ef..a5c19af9 100644 --- a/packages/web/src/links/helpers.ts +++ b/packages/web/src/links/helpers.ts @@ -1,9 +1,9 @@ import { LinkDocType } from "@oboku/shared" -import { useRefreshBookMetadata } from "../books/helpers" import { Database, useDatabase } from "../rxdb" import { useCallback } from "react" import { Report } from "../debug/report.shared" import { from } from "rxjs" +import { useRefreshBookMetadata } from "../books/useRefreshBookMetadata" type EditLinkPayload = Partial & Required> diff --git a/packages/web/src/network/useWithNetwork.ts b/packages/web/src/network/useWithNetwork.ts new file mode 100644 index 00000000..881c4bbd --- /dev/null +++ b/packages/web/src/network/useWithNetwork.ts @@ -0,0 +1,20 @@ +import { Observable, tap } from "rxjs" +import { useDialogManager } from "../dialog" +import { useNetworkState } from "react-use" +import { OfflineError } from "../errors" + +export const useWithNetwork = () => { + const dialog = useDialogManager() + const networkState = useNetworkState() + + return (stream: Observable) => + stream.pipe( + tap(() => { + if (!networkState.online) { + dialog({ preset: "OFFLINE" }) + + throw new OfflineError() + } + }) + ) +} diff --git a/packages/web/src/queries/queryClient.ts b/packages/web/src/queries/queryClient.ts index 9c2818a5..1f39cffa 100644 --- a/packages/web/src/queries/queryClient.ts +++ b/packages/web/src/queries/queryClient.ts @@ -2,6 +2,14 @@ import { QueryClient } from "reactjrx" export const queryClient = new QueryClient({ defaultOptions: { + mutations: { + /** + * @important + * Same as for queries, most of mutations are offline by default. + * Don't forget to change it when needed + */ + networkMode: "always" + }, queries: { /** * @important diff --git a/packages/web/src/rxdb/collections/collection.ts b/packages/web/src/rxdb/collections/collection.ts index 4ef6d4b3..4ab05c5a 100644 --- a/packages/web/src/rxdb/collections/collection.ts +++ b/packages/web/src/rxdb/collections/collection.ts @@ -68,6 +68,9 @@ export const collectionSchema: RxJsonSchema< createdAt: { type: "string" }, modifiedAt: { type: ["string", "null"] }, lastMetadataUpdatedAt: { type: ["string"] }, + lastMetadataStartedAt: { type: ["string"] }, + lastMetadataUpdateError: { type: ["string", "null"] }, + metadataUpdateStatus: { type: ["string"] }, syncAt: { type: ["string"] }, dataSourceId: { type: ["string", "null"] }, metadata: { type: ["array"] }, diff --git a/packages/web/src/rxdb/databases.ts b/packages/web/src/rxdb/databases.ts index e22be8e6..bb4dbf47 100644 --- a/packages/web/src/rxdb/databases.ts +++ b/packages/web/src/rxdb/databases.ts @@ -84,7 +84,7 @@ export const createDatabase = async () => { }) const db = await createRxDatabase({ - name: "oboku-32", + name: "oboku-34", // NOTICE: Schema validation can be CPU expensive and increases your build size. // You should always use a schema validation plugin in development mode. // For most use cases, you should not use a validation plugin in production.