diff --git a/AGENTS.md b/AGENTS.md index 2b98bea2e..71e7a3368 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,8 +1,23 @@ # AGENTS.md -## Engineering principles for Codex in this repository - -When implementing changes in this codebase, prioritize consistency and consolidation over novelty. +## Engineering principles in this repository + +- When implementing changes in this codebase, prioritize consistency and consolidation over novelty. + +### Code comments + +- **Default to no comments.** Code, names, and types are the documentation. Add a comment only when a future reader cannot recover the intent from the code itself. +- **Never restate what the code does.** If the comment paraphrases the next line, delete it. +- **Never explain a change you are making.** Comments are about the resulting code, not about the diff or the review that produced it. +- **Never narrate trivial mechanics** ("seed the form", "fall back to default", "guard for unmount", "cleanup on success", "preserves siblings", etc.). If the function/variable name doesn't already say that, rename instead of commenting. +- **No "why" boilerplate.** Don't write comments like "we do X so that Y" unless Y is genuinely surprising and not visible from reading the function. A two-word identifier (`safeFallback`, `corsHeaders`, …) usually beats a three-line comment. +- **Allowed comments** (rare, deliberate): + - Workarounds for third-party bugs, with a link or version reference. + - Non-obvious invariants or ordering constraints that aren't enforced by the type system. + - Explanations *required* by another rule in this file (e.g. the `as` rule, the MUI polymorphic-strip cast rule). + - Spec citations when the code implements a non-trivial part of an external spec. +- **JSDoc on exported APIs is fine** when it documents the *contract* (parameters, return shape, edge cases) — not when it restates the implementation. +- **Delete stale comments aggressively.** A comment that lies is worse than no comment. ### Reuse before creating diff --git a/apps/api/package.json b/apps/api/package.json index 0f5e8ff6b..366fccad1 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -29,6 +29,7 @@ "@nestjs/platform-express": "^11.0.1", "@nestjs/schedule": "^6.0.0", "@nestjs/typeorm": "^11.0.0", + "@oboku/archive-metadata": "^0.1.0", "@oboku/shared": "^0.8.0", "@oboku/synology": "^0.1.0", "@sentry/nestjs": "^10.25.0", diff --git a/apps/api/src/books/books-metadata.service.ts b/apps/api/src/books/books-metadata.service.ts index 8807f15c1..a4663d464 100644 --- a/apps/api/src/books/books-metadata.service.ts +++ b/apps/api/src/books/books-metadata.service.ts @@ -18,11 +18,11 @@ export class BooksMetadataService { ) {} public refreshMetadata = async ( - body: { bookId: string }, + body: { bookId: string; force?: boolean }, providerCredentials: ProviderApiCredentials, userEmail: string, ) => { - const { bookId } = body + const { bookId, force } = body const userNameHex = emailToNameHex(userEmail) @@ -63,6 +63,7 @@ export class BooksMetadataService { link, googleApiKey: this.appConfigService.GOOGLE_API_KEY, db, + force, }, this.appConfigService, this.coversService, diff --git a/apps/api/src/books/books.controller.ts b/apps/api/src/books/books.controller.ts index 100756c71..d3cef8c23 100644 --- a/apps/api/src/books/books.controller.ts +++ b/apps/api/src/books/books.controller.ts @@ -28,17 +28,17 @@ export class BooksController implements OnModuleInit { @Post("metadata/refresh") async metadataRefresh( - @Body() { bookId, providerCredentials }: RefreshBookMetadataRequest, + @Body() { bookId, providerCredentials, force }: RefreshBookMetadataRequest, @WithAuthUser() user: AuthUser, ) { - this.logger.log("metadataRefresh", bookId) + this.logger.log("metadataRefresh", bookId, { force }) this.taskQueueService.enqueue( this.BOOKS_METADATA_REFRESH_QUEUE, () => from( this.booksMetadataService.refreshMetadata( - { bookId }, + { bookId, force }, providerCredentials, user.email, ), diff --git a/apps/api/src/config/AppConfigService.ts b/apps/api/src/config/AppConfigService.ts index 76ca37e22..e64a296ae 100644 --- a/apps/api/src/config/AppConfigService.ts +++ b/apps/api/src/config/AppConfigService.ts @@ -104,10 +104,6 @@ export class AppConfigService { * ------------------------------------------------------------ */ - get COVERS_ALLOWED_EXT() { - return [".jpg", ".jpeg", ".png"] - } - get COVERS_BUCKET_NAME() { return this.config.get("COVERS_BUCKET_NAME", { infer: true }) } diff --git a/apps/api/src/features/metadata/retrieveMetadataAndSaveCover.ts b/apps/api/src/features/metadata/retrieveMetadataAndSaveCover.ts index ed1d6c77a..e3e70f0db 100644 --- a/apps/api/src/features/metadata/retrieveMetadataAndSaveCover.ts +++ b/apps/api/src/features/metadata/retrieveMetadataAndSaveCover.ts @@ -4,8 +4,9 @@ import { type BookMetadata, type FileMetadata, type LinkMetadata, + type UserMetadata, + buildBookBucketCoverKey, directives, - getBookBucketCoverKeyType, getBookCoverKey, resolveMetadataFetchEnabled, resolveMetadataFileDownloadEnabled, @@ -23,12 +24,12 @@ import { getMetadataFromZipArchive } from "../../lib/books/metadata/getMetadataF import { detectMimeTypeFromContent } from "../../lib/utils" import { downloadToTmpFolder } from "../../lib/archives/downloadToTmpFolder" import { updateCover } from "./updateCover" +import { pickCoverMetadata } from "./pickCoverMetadata" import { getRarArchive } from "../../lib/archives/getRarArchive" import { atomicUpdate } from "../../lib/couch/dbHelpers" import { AppConfigService } from "src/config/AppConfigService" import { CoversService } from "src/covers/covers.service" import { firstValueFrom } from "rxjs" -import { pickCoverMetadata } from "./pickCoverMetadata" import { MODIFIED_AT_UNSUPPORTED } from "../plugins/types" const logger = new Logger("retrieveMetadataAndSaveCover") @@ -36,7 +37,7 @@ const logger = new Logger("retrieveMetadataAndSaveCover") /** * Decides whether the file's bytes still match what we extracted on a * previous run, so we can reuse the cached `type:"file"` metadata entry - * (and the cover blob already in S3) instead of re-downloading. + * instead of re-downloading. * * The primary fingerprint is the provider-reported `modifiedAt` on the * `link` source — both sides MUST report it, otherwise we refuse to @@ -70,6 +71,15 @@ export const retrieveMetadataAndSaveCover = async ( ctx: Context & { googleApiKey?: string db: nano.DocumentScope + /** + * Hard refresh: bypass every reuse cache (cached file metadata, + * cover blob match) so the file is re-downloaded (when allowed), + * metadata re-extracted, and the cover regenerated even when + * nothing changed. Useful as a recovery hatch when a previous run + * was corrupted, when an S3 blob has gone missing, or after a fix + * has been shipped that should be re-applied to existing books. + */ + force?: boolean }, config: AppConfigService, coversService: CoversService, @@ -141,92 +151,47 @@ export const retrieveMetadataAndSaveCover = async ( (contentType && config.METADATA_EXTRACTOR_SUPPORTED_EXTENSIONS.includes(contentType)) - const sourcesMetadata = - ignoreMetadataSources || !externalFetchEnabled - ? [] - : await getBookSourcesMetadata( - { - // Some plugins return the filename (with extension) instead - // of a clean title; strip the extension for the lookup. - title: path.parse(linkMetadata.title?.toString() ?? "").name, - isbn, - googleVolumeId, - }, - { - googleApiKey: ctx.googleApiKey, - withExternalSources: externalFetchEnabled, - }, - config, - ) - - /** - * Try to reuse the previously-extracted `type:"file"` entry when the - * provider reports the file is unchanged (same `modifiedAt` + `size` - * on the `link` source). This avoids re-downloading the whole file - * just to re-derive identical metadata. Reuse is gated on: - * - the user not having added an `[oboku~ignore-metadata-file~…]` - * directive since the previous run (in which case the cached - * entry must be dropped); - * - having a previous `link` entry to compare against; - * - having a previous `file` entry to actually reuse. - * If we reuse the file metadata, we also need to make sure the cover - * blob is still in S3 when the resolved cover source is `file`, - * otherwise we must download to regenerate it. - */ const previousLinkMetadata = ctx.book.metadata?.find( (entry): entry is LinkMetadata => entry.type === "link", ) const previousFileMetadata = ctx.book.metadata?.find( (entry): entry is FileMetadata => entry.type === "file", ) + const previousUserMetadata = ctx.book.metadata?.find( + (entry): entry is UserMetadata => entry.type === "user", + ) + const fileUnchanged = isCachedFileMetadataReusable( previousLinkMetadata, linkMetadata, ) - const canReuseFileMetadata = - fileUnchanged && !ignoreMetadataFile && !!previousFileMetadata - const candidateMetadataList: BookMetadata[] = [ - linkMetadata, - ...sourcesMetadata, - ...(canReuseFileMetadata ? [previousFileMetadata] : []), - ] - /** - * Determine which source would supply the cover for this run if we - * skipped the download. If that source is `file`, we can only reuse - * the cached cover blob when (a) the bucket image was actually - * uploaded from a `file` source on the previous successful run (so - * the blob currently in S3 came from the file, not from another - * provider whose priority has since been demoted) and (b) the blob - * still exists in S3. Otherwise we must download to regenerate it. - * - * `bucketCoverKey` is the source of truth here, NOT a freshly - * recomputed pick from `metadata` + current priority — the latter - * drifts when the user reorders sources or edits metadata and would - * incorrectly let us reuse a stale blob. - */ const coverObjectKey = getBookCoverKey(ctx.userNameHex, ctx.book._id) - const projectedCoverSource = pickCoverMetadata( - candidateMetadataList, + + const predictedCoverMetadata = pickCoverMetadata( + [ + linkMetadata, + ...(previousFileMetadata ? [previousFileMetadata] : []), + ...(previousUserMetadata ? [previousUserMetadata] : []), + ], ctx.book.metadataSourcePriority, - )?.type - const bucketCoverSource = ctx.book.bucketCoverKey - ? getBookBucketCoverKeyType(ctx.book.bucketCoverKey) + ) + const predictedBucketCoverKey = predictedCoverMetadata?.coverLink + ? buildBookBucketCoverKey({ + type: predictedCoverMetadata.type, + value: predictedCoverMetadata.coverLink, + }) : undefined - /** - * Short-circuit the S3 head request when we already know we won't - * reuse the cached file metadata: the result is only consumed via - * `skipDownload` below, which itself requires `canReuseFileMetadata`. - * `updateCover` will run its own existence check later for the - * download path, so skipping here avoids a redundant round-trip. - */ - const coverFromFileNeedsDownload = - canReuseFileMetadata && - projectedCoverSource === "file" && - (bucketCoverSource !== "file" || + const fileCoverNeedsRefresh = + predictedCoverMetadata?.type === "file" && + !!predictedCoverMetadata.coverLink && + (predictedBucketCoverKey !== ctx.book.bucketCoverKey || !(await firstValueFrom(coversService.isCoverExist(coverObjectKey)))) - const skipDownload = canReuseFileMetadata && !coverFromFileNeedsDownload + const skipDownload = + !ctx.force && + !fileCoverNeedsRefresh && + (ignoreMetadataFile || (fileUnchanged && !!previousFileMetadata)) if (skipDownload) { logger.log( @@ -234,8 +199,6 @@ export const retrieveMetadataAndSaveCover = async ( ) } - const metadataList: BookMetadata[] = [linkMetadata, ...sourcesMetadata] - if (canDownload && isMaybeExtractAble && !fileDownloadEnabled) { logger.log( `Skipping file download for ${ctx.book._id} (metadataFileDownloadEnabled=false)`, @@ -265,23 +228,6 @@ export const retrieveMetadataAndSaveCover = async ( }) : { filepath: undefined } - /** - * Preserve the previously-extracted `type:"file"` entry whenever we - * trust it (`canReuseFileMetadata`) AND no fresh extraction will - * happen on this run (no `tmpFilePath`). Tying this to - * `!tmpFilePath` rather than `skipDownload` matters when the cover - * blob is missing/mismatched (forcing `skipDownload=false`) but the - * download is then skipped anyway — e.g. `fileDownloadEnabled` is - * false, `canDownload` is false, the content type isn't extractable, - * or the download itself failed. Without this, legacy books without - * a `bucketCoverKey` marker would silently drop their cached - * authors/publisher/date/coverLink fields under - * `metadataFileDownloadEnabled=false`. - */ - if (canReuseFileMetadata && !tmpFilePath && previousFileMetadata) { - metadataList.push(previousFileMetadata) - } - let fileContentLength = 0 if (tmpFilePath) { @@ -302,6 +248,7 @@ export const retrieveMetadataAndSaveCover = async ( const isRarArchive = contentType === "application/x-rar" let archiveExtractor: Extractor | undefined + let freshFileMetadata: FileMetadata | undefined if (typeof tmpFilePath === "string" && tmpFilePath) { // before starting the extraction and if we still don't have a content type, we will try to get it from the file itself. @@ -313,28 +260,18 @@ export const retrieveMetadataAndSaveCover = async ( if (!ignoreMetadataFile) { if (isRarArchive) { archiveExtractor = await getRarArchive(tmpFilePath) - const fileMetadata = await getMetadataFromRarArchive( + freshFileMetadata = await getMetadataFromRarArchive( archiveExtractor, contentType ?? ``, - config, ) - - logger.log(`Pushing file metadata for book ${ctx.book._id}`) - - metadataList.push(fileMetadata) } else if ( contentType && config.METADATA_EXTRACTOR_SUPPORTED_EXTENSIONS.includes(contentType) ) { - const fileMetadata = await getMetadataFromZipArchive( + freshFileMetadata = await getMetadataFromZipArchive( tmpFilePath, contentType, - config, ) - - logger.log(`Pushing file metadata for book ${ctx.book._id}`) - - metadataList.push(fileMetadata) } else { logger.log( `${contentType} cannot be extracted to retrieve information (cover, etc)`, @@ -343,6 +280,63 @@ export const retrieveMetadataAndSaveCover = async ( } } + /** + * Carry the previously-extracted `type:"file"` entry forward when + * the file is unchanged and no fresh extraction happened — without + * this, the cached `authors`/`publisher`/`date`/`coverLink` would + * silently disappear under `metadataFileDownloadEnabled=false`, + * unsupported content types, or download failures. + */ + const reusedFileMetadata = + !freshFileMetadata && !ctx.force && fileUnchanged && !ignoreMetadataFile + ? previousFileMetadata + : undefined + + /** + * Single Google Books lookup — runs after extraction so it sees the + * most authoritative ISBN we have. Priority mirrors the global + * `user > directive > file > …` chain. + */ + const lookupTitle = path.parse(linkMetadata.title?.toString() ?? "").name + const lookupIsbn = + previousUserMetadata?.isbn ?? + isbn ?? + freshFileMetadata?.isbn ?? + reusedFileMetadata?.isbn + + const sourcesMetadata = + ignoreMetadataSources || !externalFetchEnabled + ? [] + : await getBookSourcesMetadata( + { + // Some plugins return the filename (with extension) instead + // of a clean title; strip the extension for the lookup. + title: lookupTitle, + isbn: lookupIsbn, + googleVolumeId, + }, + { + googleApiKey: ctx.googleApiKey, + withExternalSources: externalFetchEnabled, + }, + config, + ) + + if (freshFileMetadata) { + logger.log(`Pushing file metadata for book ${ctx.book._id}`) + } + + const metadataList: BookMetadata[] = [ + linkMetadata, + ...sourcesMetadata, + ...(freshFileMetadata + ? [freshFileMetadata] + : reusedFileMetadata + ? [reusedFileMetadata] + : []), + ...(previousUserMetadata ? [previousUserMetadata] : []), + ] + const { bucketCoverKey: nextBucketCoverKey } = await updateCover({ book: ctx.book, ctx, @@ -350,6 +344,7 @@ export const retrieveMetadataAndSaveCover = async ( archiveExtractor, tmpFilePath, coversService, + force: ctx.force, }) console.log( diff --git a/apps/api/src/features/metadata/updateCover.ts b/apps/api/src/features/metadata/updateCover.ts index 045a21b2c..db0fd780b 100644 --- a/apps/api/src/features/metadata/updateCover.ts +++ b/apps/api/src/features/metadata/updateCover.ts @@ -24,6 +24,7 @@ export const updateCover = async ({ ctx, tmpFilePath, coversService, + force = false, }: { ctx: Context book: BookDocType @@ -31,6 +32,11 @@ export const updateCover = async ({ archiveExtractor?: Extractor | undefined tmpFilePath?: string coversService: CoversService + /** + * Hard refresh: regenerate the cover even when the bucket key + * already matches the picked source and the blob exists. + */ + force?: boolean }): Promise => { const coverObjectKey = getBookCoverKey(ctx.userNameHex, ctx.book._id) const metadataForCover = pickCoverMetadata( @@ -45,6 +51,7 @@ export const updateCover = async ({ : undefined if ( + !force && expectedBucketCoverKey !== undefined && expectedBucketCoverKey === book.bucketCoverKey && (await firstValueFrom(coversService.isCoverExist(coverObjectKey))) diff --git a/apps/api/src/lib/books/metadata/getMetadataFromArchive.ts b/apps/api/src/lib/books/metadata/getMetadataFromArchive.ts new file mode 100644 index 000000000..90c76bf91 --- /dev/null +++ b/apps/api/src/lib/books/metadata/getMetadataFromArchive.ts @@ -0,0 +1,36 @@ +import { + type ArchiveSource, + readArchiveMetadata, +} from "@oboku/archive-metadata" +import type { FileMetadata } from "@oboku/shared" +import { Logger } from "@nestjs/common" + +const logger = new Logger("getMetadataFromArchive") + +export const getMetadataFromArchive = async ( + archive: ArchiveSource, + contentType: string, +): Promise => { + const metadata = await readArchiveMetadata(archive) + const opf = metadata.opf + const comicInfo = metadata.comicInfo + + logger.log( + `Extracted archive metadata (hasOpf=${metadata.hasOpf}, hasComicInfo=${metadata.hasComicInfo})`, + ) + + return { + type: "file", + contentType, + title: opf?.title ?? comicInfo?.title, + authors: opf?.authors ?? comicInfo?.authors, + publisher: opf?.publisher ?? comicInfo?.publisher, + rights: opf?.rights ?? comicInfo?.rights, + languages: opf?.languages ?? comicInfo?.languages, + date: opf?.date ?? comicInfo?.date, + subjects: opf?.subjects ?? comicInfo?.subjects, + coverLink: metadata.coverHref, + pageCount: metadata.pageCount, + isbn: comicInfo?.isbn ?? opf?.isbn, + } +} diff --git a/apps/api/src/lib/books/metadata/getMetadataFromRarArchive.ts b/apps/api/src/lib/books/metadata/getMetadataFromRarArchive.ts index c79761983..06b126555 100644 --- a/apps/api/src/lib/books/metadata/getMetadataFromRarArchive.ts +++ b/apps/api/src/lib/books/metadata/getMetadataFromRarArchive.ts @@ -1,43 +1,13 @@ import type { FileMetadata } from "@oboku/shared" import type { Extractor } from "node-unrar-js" -import path from "node:path" -import type { AppConfigService } from "src/config/AppConfigService" +import { getMetadataFromArchive } from "./getMetadataFromArchive" +import { createUnrarArchiveSource } from "./unrarArchive" export const getMetadataFromRarArchive = async ( extractor: Extractor, contentType: string, - config: AppConfigService, ): Promise => { - const list = extractor.getFileList() - const fileHeaders = [...list.fileHeaders] + const archive = createUnrarArchiveSource(extractor) - const firstImageFound = fileHeaders.find((fileHeader) => { - const isAllowedImage = config.COVERS_ALLOWED_EXT.includes( - path.extname(fileHeader.name).toLowerCase(), - ) - - return isAllowedImage - }) - - const opfFile = fileHeaders.find((header) => header.name.endsWith(`.opf`)) - const archiveIsNotEpub = !opfFile - const onlyFileHeaders = fileHeaders.filter( - (header) => !header.flags.directory, - ) - - if (archiveIsNotEpub) { - return { - type: "file", - contentType, - pageCount: onlyFileHeaders.length, - coverLink: firstImageFound?.name, - } - } - - return { - type: "file", - contentType, - pageCount: onlyFileHeaders.length, - coverLink: firstImageFound?.name, - } + return getMetadataFromArchive(archive, contentType) } diff --git a/apps/api/src/lib/books/metadata/getMetadataFromZipArchive.ts b/apps/api/src/lib/books/metadata/getMetadataFromZipArchive.ts index 70f5eebf4..064e4f40b 100644 --- a/apps/api/src/lib/books/metadata/getMetadataFromZipArchive.ts +++ b/apps/api/src/lib/books/metadata/getMetadataFromZipArchive.ts @@ -1,77 +1,16 @@ -import type { FileMetadata, OPF } from "@oboku/shared" -import fs from "node:fs" -import path from "node:path" -import unzipper from "unzipper" -import { parseOpfMetadata } from "../../metadata/opf/parseOpfMetadata" -import { Logger } from "@nestjs/common" -import { parseXmlAsJson } from "../parseXmlAsJson" -import { AppConfigService } from "src/config/AppConfigService" - -const logger = new Logger("getMetadataFromZipArchive") +import type { FileMetadata } from "@oboku/shared" +import { getMetadataFromArchive } from "./getMetadataFromArchive" +import { createUnzipperArchiveSource } from "./unzipperArchive" export const getMetadataFromZipArchive = async ( tmpFilePath: string, contentType: string, - config: AppConfigService, ): Promise => { - let contentLength = 0 - const files: string[] = [] - let opfBasePath = "" - let opfAsJson: OPF = { - package: { - manifest: {}, - metadata: {}, - }, - } - - await fs - .createReadStream(tmpFilePath) - .pipe( - unzipper.Parse({ - verbose: false, - }), - ) - .on("entry", async (entry: unzipper.Entry) => { - contentLength = contentLength + entry.vars.compressedSize - const filepath = entry.path - - if (entry.type === "File") { - files.push(entry.path) - } - - if (filepath.endsWith(".opf")) { - opfBasePath = `${filepath.substring(0, filepath.lastIndexOf("/"))}` - const xml = (await entry.buffer()).toString("utf8") - opfAsJson = parseXmlAsJson(xml) - entry.autodrain() - } else { - entry.autodrain() - } - }) - .promise() - - logger.log(`opfBasePath`, opfBasePath) - - const { coverLink: opfCoverLink, ...opfMetadata } = - parseOpfMetadata(opfAsJson) - - const firstValidImagePath = files - .filter((file) => - config.COVERS_ALLOWED_EXT.includes(path.extname(file).toLowerCase()), - ) - .sort()[0] + const archive = await createUnzipperArchiveSource(tmpFilePath) - return { - type: "file", - contentType, - ...opfMetadata, - /** - * Path in the archive to the cover image - */ - coverLink: opfCoverLink - ? opfBasePath !== "" - ? `${opfBasePath}/${opfCoverLink}` - : opfCoverLink - : firstValidImagePath, + try { + return await getMetadataFromArchive(archive, contentType) + } finally { + await archive.close() } } diff --git a/apps/api/src/lib/books/metadata/unrarArchive.ts b/apps/api/src/lib/books/metadata/unrarArchive.ts new file mode 100644 index 000000000..f792b93d4 --- /dev/null +++ b/apps/api/src/lib/books/metadata/unrarArchive.ts @@ -0,0 +1,72 @@ +import type { ArchiveEntry, ArchiveSource } from "@oboku/archive-metadata" +import type { Extractor } from "node-unrar-js" + +/** + * Extract a single entry's bytes from a `node-unrar-js` extractor. The + * library's `extract({ files })` returns a generator that holds + * resources until consumed end-to-end — spreading into an array is the + * documented way to free them (see `saveCoverFromRarArchiveToBucket` + * for the same pattern on the cover path). + */ +const extractEntryBytes = ( + extractor: Extractor, + fileName: string, +): Uint8Array | undefined => { + const extracted = extractor.extract({ files: [fileName] }) + const files = [...extracted.files] + const file = files[0] + + return file?.extraction +} + +/** + * Adapt a `node-unrar-js` extractor to the runtime-agnostic + * `ArchiveSource` interface consumed by `@oboku/archive-metadata`. + * + * Ownership stays with the caller: the extractor is created upstream + * (see {@link getRarArchive}) and reused for both metadata parsing + * *and* cover binary extraction. The adapter only holds a reference + * and never disposes the extractor itself. + * + * The archive's file list comes from the already-loaded central + * directory (the whole RAR is buffered in memory by `getRarArchive`), + * so listing is effectively free and doesn't hit disk. + */ +export const createUnrarArchiveSource = ( + extractor: Extractor, +): ArchiveSource => { + const list = extractor.getFileList() + const headers = [...list.fileHeaders] + + const entries: ArchiveEntry[] = headers.map((header) => ({ + path: header.name, + isDir: header.flags.directory, + size: header.unpSize, + readAsUint8Array: async () => { + const bytes = extractEntryBytes(extractor, header.name) + + if (!bytes) { + throw new Error( + `node-unrar-js failed to extract entry "${header.name}" from the RAR archive`, + ) + } + + return bytes + }, + readAsString: async () => { + const bytes = extractEntryBytes(extractor, header.name) + + if (!bytes) { + throw new Error( + `node-unrar-js failed to extract entry "${header.name}" from the RAR archive`, + ) + } + + return new TextDecoder("utf-8").decode(bytes) + }, + })) + + return { + listEntries: async () => entries, + } +} diff --git a/apps/api/src/lib/books/metadata/unzipperArchive.ts b/apps/api/src/lib/books/metadata/unzipperArchive.ts new file mode 100644 index 000000000..b736c6411 --- /dev/null +++ b/apps/api/src/lib/books/metadata/unzipperArchive.ts @@ -0,0 +1,45 @@ +import type { ArchiveEntry, ArchiveSource } from "@oboku/archive-metadata" +import unzipper from "unzipper" + +type CentralDirectoryFile = Awaited< + ReturnType +>["files"][number] + +const toArchiveEntry = (file: CentralDirectoryFile): ArchiveEntry => ({ + path: file.path, + isDir: file.type === "Directory", + size: file.uncompressedSize, + readAsString: async () => (await file.buffer()).toString("utf8"), + readAsUint8Array: async () => { + const buffer = await file.buffer() + + // `Buffer` is a `Uint8Array` subclass; return a plain Uint8Array so + // `@oboku/archive-metadata` stays free of Node-specific types. + return new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength) + }, +}) + +/** + * Adapt a zip file on disk to the runtime-agnostic `ArchiveSource` + * interface consumed by `@oboku/archive-metadata`. Uses + * `unzipper.Open.file` (random-access via the central directory) so + * individual entries are still decoded lazily — the metadata package + * only touches the handful it needs (e.g. OPF, ComicInfo.xml) and the + * rest remain on disk. + */ +export const createUnzipperArchiveSource = async ( + filePath: string, +): Promise Promise }> => { + const directory = await unzipper.Open.file(filePath) + + const entries = directory.files.map(toArchiveEntry) + + return { + listEntries: async () => entries, + // `unzipper.Open.file` doesn't expose an explicit close hook — the + // underlying FDs are released per-entry when `buffer()` resolves. + // We keep a no-op close so callers can treat the adapter as + // disposable without branching on the runtime. + close: async () => {}, + } +} diff --git a/apps/api/src/lib/books/parseXmlAsJson.test.ts b/apps/api/src/lib/books/parseXmlAsJson.test.ts deleted file mode 100644 index 381aef678..000000000 --- a/apps/api/src/lib/books/parseXmlAsJson.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { parseXmlAsJson } from "./parseXmlAsJson" -import { describe, it, expect } from "vitest" - -describe("Given basic xml", () => { - it("should return valid json", async () => { - const xml = ` - - - - Cover - - - - - ` - - expect(await parseXmlAsJson(xml)).toEqual({ - "?xml": { - encoding: "utf-8", - standalone: "no", - version: "1.0", - }, - html: { - body: "", - lang: "en", - "xml:lang": "en", - xmlns: "http://www.w3.org/1999/xhtml", - "xmlns:epub": "http://www.idpf.org/2007/ops", - head: { title: "Cover" }, - }, - }) - }) -}) diff --git a/apps/api/src/lib/books/parseXmlAsJson.ts b/apps/api/src/lib/books/parseXmlAsJson.ts deleted file mode 100644 index b98f5f9d3..000000000 --- a/apps/api/src/lib/books/parseXmlAsJson.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { XMLParser } from "fast-xml-parser" - -export const parseXmlAsJson = (xml: string) => { - const parser = new XMLParser({ - attributeNamePrefix: "", - ignoreAttributes: false, - }) - return parser.parse(xml) -} diff --git a/apps/api/src/lib/metadata/opf/parseOpfMetadata.ts b/apps/api/src/lib/metadata/opf/parseOpfMetadata.ts deleted file mode 100644 index afe7e7e99..000000000 --- a/apps/api/src/lib/metadata/opf/parseOpfMetadata.ts +++ /dev/null @@ -1,101 +0,0 @@ -import type { FileMetadata, OPF } from "@oboku/shared" -import { extractDateComponents } from "../extractDateComponents" - -const extractLanguage = ( - metadata?: undefined | null | string | { "#text"?: string }, -): string | null => { - if (!metadata) return null - - if (typeof metadata === "string") return metadata - - if (metadata["#text"]) return metadata["#text"] - - return null -} - -const normalizeDate = ( - date: NonNullable["metadata"]>["dc:date"], -) => { - if (!date) return { year: undefined, month: undefined, day: undefined } - - if (typeof date === "string") return extractDateComponents(date) - - return extractDateComponents(String(date["#text"])) -} - -const findCoverPathFromOpf = (opf: OPF) => { - const manifest = opf.package?.manifest - const meta = opf.package?.metadata?.meta - const normalizedMeta = Array.isArray(meta) ? meta : meta ? [meta] : [] - const coverInMeta = normalizedMeta.find( - (item) => item?.name === "cover" && (item?.content?.length || 0) > 0, - ) - let href = "" - - const isImage = ( - item: NonNullable["item"]>[number], - ) => - item["media-type"] && - (item["media-type"].indexOf("image/") > -1 || - item["media-type"].indexOf("page/jpeg") > -1 || - item["media-type"].indexOf("page/png") > -1) - - if (coverInMeta) { - const item = manifest?.item?.find( - (item) => item.id === coverInMeta?.content && isImage(item), - ) - - if (item) { - return item?.href - } - } - - manifest?.item?.find((item) => { - const indexOfCover = item?.id?.toLowerCase().indexOf("cover") - if (indexOfCover !== undefined && indexOfCover > -1 && isImage(item)) { - href = item.href || "" - } - return "" - }) - - return href -} - -export const parseOpfMetadata = (opf: OPF): Omit => { - const metadata = opf.package?.metadata || {} - const creatrawCreator = metadata["dc:creator"] - - const language = extractLanguage(metadata["dc:language"]) - const creator = Array.isArray(creatrawCreator) - ? creatrawCreator[0]?.["#text"] - : typeof creatrawCreator === "object" - ? creatrawCreator["#text"] - : creatrawCreator - - const subjects = Array.isArray(metadata["dc:subject"]) - ? (metadata["dc:subject"] as string[]) - : typeof metadata["dc:subject"] === "string" - ? ([metadata["dc:subject"]] as string[]) - : null - - const title: string | number = - typeof metadata["dc:title"] === "object" - ? metadata["dc:title"]["#text"] - : metadata.title || metadata["dc:title"] - - return { - title, - publisher: - typeof metadata["dc:publisher"] === "string" - ? metadata["dc:publisher"] - : typeof metadata["dc:publisher"] === "object" - ? metadata["dc:publisher"]["#text"] - : undefined, - rights: metadata["dc:rights"] as string | undefined, - languages: language ? [language] : [], - date: normalizeDate(metadata["dc:date"]), - subjects: subjects ? subjects : [], - authors: creator ? [creator] : [], - coverLink: findCoverPathFromOpf(opf), - } -} diff --git a/apps/web/package.json b/apps/web/package.json index d58caf0d1..503f6cc34 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -13,6 +13,7 @@ "@mui/material": "^9.0.0", "@mui/utils": "^9.0.0", "@mui/x-tree-view": "^9.0.2", + "@oboku/archive-metadata": "0.1.0", "@oboku/shared": "0.8.0", "@oboku/synology": "0.1.0", "@prose-reader/core": "^1.291.0", diff --git a/apps/web/src/books/Cover.tsx b/apps/web/src/books/Cover.tsx index b58a4c4bd..0887dde10 100644 --- a/apps/web/src/books/Cover.tsx +++ b/apps/web/src/books/Cover.tsx @@ -51,6 +51,12 @@ const CoverImg = styled(`img`)<{ }), })) +const CoverRootBox = styled(Box)({ + width: "100%", + height: "100%", + overflow: "hidden", +}) + export const Cover = memo( ({ bookId, @@ -59,10 +65,12 @@ export const Cover = memo( withShadow = false, rounded = true, blurIfNeeded = true, + className, sx, ...rest }: { bookId: string + className?: string style?: React.CSSProperties fullWidth?: boolean withShadow?: boolean @@ -90,17 +98,7 @@ export const Cover = memo( }, [coverSrc]) return ( - + {isLoading && ( - + ) }, ) diff --git a/apps/web/src/books/details/BookMetadataTabPane.tsx b/apps/web/src/books/details/BookMetadataTabPane.tsx index 3fb23c620..67553f48b 100644 --- a/apps/web/src/books/details/BookMetadataTabPane.tsx +++ b/apps/web/src/books/details/BookMetadataTabPane.tsx @@ -1,11 +1,26 @@ import { memo } from "react" -import { Stack, styled } from "@mui/material" +import { + List, + ListItem, + ListItemButton, + ListItemIcon, + ListItemText, + Stack, + styled, +} from "@mui/material" +import { AutoFixHighRounded, ChevronRightRounded } from "@mui/icons-material" +import { Link } from "react-router" import { useBook } from "../states" import { useResolvedMetadataFetchEnabled } from "../../metadata/useResolvedMetadataFetchEnabled" import { useIncrementalBookPatch } from "../useIncrementalBookPatch" import { MetadataSourcePane } from "./MetadataSourcePane" import { BookMetadataPolicyPane } from "../metadata/BookMetadataPolicyPane" import { DataSourceSection } from "./DataSourceSection" +import { + BOOK_OPTIMIZE_TABS, + getBookOptimizeRoute, +} from "../../pages/books/$id/optimize/BookOptimizeScreen" +import { useRefreshBookMetadata } from "../useRefreshBookMetadata" type Props = { bookId: string @@ -15,6 +30,10 @@ const TabStack = styled(Stack)(({ theme }) => ({ gap: theme.spacing(2), })) +// Cast preserves ListItemButton's polymorphic `component` prop (used to +// render as a react-router `Link`), which MUI's `styled` otherwise erases. +const ActionListItemButton = styled(ListItemButton)({}) as typeof ListItemButton + export const BookMetadataTabPane = memo(function BookMetadataTabPane({ bookId, }: Props) { @@ -25,9 +44,30 @@ export const BookMetadataTabPane = memo(function BookMetadataTabPane({ resolved: metadataFetchResolved, } = useResolvedMetadataFetchEnabled({ kind: "book", book }) const { mutate: incrementalBookPatch } = useIncrementalBookPatch() + const refreshMetadata = useRefreshBookMetadata() return ( + + + + + + + + + + + { + refreshMetadata(bookId, { force: true }) + }} + forceRefreshDisabled={book?.metadataUpdateStatus === "fetching"} /> diff --git a/apps/web/src/books/details/CoverPane.tsx b/apps/web/src/books/details/CoverPane.tsx index f479815b9..0a8fe15ee 100644 --- a/apps/web/src/books/details/CoverPane.tsx +++ b/apps/web/src/books/details/CoverPane.tsx @@ -1,29 +1,18 @@ -import { useTheme, Box, type BoxProps } from "@mui/material" +import { styled } from "@mui/material" import { Cover } from "../Cover" -export const CoverPane = ({ - bookId, - ...rest -}: { bookId?: string } & BoxProps) => { - const theme = useTheme() +const BookDetailsCover = styled(Cover)(({ theme }) => ({ + alignSelf: "center", + width: "60%", + maxWidth: theme.custom.maxWidthCenteredContent, + aspectRatio: theme.custom.coverAverageRatio, + [theme.breakpoints.up("sm")]: { + width: 200, + }, +})) - return ( - - - {!!bookId && } - - - ) +export const CoverPane = ({ bookId }: { bookId?: string }) => { + if (!bookId) return null + + return } diff --git a/apps/web/src/books/details/DataSourceSection.tsx b/apps/web/src/books/details/DataSourceSection.tsx index 8685302d3..46cb9a41d 100644 --- a/apps/web/src/books/details/DataSourceSection.tsx +++ b/apps/web/src/books/details/DataSourceSection.tsx @@ -14,6 +14,7 @@ import { Logger } from "../../debug/logger.shared" import { useBook } from "../states" import { useCreateRequestPopupDialog } from "../../plugins/useCreateRequestPopupDialog" import { showDialog } from "../../common/dialogs/createDialog" +import { createNotImplementedDialogOptions } from "../../common/dialogs/presets" import { useUpsertBookLink } from "../useUpdateBookLink" import { useRefreshBookMetadata } from "../useRefreshBookMetadata" import { useLink } from "../../links/states" @@ -49,7 +50,7 @@ export const DataSourceSection = memo(({ bookId }: { bookId: string }) => { key={link?._id} onClick={() => { if (!dataSourcePlugin?.SelectItemComponent) { - showDialog({ preset: "NOT_IMPLEMENTED" }) + showDialog(createNotImplementedDialogOptions()) } else { setIsSelectItemOpened(true) } diff --git a/apps/web/src/books/details/metadataSource/FileSourceContent.tsx b/apps/web/src/books/details/metadataSource/FileSourceContent.tsx index 22da85d8c..98ae90fbf 100644 --- a/apps/web/src/books/details/metadataSource/FileSourceContent.tsx +++ b/apps/web/src/books/details/metadataSource/FileSourceContent.tsx @@ -9,11 +9,6 @@ type Props = { metadata: DeepReadonlyObject | undefined } -/** - * Read-only content extracted from the file itself (EPUB OPF or RAR/ZIP - * scan). The advertised fields mirror what `parseOpfMetadata` and the - * archive scanners can produce. - */ export const FileSourceContent = ({ metadata }: Props) => ( Fields}> @@ -40,5 +35,6 @@ export const FileSourceContent = ({ metadata }: Props) => ( } /> + ) diff --git a/apps/web/src/books/details/metadataSource/LinkSourceContent.tsx b/apps/web/src/books/details/metadataSource/LinkSourceContent.tsx index 7f66d1173..ffa276019 100644 --- a/apps/web/src/books/details/metadataSource/LinkSourceContent.tsx +++ b/apps/web/src/books/details/metadataSource/LinkSourceContent.tsx @@ -97,7 +97,7 @@ const DirectivesSection = ({ * Directive-derived metadata fields are exposed in their own section. */ export const LinkSourceContent = ({ metadata }: Props) => { - const rawTitle = metadata?.title?.toString() + const rawTitle = metadata?.title const cleanTitle = rawTitle ? directives.removeDirectiveFromString(rawTitle) : undefined diff --git a/apps/web/src/books/metadata/BookMetadataPolicyPane.tsx b/apps/web/src/books/metadata/BookMetadataPolicyPane.tsx index 8b7d5c1bd..c7d738545 100644 --- a/apps/web/src/books/metadata/BookMetadataPolicyPane.tsx +++ b/apps/web/src/books/metadata/BookMetadataPolicyPane.tsx @@ -1,12 +1,13 @@ import { List, ListItem, + ListItemButton, ListItemIcon, ListItemText, ListSubheader, Switch, } from "@mui/material" -import { FileDownloadOutlined } from "@mui/icons-material" +import { FileDownloadOutlined, RefreshRounded } from "@mui/icons-material" import { memo } from "react" import type { MetadataFetchOverride, @@ -21,6 +22,8 @@ type Props = { onChange: (next: MetadataFetchOverride) => void fileDownloadOverride: MetadataFileDownloadOverride onFileDownloadChange: (next: MetadataFileDownloadOverride) => void + onForceRefresh: () => void + forceRefreshDisabled: boolean } /** @@ -38,6 +41,8 @@ export const BookMetadataPolicyPane = memo(function BookMetadataPolicyPane({ onChange, fileDownloadOverride, onFileDownloadChange, + onForceRefresh, + forceRefreshDisabled, }: Props) { const fileDownloadEnabled = fileDownloadOverride !== false @@ -76,6 +81,20 @@ export const BookMetadataPolicyPane = memo(function BookMetadataPolicyPane({ } /> + + + + + + + + ) }) diff --git a/apps/web/src/books/metadata/index.ts b/apps/web/src/books/metadata/index.ts index 1ad71e54b..817d1f16e 100644 --- a/apps/web/src/books/metadata/index.ts +++ b/apps/web/src/books/metadata/index.ts @@ -12,6 +12,13 @@ type Return = DeepReadonlyObject & { displayableDate?: string } +type DirectiveEntry = { type: "directive" } & Pick< + BookMetadataFields, + "isbn" | "googleVolumeId" +> & { + [K in Exclude]?: never + } + type GenericObject = { [key: string]: any } function mergeObjects(a: GenericObject, b: GenericObject): GenericObject { @@ -36,44 +43,35 @@ export const getMetadataFromBook = ( ): Return => { const list = book?.metadata ?? [] - /** - * Effective merge priority, lowest → highest. Later items override - * earlier ones in the reduce below, so the first entries are the - * weakest sources. - * - * Sources are ordered in **reverse** of the user-defined display - * priority returned by {@link getOrderedBookMetadataSources}, so that - * `user` ends up last (winning) and `link` first (losing to every - * typed source). - */ - const displayPriority = getOrderedBookMetadataSources( - book?.metadataSourcePriority, - ) - const sourceWeight = new Map( - displayPriority.map((source, index) => [source, index]), - ) - const orderedList = [...list].sort((a, b) => { - // Unknown types fall back to the weakest position (sorted first, reduced - // first, overridden by every known source) for forward-compat with docs - // written by newer clients that introduced a source we don't know about. - const aWeight = sourceWeight.get(a.type) ?? Number.POSITIVE_INFINITY - const bWeight = sourceWeight.get(b.type) ?? Number.POSITIVE_INFINITY - - // Higher displayPriority index === lower priority, so it comes first - // (gets overridden by later entries). - return bWeight - aWeight - }) - - /** - * Filename directives are the canonical source for `isbn` and - * `googleVolumeId` — they live in the link's title and are parsed on - * demand here so consumers see the effective values without us - * persisting a duplicate that could go stale. - */ const linkEntry = list.find((item) => item.type === "link") const linkDirectives = linkEntry?.title ? directives.extractDirectivesFromName(linkEntry.title.toString()) : undefined + const directiveEntry: DirectiveEntry | undefined = + linkDirectives?.isbn !== undefined || + linkDirectives?.googleVolumeId !== undefined + ? { + type: "directive", + isbn: linkDirectives?.isbn, + googleVolumeId: linkDirectives?.googleVolumeId, + } + : undefined + + const priorityLowestFirst: string[] = [ + ...getOrderedBookMetadataSources(book?.metadataSourcePriority) + .filter((source) => source !== "user") + .toReversed(), + "directive", + "user", + ] + + const orderedList = [ + ...list, + ...(directiveEntry ? [directiveEntry] : []), + ].toSorted( + (a, b) => + priorityLowestFirst.indexOf(a.type) - priorityLowestFirst.indexOf(b.type), + ) const reducedMetadata = orderedList.reduce((acc, item) => { const mergedValue = mergeObjects(acc, item) as Return @@ -100,9 +98,6 @@ export const getMetadataFromBook = ( return { ...reducedMetadata, - isbn: reducedMetadata.isbn ?? linkDirectives?.isbn, - googleVolumeId: - reducedMetadata.googleVolumeId ?? linkDirectives?.googleVolumeId, title: directives.removeDirectiveFromString( reducedMetadata.title?.toString() ?? "", ), diff --git a/apps/web/src/books/optimize/ContentTab.tsx b/apps/web/src/books/optimize/ContentTab.tsx new file mode 100644 index 000000000..3fa0b79ab --- /dev/null +++ b/apps/web/src/books/optimize/ContentTab.tsx @@ -0,0 +1,33 @@ +import { Stack, Typography, styled } from "@mui/material" +import { TestBookButton } from "./TestBookButton" + +const ContentTabRootStack = styled(Stack)(({ theme }) => ({ + flex: 1, + minHeight: 0, + gap: theme.spacing(2), +})) + +const ActionsStack = styled(Stack)(({ theme }) => ({ + marginTop: "auto", + gap: theme.spacing(1), + paddingTop: theme.spacing(1), + paddingBottom: theme.spacing(1), +})) + +type Props = { + bookId: string + hidden: boolean +} + +export function ContentTab({ bookId, hidden }: Props) { + return ( + + ) +} diff --git a/apps/web/src/books/optimize/DownloadBookStep.tsx b/apps/web/src/books/optimize/DownloadBookStep.tsx new file mode 100644 index 000000000..5d8f0dcb4 --- /dev/null +++ b/apps/web/src/books/optimize/DownloadBookStep.tsx @@ -0,0 +1,74 @@ +import { + Button, + LinearProgress, + Stack, + Typography, + styled, +} from "@mui/material" +import type { BookDocType } from "@oboku/shared" +import { useCallback, useEffect } from "react" +import type { DeepReadonlyObject } from "rxdb" +import { useCancelBookDownload, useDownloadBook } from "../../download" +import { useBookDownloadState } from "../../download/states" + +const DownloadBookStepRootStack = styled(Stack)(({ theme }) => ({ + gap: theme.spacing(2), +})) + +const DownloadProgressStack = styled(Stack)(({ theme }) => ({ + gap: theme.spacing(1), +})) + +type Props = { + book: DeepReadonlyObject + displayFileName: string | undefined +} + +export function DownloadBookStep({ book, displayFileName }: Props) { + const downloadState = useBookDownloadState(book._id) + const { mutate: downloadBook } = useDownloadBook() + const cancelBookDownload = useCancelBookDownload() + + const isDownloading = downloadState?.isDownloading ?? false + const downloadProgress = downloadState?.downloadProgress ?? 0 + + const handleDownload = useCallback(() => { + downloadBook({ + _id: book._id, + links: [...book.links], + }) + }, [book._id, book.links, downloadBook]) + + useEffect( + () => () => { + cancelBookDownload(book._id) + }, + [book._id, cancelBookDownload], + ) + + return ( + + + Download the book first + + Metadata and content optimization work on the downloaded file. + + {displayFileName && ( + {displayFileName} + )} + + {isDownloading ? ( + + + Downloading… {downloadProgress}% + + + + ) : ( + + )} + + ) +} diff --git a/apps/web/src/books/optimize/MetadataTab.tsx b/apps/web/src/books/optimize/MetadataTab.tsx new file mode 100644 index 000000000..5cae7e2fc --- /dev/null +++ b/apps/web/src/books/optimize/MetadataTab.tsx @@ -0,0 +1,236 @@ +import { CloudUploadOutlined, SaveOutlined } from "@mui/icons-material" +import { Button, Stack, styled } from "@mui/material" +import type { BookDocType, LinkDocType } from "@oboku/shared" +import { useEffect, useMemo, useRef } from "react" +import { useForm } from "react-hook-form" +import type { DeepReadonlyObject } from "rxdb" +import { showConfirmDialog } from "../../common/dialogs/presets" +import { CancelError } from "../../errors/errors.shared" +import { notify, notifyError } from "../../notifications/toasts" +import { useApplyMetadataFix } from "./metadata/useApplyMetadataFix" +import { useFileInspection } from "./metadata/useFileInspection" +import { MetadataDetectionSummary } from "./metadata/MetadataDetectionSummary" +import { MetadataForm } from "./metadata/MetadataForm" +import { + collectDetectedContainers, + EMPTY_METADATA_FIXER_FORM_VALUES, + resolveArchiveMetadataPatchPlans, + resolveMetadataFixerFormValues, + resolveMetadataFormSections, + trimMetadataFixerFormValues, +} from "./metadata/targets" +import type { MetadataFixerFormValues } from "./metadata/types" +import { TestBookButton } from "./TestBookButton" + +const MetadataTabRootStack = styled(Stack)(({ theme }) => ({ + flex: 1, + minHeight: 0, + gap: theme.spacing(2), +})) + +const ActionsStack = styled(Stack)(({ theme }) => ({ + marginTop: "auto", + gap: theme.spacing(1), + paddingTop: theme.spacing(1), + paddingBottom: theme.spacing(1), +})) + +type Props = { + book: DeepReadonlyObject + link: DeepReadonlyObject + canUploadToDataSource: boolean + hidden: boolean +} + +export function MetadataTab({ + book, + link, + canUploadToDataSource, + hidden, +}: Props) { + const metadataFormKey = `${book._id}:${link._id}` + const metadataFormKeyRef = useRef(metadataFormKey) + const isMetadataFormCurrent = metadataFormKeyRef.current === metadataFormKey + const { data: inspectionData, refetch: refetchInspection } = + useFileInspection({ + bookId: book._id, + enabled: true, + }) + + const inspection = inspectionData ?? undefined + const detectedContainers = inspection + ? collectDetectedContainers({ + hasOpf: inspection.hasOpf, + hasComicInfo: inspection.hasComicInfo, + }) + : [] + const formSections = useMemo( + () => resolveMetadataFormSections(inspection), + [inspection], + ) + const resolvedFormValues = useMemo( + () => resolveMetadataFixerFormValues(inspection), + [inspection], + ) + + const { + control, + handleSubmit, + reset, + formState: { isDirty, isValid }, + } = useForm({ + defaultValues: EMPTY_METADATA_FIXER_FORM_VALUES, + mode: "onChange", + }) + + useEffect(() => { + if (!isMetadataFormCurrent) { + metadataFormKeyRef.current = metadataFormKey + reset(resolvedFormValues) + return + } + + if (isDirty) return + reset(resolvedFormValues) + }, [ + isDirty, + isMetadataFormCurrent, + metadataFormKey, + reset, + resolvedFormValues, + ]) + + const { + mutate: applyMetadataFix, + isPending: isApplying, + slot: upsertSlot, + variables: applyMetadataFixVariables, + uploadProgress$, + } = useApplyMetadataFix() + + function applyMetadata( + values: MetadataFixerFormValues, + { uploadToDataSource }: { uploadToDataSource: boolean }, + ) { + const trimmedValues = trimMetadataFixerFormValues(values) + const patches = resolveArchiveMetadataPatchPlans(trimmedValues, inspection) + + applyMetadataFix( + { + bookId: book._id, + link, + patches, + uploadToDataSource, + }, + { + onSuccess: () => { + if (metadataFormKeyRef.current !== metadataFormKey) return + + reset(trimmedValues) + void refetchInspection() + notify({ + title: "Metadata applied", + description: uploadToDataSource + ? "Metadata was saved locally and uploaded to the data source." + : "Metadata was saved to the downloaded file on this device.", + severity: "success", + }) + }, + onError: (error) => { + if (error instanceof CancelError) return + + notifyError(error) + }, + }, + ) + } + + const isUploading = + isApplying && applyMetadataFixVariables?.uploadToDataSource === true + const isApplyingLocally = + isApplying && applyMetadataFixVariables?.uploadToDataSource === false + const inspectionReady = inspection !== undefined + const metadataReadFailed = inspection?.metadataReadFailed ?? false + const canEditMetadata = inspectionReady && !metadataReadFailed + const canApplyLocally = + isMetadataFormCurrent && + canEditMetadata && + !isApplying && + isDirty && + isValid + const canUploadMetadata = + isMetadataFormCurrent && + canEditMetadata && + !isApplying && + canUploadToDataSource && + isValid + const applyLocallyVariant = canUploadMetadata ? "outlined" : "contained" + const uploadVariant = canUploadMetadata ? "contained" : "outlined" + + function applyLocally(values: MetadataFixerFormValues) { + if (!canApplyLocally) return + + applyMetadata(values, { uploadToDataSource: false }) + } + + async function uploadMetadataToDataSource(values: MetadataFixerFormValues) { + if (!canUploadMetadata) return + + const isConfirmed = await showConfirmDialog({ + message: + "This will overwrite the file on the remote data source with the current local file.", + }) + + if (!isConfirmed) return + + applyMetadata(values, { uploadToDataSource: true }) + } + + return ( + <> + {upsertSlot} + + + ) +} diff --git a/apps/web/src/books/optimize/TestBookButton.tsx b/apps/web/src/books/optimize/TestBookButton.tsx new file mode 100644 index 000000000..d38f9e095 --- /dev/null +++ b/apps/web/src/books/optimize/TestBookButton.tsx @@ -0,0 +1,31 @@ +import { MenuBookOutlined } from "@mui/icons-material" +import { Button } from "@mui/material" +import { createSearchParams, generatePath, Link } from "react-router" +import { ROUTES } from "../../navigation/routes" +import { + READER_MODE_PARAM, + READER_PREVIEW_MODE, +} from "../../reader/ReaderScreen" + +type Props = { + bookId: string +} + +export function TestBookButton({ bookId }: Props) { + return ( + + ) +} diff --git a/apps/web/src/books/optimize/metadata/MetadataDetectionSummary.tsx b/apps/web/src/books/optimize/metadata/MetadataDetectionSummary.tsx new file mode 100644 index 000000000..88ca39ede --- /dev/null +++ b/apps/web/src/books/optimize/metadata/MetadataDetectionSummary.tsx @@ -0,0 +1,64 @@ +import { Alert, Chip, Stack, Typography, styled } from "@mui/material" +import type { DetectedContainer } from "./targets" + +const ContainersChipStack = styled(Stack)(({ theme }) => ({ + flexDirection: "row", + flexWrap: "wrap", + gap: theme.spacing(0.5), +})) + +type Props = { + inspectionReady: boolean + detectedContainers: DetectedContainer[] + metadataReadFailed: boolean +} + +export function MetadataDetectionSummary({ + inspectionReady, + detectedContainers, + metadataReadFailed, +}: Props) { + if (!inspectionReady) { + return Waiting for the file… + } + + if (metadataReadFailed) { + return ( + + This file could not be opened. Metadata cannot be edited for this format + or the file is corrupted. + + ) + } + + return ( + + {detectedContainers.length > 0 ? ( + + Detected metadata + + {detectedContainers.map((container) => ( + + ))} + + + ) : ( + + No embedded metadata containers were found.{" "} + {" "} + will be used as the default metadata container. + + )} + + ) +} diff --git a/apps/web/src/books/optimize/metadata/MetadataForm.tsx b/apps/web/src/books/optimize/metadata/MetadataForm.tsx new file mode 100644 index 000000000..b94564286 --- /dev/null +++ b/apps/web/src/books/optimize/metadata/MetadataForm.tsx @@ -0,0 +1,86 @@ +import { LinearProgress, Stack, Typography, styled } from "@mui/material" +import type { SubmitEventHandler } from "react" +import type { Control } from "react-hook-form" +import { useObserve } from "reactjrx" +import { EMPTY, type Observable } from "rxjs" +import { normalizeIsbn } from "@oboku/archive-metadata" +import { ControlledTextField } from "../../../common/forms/ControlledTextField" +import type { MetadataFormSection } from "./targets" +import type { MetadataFixerFormValues } from "./types" + +const validateIsbn = (raw: string): true | string => { + const trimmed = raw.trim() + + if (trimmed === "") return true + + return normalizeIsbn(trimmed) !== undefined + ? true + : "Not a recognizable ISBN-10 or ISBN-13" +} + +const MetadataSectionStack = styled(Stack)(({ theme }) => ({ + border: `1px solid ${theme.palette.divider}`, + borderRadius: theme.shape.borderRadius, + gap: theme.spacing(1), + padding: theme.spacing(1.5), +})) + +const getHelperText = (section: MetadataFormSection): string | undefined => { + return section.isbn ? undefined : "No ISBN found." +} + +type Props = { + control: Control + sections: MetadataFormSection[] + isApplying: boolean + isUploading: boolean + uploadProgress$: Observable | undefined + onSubmit: SubmitEventHandler +} + +export function MetadataForm({ + control, + sections, + isApplying, + isUploading, + uploadProgress$, + onSubmit, +}: Props) { + const { data: uploadProgress = 0 } = useObserve( + () => uploadProgress$ ?? EMPTY, + [uploadProgress$], + ) + const uploadPercent = Math.min( + 100, + Math.max(0, Math.round(uploadProgress * 100)), + ) + + return ( + + {sections.map((section) => ( + + {section.label} + + name={section.fieldName} + control={control} + rules={{ validate: validateIsbn }} + label="ISBN" + size="small" + fullWidth + helperText={getHelperText(section)} + disabled={isApplying} + /> + + ))} + {isUploading && ( + + Uploading… {uploadPercent}% + 0 ? "determinate" : "indeterminate"} + value={uploadPercent} + /> + + )} + + ) +} diff --git a/apps/web/src/books/optimize/metadata/archiveFile.ts b/apps/web/src/books/optimize/metadata/archiveFile.ts new file mode 100644 index 000000000..24abe94b0 --- /dev/null +++ b/apps/web/src/books/optimize/metadata/archiveFile.ts @@ -0,0 +1,99 @@ +import JSZip from "jszip" +import { + type ArchiveEntry, + type ArchiveMetadata, + type ArchivePatchedEntry, + type ArchiveMetadataTargets, + type ArchiveSource, + patchArchiveMetadata, + readArchiveMetadata, +} from "@oboku/archive-metadata" +import { Logger } from "../../../debug/logger.shared" +import type { ArchiveMetadataPatchPlan } from "./targets" + +const toArchiveEntry = (entry: JSZip.JSZipObject): ArchiveEntry => ({ + path: entry.name, + isDir: entry.dir, + readAsString: () => entry.async("string"), + readAsUint8Array: () => entry.async("uint8array"), +}) + +const createJszipArchiveSource = (zip: JSZip): ArchiveSource => ({ + listEntries: async () => Object.values(zip.files).map(toArchiveEntry), +}) + +export type { ArchiveMetadata, ArchiveMetadataTargets } + +const XML_LOG_PREVIEW_BYTES = 1024 + +const previewXml = (xml: string): string => + xml.length > XML_LOG_PREVIEW_BYTES + ? `${xml.slice(0, XML_LOG_PREVIEW_BYTES)}…` + : xml + +export const readArchiveMetadataFromFile = async ( + file: Blob | File, +): Promise => { + const zip = await JSZip.loadAsync(file) + const archive = createJszipArchiveSource(zip) + + Logger.info("[metadataFixer] archive structure", { + entryCount: Object.keys(zip.files).length, + entries: Object.values(zip.files).map((entry) => ({ + name: entry.name, + dir: entry.dir, + date: entry.date, + })), + }) + + return readArchiveMetadata(archive, { + onOpfRead: ({ path, xml }) => { + Logger.info("[metadataFixer] OPF read", { + path, + length: xml.length, + preview: previewXml(xml), + }) + }, + onComicInfoRead: ({ path, xml }) => { + Logger.info("[metadataFixer] ComicInfo.xml read", { + path, + length: xml.length, + preview: previewXml(xml), + }) + }, + }) +} + +const resolvePatchedMimeType = ( + file: Blob | File, + patches: ArchiveMetadataPatchPlan[], +): string => { + if (file.type) return file.type + + if (patches.some(({ targets }) => targets.opf)) return "application/epub+zip" + + return "application/x-cbz" +} + +export const patchArchiveFile = async ( + file: Blob | File, + patches: ArchiveMetadataPatchPlan[], +): Promise => { + const zip = await JSZip.loadAsync(file) + const archive = createJszipArchiveSource(zip) + const entries: ArchivePatchedEntry[] = [] + + for (const { patch, targets } of patches) { + const result = await patchArchiveMetadata(archive, patch, targets) + entries.push(...result.entries) + } + + for (const entry of entries) { + zip.file(entry.path, entry.xml) + } + + return zip.generateAsync({ + type: "blob", + mimeType: resolvePatchedMimeType(file, patches), + }) +} diff --git a/apps/web/src/books/optimize/metadata/targets.ts b/apps/web/src/books/optimize/metadata/targets.ts new file mode 100644 index 000000000..a04087e86 --- /dev/null +++ b/apps/web/src/books/optimize/metadata/targets.ts @@ -0,0 +1,156 @@ +import type { + ArchiveMetadataPatch, + ArchiveMetadataTargets, +} from "@oboku/archive-metadata" +import type { FileInspection } from "./useFileInspection" +import type { MetadataFixerFormValues } from "./types" + +type ContainerKey = "comicInfo" | "opf" +type FieldName = keyof MetadataFixerFormValues + +export type DetectedContainer = { + key: ContainerKey + label: string +} + +export type MetadataFormSection = { + key: ContainerKey + label: string + fieldName: FieldName + isbn: string | undefined +} + +export type ArchiveMetadataPatchPlan = { + patch: ArchiveMetadataPatch + targets: ArchiveMetadataTargets +} + +const CONTAINER_LABELS: Record = { + comicInfo: "ComicInfo.xml", + opf: "OPF package document", +} + +const CONTAINER_ORDER: readonly ContainerKey[] = ["comicInfo", "opf"] + +export const EMPTY_METADATA_FIXER_FORM_VALUES: MetadataFixerFormValues = { + comicInfoIsbn: "", + opfIsbn: "", +} + +const normalizeFormIsbn = (isbn: string): string | undefined => { + const trimmed = isbn.trim() + + return trimmed === "" ? undefined : trimmed +} + +export const trimMetadataFixerFormValues = ({ + comicInfoIsbn, + opfIsbn, +}: MetadataFixerFormValues): MetadataFixerFormValues => ({ + comicInfoIsbn: comicInfoIsbn.trim(), + opfIsbn: opfIsbn.trim(), +}) + +export const resolveMetadataFixerFormValues = ( + inspection: FileInspection | undefined, +): MetadataFixerFormValues => { + if (!inspection) return EMPTY_METADATA_FIXER_FORM_VALUES + + return { + comicInfoIsbn: inspection.comicInfoIsbn ?? "", + opfIsbn: inspection.opfIsbn ?? "", + } +} + +export const collectDetectedContainers = ({ + hasOpf, + hasComicInfo, +}: { + hasOpf: boolean + hasComicInfo: boolean +}): DetectedContainer[] => { + const present: Record = { + comicInfo: hasComicInfo, + opf: hasOpf, + } + + return CONTAINER_ORDER.filter((key) => present[key]).map((key) => ({ + key, + label: CONTAINER_LABELS[key], + })) +} + +export const resolveMetadataFormSections = ( + inspection: FileInspection | undefined, +): MetadataFormSection[] => { + if (!inspection) return [] + if (inspection.metadataReadFailed) return [] + + if (!inspection.hasComicInfo && !inspection.hasOpf) { + return [ + { + key: "comicInfo", + label: CONTAINER_LABELS.comicInfo, + fieldName: "comicInfoIsbn", + isbn: undefined, + }, + ] + } + + const sections: MetadataFormSection[] = [] + + if (inspection.hasComicInfo) { + sections.push({ + key: "comicInfo", + label: CONTAINER_LABELS.comicInfo, + fieldName: "comicInfoIsbn", + isbn: inspection.comicInfoIsbn, + }) + } + + if (inspection.hasOpf) { + sections.push({ + key: "opf", + label: CONTAINER_LABELS.opf, + fieldName: "opfIsbn", + isbn: inspection.opfIsbn, + }) + } + + return sections +} + +export const resolveArchiveMetadataPatchPlans = ( + values: MetadataFixerFormValues, + inspection: FileInspection | undefined, +): ArchiveMetadataPatchPlan[] => { + if (!inspection) return [] + if (inspection.metadataReadFailed) return [] + + if (!inspection.hasComicInfo && !inspection.hasOpf) { + return [ + { + patch: { isbn: normalizeFormIsbn(values.comicInfoIsbn) }, + targets: { comicInfo: true }, + }, + ] + } + + const patches: ArchiveMetadataPatchPlan[] = [] + + if (inspection.hasComicInfo) { + patches.push({ + patch: { isbn: normalizeFormIsbn(values.comicInfoIsbn) }, + targets: { comicInfo: true }, + }) + } + + if (inspection.hasOpf) { + patches.push({ + patch: { isbn: normalizeFormIsbn(values.opfIsbn) }, + targets: { opf: true }, + }) + } + + return patches +} diff --git a/apps/web/src/books/optimize/metadata/types.ts b/apps/web/src/books/optimize/metadata/types.ts new file mode 100644 index 000000000..a50fd9638 --- /dev/null +++ b/apps/web/src/books/optimize/metadata/types.ts @@ -0,0 +1,4 @@ +export type MetadataFixerFormValues = { + comicInfoIsbn: string + opfIsbn: string +} diff --git a/apps/web/src/books/optimize/metadata/useApplyMetadataFix.tsx b/apps/web/src/books/optimize/metadata/useApplyMetadataFix.tsx new file mode 100644 index 000000000..8d43444ff --- /dev/null +++ b/apps/web/src/books/optimize/metadata/useApplyMetadataFix.tsx @@ -0,0 +1,83 @@ +import { useMutation } from "@tanstack/react-query" +import type { LinkDocType } from "@oboku/shared" +import type { DeepReadonlyObject } from "rxdb" +import { dexieDb } from "../../../rxdb/dexie" +import { Logger } from "../../../debug/logger.shared" +import { getBookFile } from "../../../download/getBookFile.shared" +import { patchArchiveFile } from "./archiveFile" +import { usePluginUpsertFile } from "../../../plugins/usePluginUpsertFile" +import type { ArchiveMetadataPatchPlan } from "./targets" + +type ApplyMetadataFixVariables = { + bookId: string + link: DeepReadonlyObject | LinkDocType + patches: ArchiveMetadataPatchPlan[] + uploadToDataSource: boolean +} + +const saveDownloadedFile = async (bookId: string, file: File) => { + await dexieDb.downloads.put({ + id: bookId, + data: file, + filename: file.name, + }) +} + +export const useApplyMetadataFix = () => { + const { + mutateAsync: upsertFile, + slot, + progress$: uploadProgress$, + } = usePluginUpsertFile() + + const mutation = useMutation({ + mutationFn: async ({ + bookId, + link, + patches, + uploadToDataSource, + }: ApplyMetadataFixVariables) => { + const cached = await getBookFile(bookId) + + if (!cached) { + throw new Error( + `Cannot apply metadata: no cached file for book ${bookId}`, + ) + } + + const file = cached.data + + const mutated = await patchArchiveFile(file, patches) + + const mutatedFile = new File([mutated], file.name, { + type: mutated.type, + }) + + if (!uploadToDataSource) { + await saveDownloadedFile(bookId, mutatedFile) + + return { uploadedToDataSource: false } + } + + await upsertFile({ + link, + file: mutatedFile, + fileName: mutatedFile.name, + contentType: mutatedFile.type, + }) + + try { + await saveDownloadedFile(bookId, mutatedFile) + } catch (error) { + Logger.error( + `Failed to update local download cache after metadata fix for book ${bookId}`, + error, + ) + } + + return { uploadedToDataSource: true } + }, + }) + + return { ...mutation, slot, uploadProgress$ } +} diff --git a/apps/web/src/books/optimize/metadata/useFileInspection.ts b/apps/web/src/books/optimize/metadata/useFileInspection.ts new file mode 100644 index 000000000..91e2847aa --- /dev/null +++ b/apps/web/src/books/optimize/metadata/useFileInspection.ts @@ -0,0 +1,64 @@ +import { useQuery } from "@tanstack/react-query" +import { getBookFile } from "../../../download/getBookFile.shared" +import { Logger } from "../../../debug/logger.shared" +import { readArchiveMetadataFromFile } from "./archiveFile" + +export type FileInspection = { + fileName: string + hasComicInfo: boolean + hasOpf: boolean + comicInfoIsbn: string | undefined + opfIsbn: string | undefined + metadataReadFailed: boolean +} + +export const useFileInspection = ({ + bookId, + enabled, +}: { + bookId: string | undefined + enabled: boolean +}) => + useQuery({ + queryKey: ["metadataFixer", "fileInspection", bookId] as const, + enabled: enabled && !!bookId, + networkMode: "always", + staleTime: 0, + queryFn: async (): Promise => { + if (!bookId) return null + + const result = await getBookFile(bookId) + if (!result) return null + + const file = result.data + + Logger.info("[metadataFixer] file inspection", { + name: file.name, + size: file.size, + type: file.type, + lastModified: file.lastModified, + }) + + try { + const metadata = await readArchiveMetadataFromFile(file) + + return { + fileName: file.name, + hasComicInfo: metadata.hasComicInfo, + hasOpf: metadata.hasOpf, + comicInfoIsbn: metadata.comicInfo?.isbn, + opfIsbn: metadata.opf?.isbn, + metadataReadFailed: false, + } + } catch { + return { + fileName: file.name, + hasComicInfo: false, + hasOpf: false, + comicInfoIsbn: undefined, + opfIsbn: undefined, + metadataReadFailed: true, + } + } + }, + }) diff --git a/apps/web/src/books/useRefreshBookMetadata.ts b/apps/web/src/books/useRefreshBookMetadata.ts index b46334a5e..1a0df67a8 100644 --- a/apps/web/src/books/useRefreshBookMetadata.ts +++ b/apps/web/src/books/useRefreshBookMetadata.ts @@ -1,10 +1,10 @@ import { useNetworkState } from "react-use" -import { from, switchMap, catchError, map, of, EMPTY } from "rxjs" import { httpClientApi } from "../http/httpClientApi.web" import { usePluginRefreshMetadata } from "../plugins/usePluginRefreshMetadata" import { useDatabase } from "../rxdb" import { Logger } from "../debug/logger.shared" import { showDialog } from "../common/dialogs/createDialog" +import { createOfflineDialogOptions } from "../common/dialogs/presets" import { useIncrementalBookPatch } from "./useIncrementalBookPatch" import { CancelError } from "../errors/errors.shared" import { notifyError } from "../notifications/toasts" @@ -15,19 +15,21 @@ export const useRefreshBookMetadata = () => { const network = useNetworkState() const refreshPluginMetadata = usePluginRefreshMetadata() - return async (bookId: string) => { + return async (bookId: string, { force }: { force?: boolean } = {}) => { try { if (!network.online) { - showDialog({ preset: "OFFLINE" }) + showDialog(createOfflineDialogOptions()) return } - const book = await database?.book + if (!database) return + + const book = await database.book .findOne({ selector: { _id: bookId } }) .exec() - const firstLink = await database?.link + const firstLink = await database.link .findOne({ selector: { _id: book?.links[0] } }) .exec() @@ -43,49 +45,30 @@ export const useRefreshBookMetadata = () => { linkData: firstLink.data, }) - if (!database) return + await incrementalPatchBook({ + doc: bookId, + patch: { + metadataUpdateStatus: "fetching", + }, + }) - from( - incrementalPatchBook({ + try { + await httpClientApi.refreshBookMetadata({ + bookId, + providerCredentials, + force, + }) + } catch (e) { + await incrementalPatchBook({ doc: bookId, patch: { - metadataUpdateStatus: "fetching", + metadataUpdateStatus: null, + lastMetadataUpdateError: "unknown", }, - }), - ) - .pipe( - switchMap(() => - httpClientApi.refreshBookMetadata({ - bookId, - providerCredentials: providerCredentials, - }), - ), - catchError((e) => - from( - incrementalPatchBook({ - doc: bookId, - patch: { - metadataUpdateStatus: null, - lastMetadataUpdateError: "unknown", - }, - }), - ).pipe( - map((_) => { - throw e - }), - ), - ), - catchError((e) => { - if (e instanceof CancelError) return EMPTY + }) - notifyError(e) - - Logger.error(e) - - return of(null) - }), - ) - .subscribe() + throw e + } } catch (e) { if (e instanceof CancelError) return diff --git a/apps/web/src/common/dialogs/DialogProvider.test.tsx b/apps/web/src/common/dialogs/DialogProvider.test.tsx index 6ca5223c2..300a951cf 100644 --- a/apps/web/src/common/dialogs/DialogProvider.test.tsx +++ b/apps/web/src/common/dialogs/DialogProvider.test.tsx @@ -12,6 +12,7 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest" import { createCustomDialog } from "./createCustomDialog" import { createDialog } from "./createDialog" import { DialogProvider } from "./DialogProvider" +import { createConfirmDialogOptions, showConfirmDialog } from "./presets" import { dialogSignal } from "./state" const renderDialogProvider = () => { @@ -40,8 +41,7 @@ describe("DialogProvider", () => { dialog = createDialog({ title: "Remove books", message: "This cannot be undone.", - confirmTitle: "Remove", - onConfirm: () => "removed", + actions: [{ title: "Remove", onAction: () => "removed" }], }) }) if (!dialog) { @@ -60,6 +60,68 @@ describe("DialogProvider", () => { }) }) + it("renders confirm preset actions with the safe action emphasized", async () => { + renderDialogProvider() + + let dialog: ReturnType> | undefined + act(() => { + dialog = createDialog( + createConfirmDialogOptions({ actions: [{ title: "Overwrite" }] }), + ) + }) + if (!dialog) { + throw new Error("Expected a dialog handle") + } + + expect(await screen.findByRole("dialog")).not.toBeNull() + + const cancelButton = screen.getByRole("button", { name: "Cancel" }) + const actionButton = screen.getByRole("button", { name: "Overwrite" }) + + expect(cancelButton.className).toContain("MuiButton-contained") + expect(actionButton.className).toContain("MuiButton-outlined") + + fireEvent.click(actionButton) + + await expect(dialog.promise).resolves.toBe(true) + }) + + it("resolves confirm preset cancellation as false", async () => { + renderDialogProvider() + + let dialog: ReturnType> | undefined + act(() => { + dialog = createDialog(createConfirmDialogOptions()) + }) + if (!dialog) { + throw new Error("Expected a dialog handle") + } + + expect(await screen.findByRole("dialog")).not.toBeNull() + + fireEvent.click(screen.getByRole("button", { name: "Cancel" })) + + await expect(dialog.promise).resolves.toBe(false) + }) + + it("shows confirm dialogs through the async wrapper", async () => { + renderDialogProvider() + + let result: Promise | undefined + act(() => { + result = showConfirmDialog({ actions: [{ title: "Continue" }] }) + }) + if (!result) { + throw new Error("Expected a dialog result") + } + + expect(await screen.findByRole("dialog")).not.toBeNull() + + fireEvent.click(screen.getByRole("button", { name: "Continue" })) + + await expect(result).resolves.toBe(true) + }) + it("renders custom dialogs created programmatically", async () => { renderDialogProvider() diff --git a/apps/web/src/common/dialogs/DialogProvider.tsx b/apps/web/src/common/dialogs/DialogProvider.tsx index cb277d052..103833843 100644 --- a/apps/web/src/common/dialogs/DialogProvider.tsx +++ b/apps/web/src/common/dialogs/DialogProvider.tsx @@ -11,49 +11,8 @@ import { DialogTitle, } from "@mui/material" -const enrichDialogWithPreset = ( - dialog?: DialogTemplateType, -): DialogTemplateType | undefined => { - if (!dialog) return undefined - - switch (dialog.preset) { - case "NOT_IMPLEMENTED": - return { - ...dialog, - title: "Not implemented", - message: "Sorry this feature is not yet implemented", - dismissible: true, - } - case "OFFLINE": - return { - ...dialog, - title: "Offline is great but...", - message: "You need to be online to proceed with this action", - } - case "UNKNOWN_ERROR": - return { - title: "Oups, something went wrong!", - message: - "Something unexpected happened and oboku could not proceed with your action. Maybe you can try again?", - ...dialog, - } - case "CONFIRM": - return { - ...dialog, - title: dialog.title || "Hold on a minute!", - message: - dialog.message || - "Are you sure you want to proceed with this action?", - cancellable: - dialog.cancellable !== undefined ? dialog.cancellable : true, - } - default: - return dialog - } -} - function TemplateDialog({ dialog }: { dialog?: DialogTemplateType }) { - const currentDialog = enrichDialogWithPreset(dialog) + const currentDialog = dialog const isDismissible = currentDialog?.dismissible !== false const handleClose = useCallback(() => { @@ -73,13 +32,7 @@ function TemplateDialog({ dialog }: { dialog?: DialogTemplateType }) { [handleClose, currentDialog, isDismissible], ) - const actions = currentDialog?.actions || [ - { - title: currentDialog?.confirmTitle || "Ok", - onConfirm: currentDialog?.onConfirm, - type: "confirm", - }, - ] + const actions = currentDialog?.actions ?? [] const message = currentDialog?.message return ( @@ -92,7 +45,12 @@ function TemplateDialog({ dialog }: { dialog?: DialogTemplateType }) { )} {currentDialog?.cancellable === true && ( - )} @@ -100,14 +58,15 @@ function TemplateDialog({ dialog }: { dialog?: DialogTemplateType }) { diff --git a/apps/web/src/common/dialogs/createDialog.test.ts b/apps/web/src/common/dialogs/createDialog.test.ts index 4486f9de9..3130f8965 100644 --- a/apps/web/src/common/dialogs/createDialog.test.ts +++ b/apps/web/src/common/dialogs/createDialog.test.ts @@ -24,12 +24,12 @@ describe("createDialog", () => { dialogSignal.setValue([]) }) - it("queues a template dialog and resolves with the confirm result", async () => { - const onConfirm = vi.fn(() => "confirmed") + it("queues a template dialog and resolves with the action result", async () => { + const onAction = vi.fn(() => "confirmed") const dialog = createDialog({ title: "Confirm", message: "Are you sure?", - onConfirm, + actions: [{ title: "Confirm", onAction }], }) const queuedDialog = getOnlyTemplateDialog() @@ -40,40 +40,39 @@ describe("createDialog", () => { title: "Confirm", message: "Are you sure?", }) - const confirm = queuedDialog.onConfirm - if (!confirm) { - throw new Error("Expected a confirm handler") + const action = queuedDialog.actions?.[0] + if (!action) { + throw new Error("Expected a dialog action") } - expect(confirm()).toBe("confirmed") + expect(action.onAction()).toBe("confirmed") await expect(dialog.promise).resolves.toBe("confirmed") - expect(onConfirm).toHaveBeenCalledTimes(1) + expect(onAction).toHaveBeenCalledTimes(1) expect(dialogSignal.getValue()).toEqual([]) }) - it("resolves with null when confirming without a custom result", async () => { + it("resolves with null when using the default action", async () => { const dialog = createDialog({ title: "Notice" }) const queuedDialog = getOnlyTemplateDialog() - const confirm = queuedDialog.onConfirm - if (!confirm) { - throw new Error("Expected a confirm handler") + const action = queuedDialog.actions?.[0] + if (!action) { + throw new Error("Expected a dialog action") } - expect(confirm()).toBeNull() + expect(action.onAction()).toBeNull() await expect(dialog.promise).resolves.toBeNull() expect(dialogSignal.getValue()).toEqual([]) }) - it("wraps action confirm callbacks with the dialog settlement behavior", async () => { - const onConfirm = vi.fn(() => "archived") + it("wraps action callbacks with the dialog settlement behavior", async () => { + const onAction = vi.fn(() => "archived") const dialog = createDialog({ actions: [ { title: "Archive", - type: "confirm", - onConfirm, + onAction, }, ], }) @@ -84,10 +83,10 @@ describe("createDialog", () => { throw new Error("Expected a dialog action") } - expect(action.onConfirm()).toBe("archived") + expect(action.onAction()).toBe("archived") await expect(dialog.promise).resolves.toBe("archived") - expect(onConfirm).toHaveBeenCalledTimes(1) + expect(onAction).toHaveBeenCalledTimes(1) expect(dialogSignal.getValue()).toEqual([]) }) @@ -108,6 +107,22 @@ describe("createDialog", () => { expect(dialogSignal.getValue()).toEqual([]) }) + it("resolves with the configured cancellation result", async () => { + const onCancel = vi.fn() + const dialog = createDialog({ cancelResult: false, onCancel }) + const queuedDialog = getOnlyTemplateDialog() + const cancel = queuedDialog.onCancel + if (!cancel) { + throw new Error("Expected a cancel handler") + } + + cancel() + + await expect(dialog.promise).resolves.toBe(false) + expect(onCancel).toHaveBeenCalledTimes(1) + expect(dialogSignal.getValue()).toEqual([]) + }) + it("lets callers close the dialog externally", async () => { const onCancel = vi.fn() const dialog = createDialog({ onCancel }) diff --git a/apps/web/src/common/dialogs/createDialog.ts b/apps/web/src/common/dialogs/createDialog.ts index 7a5beb633..a2064ddf6 100644 --- a/apps/web/src/common/dialogs/createDialog.ts +++ b/apps/web/src/common/dialogs/createDialog.ts @@ -1,12 +1,22 @@ -import { type DialogTemplateType, dialogSignal } from "./state" +import { + type DialogAction, + type DialogTemplateType, + dialogSignal, +} from "./state" import { removeDialog } from "./removeDialog" import { CancelError } from "../../errors/errors.shared" import { getNextDialogId } from "./getNextDialogId" -export type CreateDialogOptions = Omit< +type CreateDialogAction = Omit, "onAction"> & { + onAction?: () => Result | null +} + +export type CreateDialogOptions = Omit< DialogTemplateType, - "id" | "type" -> + "actions" | "id" | "type" +> & { + actions?: CreateDialogAction[] +} export type DialogHandle = { id: string @@ -37,11 +47,16 @@ export const createDialog = ({ const close = () => { settle(() => { dialog.onCancel?.() + if ("cancelResult" in dialog) { + resolveDialog?.(dialog.cancelResult ?? null) + return + } + rejectDialog?.(new CancelError()) }) } - const confirmDialog = (getResult?: () => Result | null) => { + const completeDialog = (getResult?: () => Result | null) => { if (isSettled) return null const data = getResult?.() ?? null @@ -53,6 +68,21 @@ export const createDialog = ({ return data } + const createActions = (): DialogAction[] => { + const actions = dialog.actions?.length + ? dialog.actions + : [ + { + title: "Ok", + }, + ] + + return actions.map((action) => ({ + ...action, + onAction: () => completeDialog(action.onAction), + })) + } + const promise = new Promise((resolve, reject) => { resolveDialog = resolve rejectDialog = reject @@ -62,11 +92,7 @@ export const createDialog = ({ id, type: "template", onCancel: close, - onConfirm: () => confirmDialog(dialog.onConfirm), - actions: dialog.actions?.map((action) => ({ - ...action, - onConfirm: () => confirmDialog(action.onConfirm), - })), + actions: createActions(), } dialogSignal.setValue((old) => [...old, wrappedDialog]) diff --git a/apps/web/src/common/dialogs/presets.ts b/apps/web/src/common/dialogs/presets.ts new file mode 100644 index 000000000..913dc9277 --- /dev/null +++ b/apps/web/src/common/dialogs/presets.ts @@ -0,0 +1,64 @@ +import { type CreateDialogOptions, showDialog } from "./createDialog" + +type ConfirmDialogAction = Omit< + NonNullable["actions"]>[number], + "onAction" +> + +type ConfirmDialogOptions = Omit, "actions"> & { + actions?: ConfirmDialogAction[] +} + +export const createConfirmDialogOptions = ( + options: ConfirmDialogOptions = {}, +): CreateDialogOptions => { + const actions = options.actions?.length ? options.actions : [{ title: "Ok" }] + + return { + ...options, + title: options.title || "Hold on a minute!", + message: + options.message || "Are you sure you want to proceed with this action?", + cancellable: options.cancellable ?? true, + cancelResult: options.cancelResult ?? false, + cancelButtonVariant: options.cancelButtonVariant ?? "contained", + cancelButtonAutoFocus: options.cancelButtonAutoFocus ?? true, + actions: actions.map((action) => ({ + ...action, + variant: action.variant ?? "outlined", + autoFocus: action.autoFocus ?? false, + onAction: () => true, + })), + } +} + +export const showConfirmDialog = async ( + options: ConfirmDialogOptions = {}, +): Promise => + (await showDialog(createConfirmDialogOptions(options)).promise) ?? false + +export const createNotImplementedDialogOptions = ( + options: CreateDialogOptions = {}, +): CreateDialogOptions => ({ + title: "Not implemented", + message: "Sorry this feature is not yet implemented", + dismissible: true, + ...options, +}) + +export const createOfflineDialogOptions = ( + options: CreateDialogOptions = {}, +): CreateDialogOptions => ({ + title: "Offline is great but...", + message: "You need to be online to proceed with this action", + ...options, +}) + +export const createUnknownErrorDialogOptions = ( + options: CreateDialogOptions = {}, +): CreateDialogOptions => ({ + title: "Oups, something went wrong!", + message: + "Something unexpected happened and oboku could not proceed with your action. Maybe you can try again?", + ...options, +}) diff --git a/apps/web/src/common/dialogs/state.ts b/apps/web/src/common/dialogs/state.ts index 7bd365d42..c4157b2b2 100644 --- a/apps/web/src/common/dialogs/state.ts +++ b/apps/web/src/common/dialogs/state.ts @@ -1,20 +1,27 @@ import type { ReactNode } from "react" import { signal } from "reactjrx" -type Preset = "NOT_IMPLEMENTED" | "OFFLINE" | "CONFIRM" | "UNKNOWN_ERROR" +type DialogButtonVariant = "text" | "outlined" | "contained" + +export type DialogAction = { + title: string + variant?: DialogButtonVariant + autoFocus?: boolean + onAction: () => T | null +} export type DialogTemplateType = { type?: "template" title?: string message?: string id: string - preset?: Preset cancellable?: boolean dismissible?: boolean cancelTitle?: string - confirmTitle?: string - actions?: { title: string; type: "confirm"; onConfirm: () => T | null }[] - onConfirm?: () => T | null + cancelResult?: T | null + cancelButtonVariant?: DialogButtonVariant + cancelButtonAutoFocus?: boolean + actions?: DialogAction[] onClose?: () => void onCancel?: () => void } diff --git a/apps/web/src/common/fullscreen/useFullscreenOnMount.ts b/apps/web/src/common/fullscreen/useFullscreenOnMount.ts index 49cd26049..5517052a8 100644 --- a/apps/web/src/common/fullscreen/useFullscreenOnMount.ts +++ b/apps/web/src/common/fullscreen/useFullscreenOnMount.ts @@ -43,7 +43,7 @@ export const useFullscreenOnMount = ({ enabled }: { enabled: boolean }) => { title: "Fullscreen request", message: "Your browser does not allow automatic fullscreen without an interaction", - confirmTitle: "Fullscreen", + actions: [{ title: "Fullscreen" }], cancellable: true, }), ), diff --git a/apps/web/src/common/network/useWithNetwork.ts b/apps/web/src/common/network/useWithNetwork.ts index 46db4f521..70cc82bfc 100644 --- a/apps/web/src/common/network/useWithNetwork.ts +++ b/apps/web/src/common/network/useWithNetwork.ts @@ -1,6 +1,7 @@ import { type Observable, tap } from "rxjs" import { useNetworkState } from "react-use" import { showDialog } from "../dialogs/createDialog" +import { createOfflineDialogOptions } from "../dialogs/presets" import { OfflineError } from "../../errors/errors.shared" export const useWithNetwork = () => { @@ -10,7 +11,7 @@ export const useWithNetwork = () => { stream.pipe( tap(() => { if (!networkState.online) { - showDialog({ preset: "OFFLINE" }) + showDialog(createOfflineDialogOptions()) throw new OfflineError() } diff --git a/apps/web/src/dataSources/useRemoveDataSource.ts b/apps/web/src/dataSources/useRemoveDataSource.ts index a87b5e465..78cc6c8b3 100644 --- a/apps/web/src/dataSources/useRemoveDataSource.ts +++ b/apps/web/src/dataSources/useRemoveDataSource.ts @@ -1,6 +1,7 @@ import { getLatestDatabase } from "../rxdb/RxDbProvider" -import { defaultIfEmpty, first, from, mergeMap } from "rxjs" +import { defaultIfEmpty, filter, first, from, mergeMap } from "rxjs" import { withDialog } from "../common/dialogs/withDialog" +import { createConfirmDialogOptions } from "../common/dialogs/presets" import { observeDataSourceById } from "./dbHelpers" import { withUnknownErrorDialog } from "../errors/withUnknownErrorDialog" import { useMutation$ } from "reactjrx" @@ -9,7 +10,8 @@ export const useRemoveDataSource = () => { return useMutation$({ mutationFn: ({ id }: { id: string }) => getLatestDatabase().pipe( - withDialog({ preset: "CONFIRM" }), + withDialog(createConfirmDialogOptions()), + filter(([, isConfirmed]) => isConfirmed === true), mergeMap(([db]) => observeDataSourceById(db, id).pipe( first(), diff --git a/apps/web/src/dataSources/useSynchronizeDataSource.ts b/apps/web/src/dataSources/useSynchronizeDataSource.ts index 104d979e5..849877825 100644 --- a/apps/web/src/dataSources/useSynchronizeDataSource.ts +++ b/apps/web/src/dataSources/useSynchronizeDataSource.ts @@ -3,6 +3,7 @@ import { useNetworkState } from "react-use" import { useMutation$, isDefined } from "reactjrx" import { from, filter, switchMap, catchError, map, of } from "rxjs" import { fromCreateDialog } from "../common/dialogs/fromCreateDialog" +import { createOfflineDialogOptions } from "../common/dialogs/presets" import { httpClientApi } from "../http/httpClientApi.web" import { usePluginSynchronize } from "../plugins/usePluginSynchronize" import { useDatabase } from "../rxdb" @@ -20,7 +21,7 @@ export const useSynchronizeDataSource = () => { return useMutation$({ mutationFn: (_id: string) => { if (!network.online) { - return fromCreateDialog({ preset: "OFFLINE" }) + return fromCreateDialog(createOfflineDialogOptions()) } if (!database) { diff --git a/apps/web/src/download/useCancelBookDownload.ts b/apps/web/src/download/useCancelBookDownload.ts index 954c8d81d..cbd77d3fd 100644 --- a/apps/web/src/download/useCancelBookDownload.ts +++ b/apps/web/src/download/useCancelBookDownload.ts @@ -1,7 +1,8 @@ +import { useCallback } from "react" import { cancelPluginDownloadFlow } from "./flow/PluginDownloadFlowHost" export const useCancelBookDownload = () => { - return (bookId: string) => { + return useCallback((bookId: string) => { cancelPluginDownloadFlow(bookId) - } + }, []) } diff --git a/apps/web/src/errors/ErrorMessage.tsx b/apps/web/src/errors/ErrorMessage.tsx index b6f858e5b..81f1421ec 100644 --- a/apps/web/src/errors/ErrorMessage.tsx +++ b/apps/web/src/errors/ErrorMessage.tsx @@ -6,6 +6,7 @@ import { isApiError, } from "./errors.shared" import { HttpClientError } from "../http/httpClient.shared" +import { HttpClientNetworkError } from "../http/httpClient.web" import { Alert } from "@mui/material" export const ErrorMessage = ({ error }: { error: unknown }) => { @@ -33,8 +34,6 @@ const fromObokuErrorCode = (error: ObokuErrorCode) => { return ERROR_LINK_INVALID_MESSAGE case ObokuErrorCode.ERROR_NO_LINK: return ERROR_NO_LINK_MESSAGE - case ObokuErrorCode.ERROR_RESOURCE_NOT_REACHABLE: - return ERROR_RESOURCE_NOT_REACHABLE_MESSAGE case ObokuErrorCode.ERROR_SIGNUP_LINK_INVALID: return "This sign up link is invalid or expired. Please request a new one." case ObokuErrorCode.ERROR_SIGNUP_LINK_MISSING_TOKEN: @@ -63,6 +62,10 @@ export const errorToMessage = (error: unknown) => { return "Invalid credentials" } + if (error instanceof HttpClientNetworkError) { + return ERROR_RESOURCE_NOT_REACHABLE_MESSAGE + } + if ( isApiError(error) && error.response?.data.errors[0]?.code === diff --git a/apps/web/src/errors/withUnknownErrorDialog.ts b/apps/web/src/errors/withUnknownErrorDialog.ts index 131ec8566..99928586f 100644 --- a/apps/web/src/errors/withUnknownErrorDialog.ts +++ b/apps/web/src/errors/withUnknownErrorDialog.ts @@ -1,6 +1,7 @@ import { type Observable, catchError } from "rxjs" import { CancelError, OfflineError } from "./errors.shared" import { showDialog } from "../common/dialogs/createDialog" +import { createUnknownErrorDialogOptions } from "../common/dialogs/presets" export function withUnknownErrorDialog() { return function operator(stream: Observable) { @@ -9,7 +10,7 @@ export function withUnknownErrorDialog() { if (error instanceof CancelError) throw error if (error instanceof OfflineError) throw error - showDialog({ preset: "UNKNOWN_ERROR" }) + showDialog(createUnknownErrorDialogOptions()) throw error }), diff --git a/apps/web/src/google/useDriveFile.ts b/apps/web/src/google/useDriveFile.ts index d30ab09df..ab69dc3bb 100644 --- a/apps/web/src/google/useDriveFile.ts +++ b/apps/web/src/google/useDriveFile.ts @@ -37,7 +37,6 @@ export const useCreateDriveFileQuery = () => { getFile(gapi, { fileId: id ?? "", supportsAllDrives: true, - supportsTeamDrives: true, fields: "id, size, name, kind, parents, mimeType, modifiedTime", }), ) diff --git a/apps/web/src/http/httpClient.web.ts b/apps/web/src/http/httpClient.web.ts index 6a32bbfcb..155edb29c 100644 --- a/apps/web/src/http/httpClient.web.ts +++ b/apps/web/src/http/httpClient.web.ts @@ -1,4 +1,4 @@ -import { ObokuErrorCode, ObokuSharedError } from "@oboku/shared" +import { Observable } from "rxjs" import { HttpClient } from "./httpClient.shared" import { CancelError } from "../errors/errors.shared" @@ -8,6 +8,19 @@ type XMLHttpResponseError = { __xmlerror: true } +/** + * Transport-level failure surfaced by `XMLHttpRequest`'s `onerror`: + * DNS failure, refused connection, CORS preflight rejection, etc. + * The http client only knows it couldn't reach the wire; translation + * to any domain-level error is the caller's job. + */ +export class HttpClientNetworkError extends Error { + constructor(message = "HttpClientNetworkError") { + super(message) + this.name = "HttpClientNetworkError" + } +} + const parseXmlHttpResponseHeaders = (headers: string) => headers .trim() @@ -41,80 +54,128 @@ export const isXMLHttpResponseError = ( export type DownloadParams = { url: string + headers?: Record + signal?: AbortSignal responseType: XMLHttpRequestResponseType onDownloadProgress: (event: ProgressEvent) => void -} & Parameters[1] +} -export class HttpClientWeb extends HttpClient { - download = ({ - signal, +export type UploadParams = { + url: string + method?: "POST" | "PUT" | "PATCH" + body: Blob | File | ArrayBuffer | string + headers?: Record + signal?: AbortSignal + onUploadProgress?: (event: ProgressEvent) => void +} + +export type XhrResponse = { + data: T + headers: Record + status: number + statusText: string +} + +export type UploadResponse = XhrResponse + +type XhrParams = { + url: string + method: string + headers?: Record + signal?: AbortSignal + body?: Blob | File | ArrayBuffer | string + responseType?: XMLHttpRequestResponseType + onUploadProgress?: (event: ProgressEvent) => void + onDownloadProgress?: (event: ProgressEvent) => void +} + +const sendXhr = ( + { url, + method, + headers = {}, + signal, + body, responseType, + onUploadProgress, onDownloadProgress, - headers = {}, - }: DownloadParams) => { - return new Promise<{ - data: T - headers: Record - status: number - statusText: string - }>((resolve, reject) => { - const xhr = new XMLHttpRequest() - const handleAbort = () => { - xhr.abort() - } + }: XhrParams, + unwrap: (xhr: XMLHttpRequest) => T, +) => + new Promise>((resolve, reject) => { + const xhr = new XMLHttpRequest() + const handleAbort = () => xhr.abort() + const cleanup = () => signal?.removeEventListener("abort", handleAbort) - xhr.open("GET", url) - - xhr.responseType = responseType - - Object.keys(headers).forEach((key) => { - // @ts-expect-error - xhr.setRequestHeader(key, headers[key]) - }) - - signal?.addEventListener("abort", handleAbort, { - once: true, - }) - - xhr.send() - - xhr.onload = () => { - signal?.removeEventListener("abort", handleAbort) - - if (xhr.status >= 200 && xhr.status < 300) { - const data = xhr.response - resolve({ - data, - headers: parseXmlHttpResponseHeaders(xhr.getAllResponseHeaders()), - status: xhr.status, - statusText: xhr.statusText, - }) - } else { - reject({ - status: xhr.status, - statusText: xhr.statusText, - __xmlerror: true, - } satisfies XMLHttpResponseError) - } - } + xhr.open(method, url) - xhr.onprogress = onDownloadProgress + if (responseType !== undefined) xhr.responseType = responseType - xhr.onerror = () => { - signal?.removeEventListener("abort", handleAbort) + Object.keys(headers).forEach((key) => { + const value = headers[key] + if (value !== undefined) xhr.setRequestHeader(key, value) + }) - reject( - new ObokuSharedError(ObokuErrorCode.ERROR_RESOURCE_NOT_REACHABLE), - ) + if (onUploadProgress) xhr.upload.onprogress = onUploadProgress + if (onDownloadProgress) xhr.onprogress = onDownloadProgress + + signal?.addEventListener("abort", handleAbort, { once: true }) + + xhr.onload = () => { + cleanup() + + if (xhr.status >= 200 && xhr.status < 300) { + resolve({ + data: unwrap(xhr), + headers: parseXmlHttpResponseHeaders(xhr.getAllResponseHeaders()), + status: xhr.status, + statusText: xhr.statusText, + }) + } else { + reject({ + status: xhr.status, + statusText: xhr.statusText || xhr.responseText, + __xmlerror: true, + } satisfies XMLHttpResponseError) } + } - xhr.onabort = () => { - signal?.removeEventListener("abort", handleAbort) - reject(new CancelError()) - } + xhr.onerror = () => { + cleanup() + reject(new HttpClientNetworkError()) + } + + xhr.onabort = () => { + cleanup() + reject(new CancelError()) + } + + xhr.send(body) + }) + +export class HttpClientWeb extends HttpClient { + upload = ({ method = "POST", ...rest }: UploadParams) => + sendXhr({ ...rest, method }, (xhr) => xhr.responseText) + + upload$ = (params: Omit) => + new Observable((subscriber) => { + const controller = new AbortController() + + this.upload({ ...params, signal: controller.signal }).then( + (response) => { + subscriber.next(response) + subscriber.complete() + }, + (error) => subscriber.error(error), + ) + + return () => controller.abort() }) - } + + // `xhr.response` is typed `any` because its runtime shape depends on + // `responseType`; callers pick `T` via the generic to constrain it. + download = (params: DownloadParams) => + sendXhr({ ...params, method: "GET" }, (xhr) => xhr.response as T) } export const httpClientWeb = new HttpClientWeb() diff --git a/apps/web/src/http/toProgressRatioHandler.ts b/apps/web/src/http/toProgressRatioHandler.ts new file mode 100644 index 000000000..7d29f987b --- /dev/null +++ b/apps/web/src/http/toProgressRatioHandler.ts @@ -0,0 +1,10 @@ +export const toProgressRatioHandler = ( + onProgress: ((progress: number) => void) | undefined, +) => + onProgress + ? (event: ProgressEvent) => { + if (!event.lengthComputable || event.total === 0) return + + onProgress(event.loaded / event.total) + } + : undefined diff --git a/apps/web/src/navigation/AppBrowserRouter.tsx b/apps/web/src/navigation/AppBrowserRouter.tsx index cde952a65..176287322 100644 --- a/apps/web/src/navigation/AppBrowserRouter.tsx +++ b/apps/web/src/navigation/AppBrowserRouter.tsx @@ -29,6 +29,7 @@ import { CollectionBooksScreen } from "../pages/collections/$id/books/Collection import { BookTagsScreen } from "../pages/books/$id/tags/BookTagsScreen" import { BookCollectionsScreen } from "../pages/books/$id/collections/BookCollectionsScreen" import { BookMetadataSourceScreen } from "../pages/books/$id/metadata/$source/BookMetadataSourceScreen" +import { BookOptimizeScreen } from "../pages/books/$id/optimize/BookOptimizeScreen" import { memo, useEffect, useRef, type ReactNode } from "react" import { useMediaQuery, useTheme } from "@mui/material" import { SearchScreenExpanded } from "../search/SearchScreenExpanded" @@ -120,6 +121,10 @@ export const AppBrowserRouter = ({ children }: { children: ReactNode }) => { path={ROUTES.BOOK_COLLECTIONS.slice(1)} element={} /> + } + /> } diff --git a/apps/web/src/navigation/routes.ts b/apps/web/src/navigation/routes.ts index b392da307..196d8ce35 100644 --- a/apps/web/src/navigation/routes.ts +++ b/apps/web/src/navigation/routes.ts @@ -3,6 +3,7 @@ const BOOKS_ROOT = "/books" export const ROUTES = { HOME: "/", BOOK_DETAILS: `${BOOKS_ROOT}/:id`, + BOOK_OPTIMIZE: `${BOOKS_ROOT}/:id/optimize`, BOOK_TAGS: `${BOOKS_ROOT}/:id/tags`, BOOK_COLLECTIONS: `${BOOKS_ROOT}/:id/collections`, BOOK_METADATA_SOURCE: `${BOOKS_ROOT}/:id/metadata/:source`, diff --git a/apps/web/src/pages/books/$id/optimize/BookOptimizeScreen.tsx b/apps/web/src/pages/books/$id/optimize/BookOptimizeScreen.tsx new file mode 100644 index 000000000..81dc0d0f8 --- /dev/null +++ b/apps/web/src/pages/books/$id/optimize/BookOptimizeScreen.tsx @@ -0,0 +1,150 @@ +import { Alert, Container, Tab, Tabs, Typography, styled } from "@mui/material" +import { memo, type SyntheticEvent, useCallback } from "react" +import { useParams, useSearchParams } from "react-router" +import { Page } from "../../../../common/Page" +import { NotFoundPage } from "../../../../common/NotFoundPage" +import { TopBarNavigation } from "../../../../navigation/TopBarNavigation" +import { ROUTES } from "../../../../navigation/routes" +import { useBook } from "../../../../books/states" +import { useLink } from "../../../../links/states" +import { pluginsByType } from "../../../../plugins/configure" +import { useBookDownloadState } from "../../../../download/states" +import { DownloadBookStep } from "../../../../books/optimize/DownloadBookStep" +import { MetadataTab } from "../../../../books/optimize/MetadataTab" +import { ContentTab } from "../../../../books/optimize/ContentTab" + +type ScreenParams = { + id: string +} + +export const BOOK_OPTIMIZE_TAB_PARAM = "tab" + +export const BOOK_OPTIMIZE_TABS = { + METADATA: "metadata", + CONTENT: "content", +} as const + +export type BookOptimizeTab = + (typeof BOOK_OPTIMIZE_TABS)[keyof typeof BOOK_OPTIMIZE_TABS] + +export const DEFAULT_BOOK_OPTIMIZE_TAB = BOOK_OPTIMIZE_TABS.METADATA + +export const isBookOptimizeTab = ( + value: string | null, +): value is BookOptimizeTab => + value === BOOK_OPTIMIZE_TABS.METADATA || value === BOOK_OPTIMIZE_TABS.CONTENT + +export const getBookOptimizeRoute = ({ + bookId, + tab, +}: { + bookId: string + tab?: BookOptimizeTab +}) => { + const path = ROUTES.BOOK_OPTIMIZE.replace(":id", bookId) + + if (!tab) return path + + const searchParams = new URLSearchParams({ + [BOOK_OPTIMIZE_TAB_PARAM]: tab, + }) + + return `${path}?${searchParams.toString()}` +} + +const PageContainer = styled(Container)(({ theme }) => ({ + display: "flex", + flexDirection: "column", + flex: 1, + minHeight: 0, + marginLeft: 0, + marginRight: "auto", + paddingTop: theme.spacing(2), + paddingBottom: theme.spacing(2), + gap: theme.spacing(2), +})) + +export const BookOptimizeScreen = memo(function BookOptimizeScreen() { + const { id: bookId } = useParams() + const [searchParams, setSearchParams] = useSearchParams() + const { data: book } = useBook({ id: bookId }) + const { data: link } = useLink({ id: book?.links[0] }) + + const plugin = link?.type ? pluginsByType[link.type] : undefined + const canUploadToDataSource = plugin?.canUpsertFile ?? false + + const downloadState = useBookDownloadState(bookId) + const isDownloaded = downloadState?.downloadState === "downloaded" + + const linkFileName = book?.metadata?.find( + (item) => item.type === "link", + )?.title + + const rawTab = searchParams.get(BOOK_OPTIMIZE_TAB_PARAM) + const currentTab: BookOptimizeTab = isBookOptimizeTab(rawTab) + ? rawTab + : DEFAULT_BOOK_OPTIMIZE_TAB + + const handleTabChange = useCallback( + (_event: SyntheticEvent, value: BookOptimizeTab) => { + const next = new URLSearchParams(searchParams) + next.set(BOOK_OPTIMIZE_TAB_PARAM, value) + setSearchParams(next, { replace: true }) + }, + [searchParams, setSearchParams], + ) + + if ( + !bookId || + book === null || + link === null || + (link !== undefined && !plugin) + ) { + return + } + + return ( + + + {book && link && isDownloaded && !canUploadToDataSource && ( + + This data source can't upload files back yet. Changes can only be + applied locally. + + )} + {book && link && isDownloaded && ( + + + + + )} + + {!book || !link ? ( + + Loading book… + + ) : !isDownloaded ? ( + + ) : ( + <> + + + ) +}) diff --git a/apps/web/src/pages/profile/ProfileScreen.tsx b/apps/web/src/pages/profile/ProfileScreen.tsx index 0e00d626c..fe6bb543f 100644 --- a/apps/web/src/pages/profile/ProfileScreen.tsx +++ b/apps/web/src/pages/profile/ProfileScreen.tsx @@ -50,6 +50,7 @@ import { useSignalValue } from "reactjrx" import { authStateSignal } from "../../auth/states.web" import { useRemoveAllContents } from "../../settings/useRemoveAllContents" import { showDialog } from "../../common/dialogs/createDialog" +import { createNotImplementedDialogOptions } from "../../common/dialogs/presets" import { ROUTES } from "../../navigation/routes" import { Page } from "../../common/Page" import { useUnreadNotificationsCount } from "../../notifications/inbox/useUnreadNotificationsCount" @@ -187,7 +188,7 @@ export const ProfileScreen = () => { showDialog({ preset: "NOT_IMPLEMENTED" })} + onClick={() => showDialog(createNotImplementedDialogOptions())} > diff --git a/apps/web/src/plugins/PluginUpsertFile.tsx b/apps/web/src/plugins/PluginUpsertFile.tsx new file mode 100644 index 000000000..96383410d --- /dev/null +++ b/apps/web/src/plugins/PluginUpsertFile.tsx @@ -0,0 +1,45 @@ +import { memo } from "react" +import type { LinkDocType } from "@oboku/shared" +import { pluginsByType } from "./configure" + +type Props = { + link: LinkDocType + file: Blob | File + fileName: string + contentType?: string + signal: AbortSignal + onProgress: (progress: number) => void + onError: (error: unknown) => void + onSuccess: () => void +} + +export const PluginUpsertFile = memo(function PluginUpsertFile({ + link, + ...props +}: Props) { + switch (link.type) { + case "DRIVE": { + const { UpsertFileComponent } = pluginsByType.DRIVE + if (!UpsertFileComponent) break + + return + } + case "dropbox": { + const { UpsertFileComponent } = pluginsByType.dropbox + if (!UpsertFileComponent) break + + return + } + case "one-drive": { + const { UpsertFileComponent } = pluginsByType["one-drive"] + if (!UpsertFileComponent) break + + return + } + default: { + break + } + } + + throw new Error(`Unsupported link type for upsert: ${link.type}`) +}) diff --git a/apps/web/src/plugins/dropbox/UpsertFile.tsx b/apps/web/src/plugins/dropbox/UpsertFile.tsx new file mode 100644 index 000000000..17bb6a362 --- /dev/null +++ b/apps/web/src/plugins/dropbox/UpsertFile.tsx @@ -0,0 +1,64 @@ +import { memo } from "react" +import { + from, + merge, + switchMap, + takeUntil, + throwIfEmpty, + type Observable, +} from "rxjs" +import { useMutation$ } from "reactjrx" +import type { UpsertFileComponent } from "../types" +import { CancelError } from "../../errors/errors.shared" +import { fromAbortSignal } from "../../common/rxjs/fromAbortSignal" +import { useEffectWithUnmount$ } from "../../common/rxjs/useEffectWithUnmount$" +import { scheduleDelayedEffect } from "../../common/useDelayEffect" +import { useRequestPopupDialog } from "../useRequestPopupDialog" +import { authUser } from "./lib/auth" +import { uploadFile } from "./lib/uploadFile" +import { PLUGIN_NAME } from "./constants" + +export const UpsertFile: UpsertFileComponent<"dropbox"> = memo( + function DropboxUpsertFile({ + link, + file, + onError, + onProgress, + onSuccess, + signal, + }) { + const requestPopup = useRequestPopupDialog(PLUGIN_NAME) + + const { mutate: upsert } = useMutation$({ + mutationFn: ({ + onUnmount$, + signal, + }: { + onUnmount$: Observable + signal: AbortSignal + }) => + from(authUser({ requestPopup })).pipe( + switchMap((auth) => + uploadFile({ + accessToken: auth.getAccessToken(), + path: link.data.fileId, + file, + onProgress, + }), + ), + takeUntil(merge(fromAbortSignal(signal), onUnmount$)), + throwIfEmpty(() => new CancelError()), + ), + onSuccess: () => onSuccess(), + onError: (error) => onError(error), + }) + + useEffectWithUnmount$( + (onUnmount$) => + scheduleDelayedEffect(() => upsert({ onUnmount$, signal }), 1), + [upsert, signal], + ) + + return null + }, +) diff --git a/apps/web/src/plugins/dropbox/index.tsx b/apps/web/src/plugins/dropbox/index.tsx index 6c749568d..78823d9a6 100644 --- a/apps/web/src/plugins/dropbox/index.tsx +++ b/apps/web/src/plugins/dropbox/index.tsx @@ -13,6 +13,7 @@ import { useSignOut } from "./useSignOut" import { DownloadBook } from "./DownloadBook" import { useLinkInfo } from "./useLinkInfo" import { useSyncSourceInfo } from "./useSyncSourceInfo" +import { UpsertFile } from "./UpsertFile" const DropboxIcon = (props: React.ComponentProps) => ( @@ -30,6 +31,7 @@ export const plugin: ObokuPlugin<"dropbox"> = { type: `dropbox`, name: PLUGIN_NAME, canRemoveResource: false, + canUpsertFile: true, Icon: DropboxIcon, UploadBookComponent: UploadBook, DataSourceCreateForm: (props) => ( @@ -39,6 +41,7 @@ export const plugin: ObokuPlugin<"dropbox"> = { ), DownloadBookComponent: DownloadBook, + UpsertFileComponent: UpsertFile, useRemoveResource, useLinkInfo, useSyncSourceInfo, diff --git a/apps/web/src/plugins/dropbox/lib/uploadFile.ts b/apps/web/src/plugins/dropbox/lib/uploadFile.ts new file mode 100644 index 000000000..f08a97a26 --- /dev/null +++ b/apps/web/src/plugins/dropbox/lib/uploadFile.ts @@ -0,0 +1,46 @@ +import { httpClientWeb } from "../../../http/httpClient.web" +import { toProgressRatioHandler } from "../../../http/toProgressRatioHandler" + +const DROPBOX_UPLOAD_URL = "https://content.dropboxapi.com/2/files/upload" + +/** + * Dropbox requires `Dropbox-API-Arg` header bytes to be ASCII even + * though the value is JSON, so any non-ASCII code point has to be + * re-encoded as a `\uXXXX` JSON escape. Surrogate pairs round-trip + * correctly because each unit is escaped independently. + * + * Mirrors the `httpHeaderSafeJson` helper in `dropbox-sdk-js` + * (`node_modules/dropbox/src/utils.js`); we duplicate it because + * the SDK doesn't re-export it from its public entry. Source: + * https://www.dropboxforum.com/t5/API-support/HTTP-header-quot-Dropbox-API-Arg-quot-could-not-decode-input-as/m-p/173823/#M6786 + */ +const toAsciiSafeJson = (value: object) => + JSON.stringify(value).replace(/[\u007f-\uffff]/g, (char) => { + const hex = char.charCodeAt(0).toString(16).padStart(4, "0") + + return `\\u${hex}` + }) + +type Params = { + accessToken: string + path: string + file: Blob | File + onProgress?: (progress: number) => void +} + +export const uploadFile = ({ accessToken, path, file, onProgress }: Params) => + httpClientWeb.upload$({ + url: DROPBOX_UPLOAD_URL, + method: "POST", + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/octet-stream", + "Dropbox-API-Arg": toAsciiSafeJson({ + path, + mode: { ".tag": "overwrite" }, + mute: true, + }), + }, + body: file, + onUploadProgress: toProgressRatioHandler(onProgress), + }) diff --git a/apps/web/src/plugins/google/UpsertFile.tsx b/apps/web/src/plugins/google/UpsertFile.tsx new file mode 100644 index 000000000..744a04068 --- /dev/null +++ b/apps/web/src/plugins/google/UpsertFile.tsx @@ -0,0 +1,82 @@ +import { memo } from "react" +import { + merge, + switchMap, + takeUntil, + throwIfEmpty, + type Observable, +} from "rxjs" +import { useMutation$ } from "reactjrx" +import type { UpsertFileComponent } from "../types" +import { CancelError } from "../../errors/errors.shared" +import { fromAbortSignal } from "../../common/rxjs/fromAbortSignal" +import { useEffectWithUnmount$ } from "../../common/rxjs/useEffectWithUnmount$" +import { useRequestPopupDialog } from "../useRequestPopupDialog" +import { useGoogleScripts } from "./lib/scripts" +import { useRequestToken } from "./lib/useRequestToken" +import { useRequestFilesAccess } from "./lib/useRequestFilesAccess" +import { GOOGLE_DRIVE_FILE_SCOPES, PLUGIN_NAME } from "./lib/constants" +import { updateDriveFileMedia } from "./lib/updateDriveFileMedia" +import { scheduleDelayedEffect } from "../../common/useDelayEffect" + +// `requestFilesAccess` is required: without the picker-token scope +// for this fileId Drive returns 401/403 even with a valid OAuth token. +export const UpsertFile: UpsertFileComponent<"DRIVE"> = memo( + function GoogleUpsertFile({ + link, + file, + contentType, + onError, + onProgress, + onSuccess, + signal, + }) { + const requestPopup = useRequestPopupDialog(PLUGIN_NAME) + const { getGoogleScripts } = useGoogleScripts() + const { requestToken } = useRequestToken({ requestPopup }) + const requestFilesAccess = useRequestFilesAccess({ requestPopup }) + + const { mutate: upsert } = useMutation$({ + mutationFn: ({ + onUnmount$, + signal, + }: { + onUnmount$: Observable + signal: AbortSignal + }) => { + const fileId = link.data.fileId + + return getGoogleScripts().pipe( + switchMap(([, gapiInstance]) => + requestFilesAccess(gapiInstance, [fileId]).pipe( + switchMap(() => + requestToken({ scope: GOOGLE_DRIVE_FILE_SCOPES }), + ), + ), + ), + switchMap((accessToken) => + updateDriveFileMedia({ + fileId, + file, + accessToken: accessToken.access_token, + contentType, + onProgress, + }), + ), + takeUntil(merge(fromAbortSignal(signal), onUnmount$)), + throwIfEmpty(() => new CancelError()), + ) + }, + onSuccess: () => onSuccess(), + onError: (error) => onError(error), + }) + + useEffectWithUnmount$( + (onUnmount$) => + scheduleDelayedEffect(() => upsert({ onUnmount$, signal }), 1), + [upsert, signal], + ) + + return null + }, +) diff --git a/apps/web/src/plugins/google/index.tsx b/apps/web/src/plugins/google/index.tsx index e7d254dcc..0e34f689c 100644 --- a/apps/web/src/plugins/google/index.tsx +++ b/apps/web/src/plugins/google/index.tsx @@ -18,6 +18,7 @@ import { InfoScreen } from "./InfoScreen" import { DataSourceDetails } from "./DataSourceDetails" import { DownloadBook } from "./DownloadBook" import { useLinkInfo } from "./useLinkInfo" +import { UpsertFile } from "./UpsertFile" const GoogleDriveIcon = (props: React.ComponentProps) => ( @@ -35,10 +36,12 @@ export const plugin: ObokuPlugin<"DRIVE"> = { type: `DRIVE`, name: PLUGIN_NAME, canRemoveResource: false, + canUpsertFile: true, Icon: GoogleDriveIcon, UploadBookComponent: UploadBook, canSynchronize: true, DownloadBookComponent: DownloadBook, + UpsertFileComponent: UpsertFile, DataSourceCreateForm: DataSourceForm, DataSourceEditForm: DataSourceDetails, SelectItemComponent, diff --git a/apps/web/src/plugins/google/lib/updateDriveFileMedia.ts b/apps/web/src/plugins/google/lib/updateDriveFileMedia.ts new file mode 100644 index 000000000..4209acb3e --- /dev/null +++ b/apps/web/src/plugins/google/lib/updateDriveFileMedia.ts @@ -0,0 +1,28 @@ +import { httpClientWeb } from "../../../http/httpClient.web" +import { toProgressRatioHandler } from "../../../http/toProgressRatioHandler" + +type Params = { + fileId: string + file: Blob | File + accessToken: string + contentType?: string + onProgress?: (progress: number) => void +} + +export const updateDriveFileMedia = ({ + fileId, + file, + accessToken, + contentType, + onProgress, +}: Params) => + httpClientWeb.upload$({ + url: `https://www.googleapis.com/upload/drive/v3/files/${fileId}?uploadType=media&supportsAllDrives=true`, + method: "PATCH", + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": contentType ?? file.type ?? "application/octet-stream", + }, + body: file, + onUploadProgress: toProgressRatioHandler(onProgress), + }) diff --git a/apps/web/src/plugins/google/lib/useHasFilesAccess.ts b/apps/web/src/plugins/google/lib/useHasFilesAccess.ts index c9a4077b4..f2dfa82e0 100644 --- a/apps/web/src/plugins/google/lib/useHasFilesAccess.ts +++ b/apps/web/src/plugins/google/lib/useHasFilesAccess.ts @@ -37,7 +37,6 @@ export const useHasFilesAccess = ({ getDriveFile(_gapi, { fileId, supportsAllDrives: true, - supportsTeamDrives: true, fields: "capabilities, id", }).pipe(catchError(() => of(null))), ), diff --git a/apps/web/src/plugins/one-drive/UpsertFile.tsx b/apps/web/src/plugins/one-drive/UpsertFile.tsx new file mode 100644 index 000000000..c592b713a --- /dev/null +++ b/apps/web/src/plugins/one-drive/UpsertFile.tsx @@ -0,0 +1,76 @@ +import { memo } from "react" +import { + from, + merge, + switchMap, + takeUntil, + throwIfEmpty, + type Observable, +} from "rxjs" +import { useMutation$ } from "reactjrx" +import type { UpsertFileComponent } from "../types" +import { CancelError } from "../../errors/errors.shared" +import { fromAbortSignal } from "../../common/rxjs/fromAbortSignal" +import { useEffectWithUnmount$ } from "../../common/rxjs/useEffectWithUnmount$" +import { scheduleDelayedEffect } from "../../common/useDelayEffect" +import { useRequestPopupDialog } from "../useRequestPopupDialog" +import { requestMicrosoftAccessToken } from "./auth/auth" +import { + ONE_DRIVE_GRAPH_WRITE_SCOPES, + ONE_DRIVE_PLUGIN_NAME, +} from "./constants" +import { updateOneDriveDriveItemContent } from "./graph" + +export const UpsertFile: UpsertFileComponent<"one-drive"> = memo( + function OneDriveUpsertFile({ + link, + file, + contentType, + onError, + onProgress, + onSuccess, + signal, + }) { + const requestPopup = useRequestPopupDialog(ONE_DRIVE_PLUGIN_NAME) + + const { mutate: upsert } = useMutation$({ + mutationFn: ({ + onUnmount$, + signal, + }: { + onUnmount$: Observable + signal: AbortSignal + }) => + from( + requestMicrosoftAccessToken({ + interaction: "allow-interactive", + requestPopup, + scopes: ONE_DRIVE_GRAPH_WRITE_SCOPES, + }), + ).pipe( + switchMap((authResult) => + updateOneDriveDriveItemContent({ + accessToken: authResult.accessToken, + contentType, + driveId: link.data.driveId, + file, + fileId: link.data.fileId, + onProgress, + }), + ), + takeUntil(merge(fromAbortSignal(signal), onUnmount$)), + throwIfEmpty(() => new CancelError()), + ), + onSuccess: () => onSuccess(), + onError: (error) => onError(error), + }) + + useEffectWithUnmount$( + (onUnmount$) => + scheduleDelayedEffect(() => upsert({ onUnmount$, signal }), 1), + [upsert, signal], + ) + + return null + }, +) diff --git a/apps/web/src/plugins/one-drive/constants.ts b/apps/web/src/plugins/one-drive/constants.ts index 8db1f4a1a..0f415db72 100644 --- a/apps/web/src/plugins/one-drive/constants.ts +++ b/apps/web/src/plugins/one-drive/constants.ts @@ -1,12 +1,15 @@ export const ONE_DRIVE_PLUGIN_NAME = "OneDrive" /** - * Fully qualified Graph scope. The bare shorthand `Files.Read` is ambiguous + * Fully qualified Graph scopes. Bare shorthands like `Files.Read` are ambiguous * under the `/consumers` authority and can produce an opaque token instead * of a JWT, which the Graph API rejects. The full URI ensures the token is * always issued for the `https://graph.microsoft.com` audience. */ export const ONE_DRIVE_GRAPH_SCOPES = ["https://graph.microsoft.com/Files.Read"] +export const ONE_DRIVE_GRAPH_WRITE_SCOPES = [ + "https://graph.microsoft.com/Files.ReadWrite", +] export const ONE_DRIVE_CONSUMER_AUTHORITY = "https://login.microsoftonline.com/consumers" diff --git a/apps/web/src/plugins/one-drive/graph/index.test.ts b/apps/web/src/plugins/one-drive/graph/index.test.ts index 480ae22d8..776437bfa 100644 --- a/apps/web/src/plugins/one-drive/graph/index.test.ts +++ b/apps/web/src/plugins/one-drive/graph/index.test.ts @@ -113,4 +113,67 @@ describe("getOneDrivePickerBaseUrl", () => { }, ) }) + + it("updates drive item content through Microsoft Graph", async () => { + type UploadProgressEvent = Pick< + ProgressEvent, + "lengthComputable" | "loaded" | "total" + > + type UploadRequest = { + body: Blob | File + headers: Record + method: string + onUploadProgress?: (event: UploadProgressEvent) => void + url: string + } + let uploadRequest: UploadRequest | undefined + const upload$ = vi.fn((request: UploadRequest) => { + uploadRequest = request + + return "upload-result" + }) + + vi.doMock("../../../http/httpClient.web", () => ({ + httpClientWeb: { + upload$, + }, + })) + + const { updateOneDriveDriveItemContent } = await import("./index") + const file = new File(["contents"], "Book.epub", { + type: "application/epub+zip", + }) + const onProgress = vi.fn() + + expect( + updateOneDriveDriveItemContent({ + accessToken: "graph-token", + driveId: "drive/id", + file, + fileId: "file id", + onProgress, + }), + ).toBe("upload-result") + + expect(upload$).toHaveBeenCalledWith({ + url: "https://graph.microsoft.com/v1.0/drives/drive%2Fid/items/file%20id/content", + method: "PUT", + headers: { + Authorization: "Bearer graph-token", + "Content-Type": "application/epub+zip", + }, + body: file, + onUploadProgress: expect.any(Function), + }) + + expect(uploadRequest?.onUploadProgress).toEqual(expect.any(Function)) + + uploadRequest?.onUploadProgress?.({ + lengthComputable: true, + loaded: 1, + total: 4, + }) + + expect(onProgress).toHaveBeenCalledWith(0.25) + }) }) diff --git a/apps/web/src/plugins/one-drive/graph/index.ts b/apps/web/src/plugins/one-drive/graph/index.ts index 7190ec7cc..82d739ab2 100644 --- a/apps/web/src/plugins/one-drive/graph/index.ts +++ b/apps/web/src/plugins/one-drive/graph/index.ts @@ -7,6 +7,8 @@ import { } from "@oboku/shared" import { map } from "rxjs" import { fromFetch } from "rxjs/fetch" +import { httpClientWeb } from "../../../http/httpClient.web" +import { toProgressRatioHandler } from "../../../http/toProgressRatioHandler" import { ONE_DRIVE_CONSUMER_PICKER_BASE_URL } from "../constants" const MICROSOFT_GRAPH_ME_DRIVE_URL = @@ -63,6 +65,10 @@ function buildOneDriveItemSummaryUrl(driveId: string, fileId: string) { return url.toString() } +function buildOneDriveItemContentUrl(driveId: string, fileId: string) { + return `${buildDriveItemUrl(driveId, fileId)}/content` +} + export async function getOneDriveItemSummary({ accessToken, driveId, @@ -131,3 +137,30 @@ export function getOneDriveDownloadInfo$({ }), ) } + +export function updateOneDriveDriveItemContent({ + accessToken, + contentType, + driveId, + file, + fileId, + onProgress, +}: { + accessToken: string + contentType?: string + driveId: string + file: Blob | File + fileId: string + onProgress?: (progress: number) => void +}) { + return httpClientWeb.upload$({ + url: buildOneDriveItemContentUrl(driveId, fileId), + method: "PUT", + headers: { + ...getMicrosoftGraphAuthorizationHeaders(accessToken), + "Content-Type": contentType || file.type || "application/octet-stream", + }, + body: file, + onUploadProgress: toProgressRatioHandler(onProgress), + }) +} diff --git a/apps/web/src/plugins/one-drive/index.tsx b/apps/web/src/plugins/one-drive/index.tsx index 7abc794fd..634a86606 100644 --- a/apps/web/src/plugins/one-drive/index.tsx +++ b/apps/web/src/plugins/one-drive/index.tsx @@ -9,6 +9,7 @@ import { DownloadBook } from "./DownloadBook" import { Provider } from "./Provider" import { InfoScreen } from "./InfoScreen" import { UploadBook } from "./UploadBook" +import { UpsertFile } from "./UpsertFile" import { useLinkInfo } from "./useLinkInfo" import { useRefreshMetadata } from "./useRefreshMetadata" import { useSynchronize } from "./useSynchronize" @@ -29,6 +30,7 @@ const useRemoveResource: ObokuPlugin<"one-drive">["useRemoveResource"] = () => { export const plugin: ObokuPlugin<"one-drive"> = { canSynchronize: true, + canUpsertFile: true, canRemoveResource: false, description: "Manage contents from Microsoft OneDrive", DataSourceCreateForm, @@ -39,6 +41,7 @@ export const plugin: ObokuPlugin<"one-drive"> = { Provider, type: PLUGIN_ONE_DRIVE_TYPE, UploadBookComponent: UploadBook, + UpsertFileComponent: UpsertFile, InfoScreen, useLinkInfo, useRefreshMetadata, diff --git a/apps/web/src/plugins/types.ts b/apps/web/src/plugins/types.ts index 8c22aa3f0..6b0fa863e 100644 --- a/apps/web/src/plugins/types.ts +++ b/apps/web/src/plugins/types.ts @@ -116,6 +116,35 @@ type UseRemoveResource< links: readonly LinkDocTypeForProvider[], ) => Promise> +export type UpsertFileComponentProps< + T extends DataSourceDocType["type"] = DataSourceDocType["type"], +> = { + link: LinkDocTypeForProvider + file: Blob | File + fileName: string + contentType?: string + onProgress: (progress: number) => void + onError: (error: unknown) => void + onSuccess: () => void + signal: AbortSignal +} + +export type UpsertFileComponent< + T extends DataSourceDocType["type"] = DataSourceDocType["type"], +> = ComponentType> + +type UpsertFileCapability< + T extends DataSourceDocType["type"] = DataSourceDocType["type"], +> = + | { + canUpsertFile: true + UpsertFileComponent: UpsertFileComponent + } + | { + canUpsertFile?: false + UpsertFileComponent?: undefined + } + export type UseSyncSourceInfo< T extends DataSourceDocType["type"] = DataSourceDocType["type"], > = (data: { @@ -174,7 +203,7 @@ export type DataSourceEditFormProps< onSubmit: (payload: DataSourceSubmitPayload) => void } -export type ObokuPlugin< +type ObokuPluginBase< T extends DataSourceDocType["type"] = DataSourceDocType["type"], > = { name: string @@ -216,3 +245,7 @@ export type ObokuPlugin< useSyncSourceInfo: UseSyncSourceInfo useSignOut: () => () => void } + +export type ObokuPlugin< + T extends DataSourceDocType["type"] = DataSourceDocType["type"], +> = ObokuPluginBase & UpsertFileCapability diff --git a/apps/web/src/plugins/uri/DownloadBook.tsx b/apps/web/src/plugins/uri/DownloadBook.tsx index cff498308..8004cb963 100644 --- a/apps/web/src/plugins/uri/DownloadBook.tsx +++ b/apps/web/src/plugins/uri/DownloadBook.tsx @@ -38,7 +38,6 @@ export const DownloadBook = memo( return from( httpClientWeb.download({ responseType: "blob", - mode: "cors", signal: abortController.signal, url: downloadLink, onDownloadProgress: (event) => { diff --git a/apps/web/src/plugins/useCreateRequestPopupDialog.ts b/apps/web/src/plugins/useCreateRequestPopupDialog.ts index 8a9ab3c0e..c4f4aa4d9 100644 --- a/apps/web/src/plugins/useCreateRequestPopupDialog.ts +++ b/apps/web/src/plugins/useCreateRequestPopupDialog.ts @@ -1,18 +1,14 @@ import { useCallback } from "react" -import { createDialog } from "../common/dialogs/createDialog" +import { showConfirmDialog } from "../common/dialogs/presets" export const useCreateRequestPopupDialog = () => { return useCallback( ({ name }: { name: string }) => - async () => { - return createDialog({ - preset: "CONFIRM", + () => + showConfirmDialog({ title: `Plugin ${name} requires some actions`, message: `To proceed, the plugin ${name} requires some action from you which involve opening a popup`, - }) - .promise.then(() => true) - .catch(() => false) - }, + }), [], ) } diff --git a/apps/web/src/plugins/usePluginUpsertFile.tsx b/apps/web/src/plugins/usePluginUpsertFile.tsx new file mode 100644 index 000000000..05aa22e84 --- /dev/null +++ b/apps/web/src/plugins/usePluginUpsertFile.tsx @@ -0,0 +1,87 @@ +import { useEffect, useState, type ReactNode } from "react" +import { useMutation } from "@tanstack/react-query" +import { useLiveRef } from "reactjrx" +import { BehaviorSubject, type Observable } from "rxjs" +import type { LinkDocType } from "@oboku/shared" +import { CancelError } from "../errors/errors.shared" +import { PluginUpsertFile } from "./PluginUpsertFile" + +type UpsertFileVariables = { + link: LinkDocType + file: Blob | File + fileName: string + contentType?: string +} + +type Pending = UpsertFileVariables & { + abortController: AbortController + progress$: BehaviorSubject + handleProgress: (value: number) => void + resolve: () => void + reject: (error: Error) => void +} + +const SCOPE_ID = "plugin-upsert-file" + +export const usePluginUpsertFile = () => { + const [pending, setPending] = useState(null) + const pendingRef = useLiveRef(pending) + + useEffect( + () => () => { + const inFlight = pendingRef.current + if (!inFlight) return + inFlight.abortController.abort() + inFlight.progress$.complete() + inFlight.reject(new CancelError()) + }, + [pendingRef], + ) + + const mutation = useMutation({ + scope: { id: SCOPE_ID }, + mutationFn: async (variables: UpsertFileVariables) => { + const abortController = new AbortController() + const progress$ = new BehaviorSubject(0) + const handleProgress = (value: number) => progress$.next(value) + + try { + await new Promise((resolve, reject) => { + setPending({ + ...variables, + abortController, + progress$, + handleProgress, + resolve, + reject, + }) + }) + } finally { + abortController.abort() + progress$.complete() + setPending(null) + } + }, + }) + + const slot: ReactNode = pending ? ( + + pending.reject( + error instanceof Error ? error : new Error(String(error)), + ) + } + /> + ) : null + + const progress$: Observable | undefined = pending?.progress$ + + return { ...mutation, slot, progress$ } +} diff --git a/apps/web/src/reader/Reader.tsx b/apps/web/src/reader/Reader.tsx index ea6d7f91f..db0604ee2 100644 --- a/apps/web/src/reader/Reader.tsx +++ b/apps/web/src/reader/Reader.tsx @@ -21,7 +21,13 @@ import { localSettingsSignal } from "../settings/useLocalSettings" import { useSettingsFormValues } from "./settings/useSettingsFormValues" import { useShowBookFinishedDialog } from "./navigation/useShowBookFinishedDialog" -export const Reader = memo(function Reader({ bookId }: { bookId: string }) { +export const Reader = memo(function Reader({ + bookId, + isPreview, +}: { + bookId: string + isPreview: boolean +}) { const reader = useSignalValue(readerSignal) const { data: readerState } = useObserve(() => reader?.state$, [reader]) const readerContainerRef = useRef(null) @@ -41,6 +47,7 @@ export const Reader = memo(function Reader({ bookId }: { bookId: string }) { const { showBookFinishedDialogOnClose } = useShowBookFinishedDialog({ bookId, onClose: goBack, + enabled: !isPreview, }) const onItemClick = useCallback( @@ -99,6 +106,7 @@ export const Reader = memo(function Reader({ bookId }: { bookId: string }) { {readerState !== "ready" && } { const { bookId } = useParams<{ bookId?: string }>() + const [searchParams] = useSearchParams() + const isPreview = searchParams.get(READER_MODE_PARAM) === READER_PREVIEW_MODE return ( - {bookId && } + {bookId && } - + ) }) -const Effects = memo(({ bookId }: { bookId?: string }) => { - useTrackBookBeingRead(bookId) +const Effects = memo(function Effects({ + bookId, + isPreview, +}: { + bookId?: string + isPreview: boolean +}) { + useTrackBookBeingRead(bookId, { enabled: !isPreview }) useWakeLock() useFullscreenAutoSwitch() diff --git a/apps/web/src/reader/navigation/useShowBookFinishedDialog.tsx b/apps/web/src/reader/navigation/useShowBookFinishedDialog.tsx index d7b0cfd7a..11729d9f9 100644 --- a/apps/web/src/reader/navigation/useShowBookFinishedDialog.tsx +++ b/apps/web/src/reader/navigation/useShowBookFinishedDialog.tsx @@ -20,9 +20,11 @@ type BookFinishedDialogState = { export const useShowBookFinishedDialog = ({ bookId, onClose, + enabled = true, }: { bookId: string onClose: () => void + enabled?: boolean }) => { const reader = useReader() const { mutate: removeDownloadFile } = useRemoveDownloadFile() @@ -32,6 +34,7 @@ export const useShowBookFinishedDialog = ({ useEffect( function setBookFinishedDialogStateEffect() { + if (!enabled) return if (!book || bookFinishedDialogState?.bookId === bookId) { return } @@ -43,7 +46,7 @@ export const useShowBookFinishedDialog = ({ wasDialogShown: false, }) }, - [book, bookFinishedDialogState?.bookId, bookId], + [book, bookFinishedDialogState?.bookId, bookId, enabled], ) const createBookFinishedDialog = useCallback(() => { @@ -86,6 +89,7 @@ export const useShowBookFinishedDialog = ({ }, [bookFinishedDialogState, bookId]) const subscribeToBookBoundaryReached = useCallback(() => { + if (!enabled) return if (!reader || wasFinishedWhenOpened !== false) return if (wasDialogShown) return @@ -106,6 +110,7 @@ export const useShowBookFinishedDialog = ({ }, [ markDialogAsShown, reader, + enabled, wasDialogShown, wasFinishedWhenOpened, createBookFinishedDialog, @@ -116,6 +121,7 @@ export const useShowBookFinishedDialog = ({ const { mutate: showBookFinishedDialogOnClose } = useMutation({ mutationFn: async () => { + if (!enabled) return null if (wasFinishedWhenOpened !== false) return null if (wasDialogShown) return null if (book?.readingStateCurrentState !== ReadingStateState.Finished) { diff --git a/apps/web/src/reader/progress/useSyncBookProgress.ts b/apps/web/src/reader/progress/useSyncBookProgress.ts index a21b5017c..8086481fc 100644 --- a/apps/web/src/reader/progress/useSyncBookProgress.ts +++ b/apps/web/src/reader/progress/useSyncBookProgress.ts @@ -31,11 +31,15 @@ const normalizeProgress = (progress: number | undefined) => { return Number(progress.toFixed(4)) } -export const useSyncBookProgress = (bookId: string) => { +export const useSyncBookProgress = ( + bookId: string, + { enabled = true }: { enabled?: boolean } = {}, +) => { const reader = useReader() const { mutateAsync: incrementalBookModify } = useIncrementalBookModify() useEffect(() => { + if (!enabled) return if (!reader) return // Signals that the hook is unmounting. When it emits, takeUntil() @@ -154,5 +158,5 @@ export const useSyncBookProgress = (bookId: string) => { unmount$.complete() sub.unsubscribe() } - }, [reader, bookId, incrementalBookModify]) + }, [reader, bookId, incrementalBookModify, enabled]) } diff --git a/apps/web/src/reader/useLoadReader.ts b/apps/web/src/reader/useLoadReader.ts index 8beb20876..3305996f6 100644 --- a/apps/web/src/reader/useLoadReader.ts +++ b/apps/web/src/reader/useLoadReader.ts @@ -8,16 +8,20 @@ export const useLoadReader = ({ manifest, containerElement, bookId, + isPreview, }: { manifest?: Manifest containerElement?: HTMLElement | null bookId?: string + isPreview: boolean }) => { const reader = useSignalValue(readerSignal) const isBookLoadedRef = useRef(false) const { data: book } = useBook({ id: bookId, enabled: (query) => { + if (isPreview) return false + const hasNoResultYet = query.state.data === undefined return hasNoResultYet @@ -30,15 +34,18 @@ export const useLoadReader = ({ reader && manifest && containerElement && - book + (isPreview || book) ) { isBookLoadedRef.current = true + const cfi = isPreview + ? undefined + : book?.readingStateCurrentBookmarkLocation || undefined reader.load({ containerElement, manifest, - cfi: book.readingStateCurrentBookmarkLocation || undefined, + ...(cfi ? { cfi } : {}), }) } - }, [manifest, book, containerElement, reader]) + }, [manifest, book, containerElement, reader, isPreview]) } diff --git a/apps/web/src/reading/useTrackBookBeingRead.ts b/apps/web/src/reading/useTrackBookBeingRead.ts index 128f4a287..efbf87351 100644 --- a/apps/web/src/reading/useTrackBookBeingRead.ts +++ b/apps/web/src/reading/useTrackBookBeingRead.ts @@ -5,16 +5,23 @@ import { } from "./states" import { SIGNAL_RESET } from "reactjrx" -export const useTrackBookBeingRead = (bookId: string | undefined) => { +export const useTrackBookBeingRead = ( + bookId: string | undefined, + { enabled = true }: { enabled?: boolean } = {}, +) => { useEffect(() => { + if (!enabled) return + bookBeingReadStateSignal.setValue(bookId) hasOpenedReaderAlreadyStateSignal.setValue(true) - }, [bookId]) + }, [bookId, enabled]) useEffect( () => () => { + if (!enabled) return + bookBeingReadStateSignal.setValue(SIGNAL_RESET) }, - [], + [enabled], ) } diff --git a/biome.json b/biome.json index b6c8b6693..3c91e3609 100644 --- a/biome.json +++ b/biome.json @@ -1,5 +1,5 @@ { - "$schema": "https://biomejs.dev/schemas/2.4.13/schema.json", + "$schema": "https://biomejs.dev/schemas/2.4.15/schema.json", "vcs": { "enabled": false, "clientKind": "git", diff --git a/config/vite.lib.ts b/config/vite.lib.ts index f239eb028..17222f91d 100644 --- a/config/vite.lib.ts +++ b/config/vite.lib.ts @@ -1,5 +1,6 @@ import { defineConfig, type UserConfigFnObject } from "vite" import dts from "unplugin-dts/vite" +import externals from "rollup-plugin-node-externals" /** * Shared Vite config factory for internal `packages/*` libraries. @@ -20,5 +21,8 @@ export const definePackageLibConfig = (name: string): UserConfigFnObject => emptyOutDir: mode !== "development", sourcemap: true, }, - plugins: [dts({ bundleTypes: true })], + plugins: [ + externals({ peerDeps: true, deps: true, devDeps: true }), + dts({ bundleTypes: true }), + ], })) diff --git a/package-lock.json b/package-lock.json index cdbd873d1..90eee52a3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,11 +27,11 @@ "lint-staged": "^16.1.5", "rollup": "^4.60.1", "rollup-plugin-analyzer": "^4.0.0", + "rollup-plugin-node-externals": "^8.0.0", "typescript": "^5.9.2", "unplugin-dts": "^1.0.0", "vercel": "^50.25.6", - "vitest": "^4.0.8", - "webpack-node-externals": "^3.0.0" + "vitest": "^4.0.8" }, "engines": { "node": "22.x" @@ -84,6 +84,7 @@ "@nestjs/platform-express": "^11.0.1", "@nestjs/schedule": "^6.0.0", "@nestjs/typeorm": "^11.0.0", + "@oboku/archive-metadata": "^0.1.0", "@oboku/shared": "^0.8.0", "@oboku/synology": "^0.1.0", "@sentry/nestjs": "^10.25.0", @@ -198,6 +199,7 @@ "@mui/material": "^9.0.0", "@mui/utils": "^9.0.0", "@mui/x-tree-view": "^9.0.2", + "@oboku/archive-metadata": "0.1.0", "@oboku/shared": "0.8.0", "@oboku/synology": "0.1.0", "@prose-reader/cbz": "^1.291.0", @@ -9521,6 +9523,10 @@ "resolved": "apps/api", "link": true }, + "node_modules/@oboku/archive-metadata": { + "resolved": "packages/archive-metadata", + "link": true + }, "node_modules/@oboku/landing": { "resolved": "apps/landing", "link": true @@ -34358,6 +34364,29 @@ "node": ">=8.0.0" } }, + "node_modules/rollup-plugin-node-externals": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/rollup-plugin-node-externals/-/rollup-plugin-node-externals-8.1.2.tgz", + "integrity": "sha512-EuB6/lolkMLK16gvibUjikERq5fCRVIGwD2xue/CrM8D0pz5GXD2V6N8IrgxegwbcUoKkUFI8VYCEEv8MMvgpA==", + "dev": true, + "funding": [ + { + "type": "patreon", + "url": "https://patreon.com/Septh" + }, + { + "type": "paypal", + "url": "https://paypal.me/septh07" + } + ], + "license": "MIT", + "engines": { + "node": ">= 21 || ^20.6.0 || ^18.19.0" + }, + "peerDependencies": { + "rollup": "^4.0.0" + } + }, "node_modules/router": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", @@ -40191,6 +40220,18 @@ "url": "https://github.com/sponsors/wooorm" } }, + "packages/archive-metadata": { + "name": "@oboku/archive-metadata", + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "@prose-reader/archive-parser": "^1.277.0", + "@prose-reader/shared": "^1.277.0" + }, + "devDependencies": { + "vite": "^8.0.5" + } + }, "packages/shared": { "name": "@oboku/shared", "version": "0.8.0", diff --git a/package.json b/package.json index 7c7b23b4a..f44dbfd95 100644 --- a/package.json +++ b/package.json @@ -43,7 +43,7 @@ "unplugin-dts": "^1.0.0", "vercel": "^50.25.6", "vitest": "^4.0.8", - "webpack-node-externals": "^3.0.0" + "rollup-plugin-node-externals": "^8.0.0" }, "dependencies": { "@aws-sdk/client-s3": "^3.787.0", diff --git a/packages/archive-metadata/package.json b/packages/archive-metadata/package.json new file mode 100644 index 000000000..f9f975e04 --- /dev/null +++ b/packages/archive-metadata/package.json @@ -0,0 +1,32 @@ +{ + "name": "@oboku/archive-metadata", + "version": "0.1.0", + "type": "module", + "types": "./dist/index.d.ts", + "main": "./dist/index.js", + "module": "./dist/index.js", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.cjs" + } + }, + "license": "MIT", + "files": [ + "/dist" + ], + "scripts": { + "start:dev": "vite build --watch --mode development", + "start": "npm run start:dev", + "build": "tsc && vite build", + "test": "vitest run" + }, + "dependencies": { + "@prose-reader/archive-parser": "^1.277.0", + "@prose-reader/shared": "^1.277.0" + }, + "devDependencies": { + "vite": "^8.0.5" + } +} diff --git a/packages/archive-metadata/src/archive/types.ts b/packages/archive-metadata/src/archive/types.ts new file mode 100644 index 000000000..79bbc2a58 --- /dev/null +++ b/packages/archive-metadata/src/archive/types.ts @@ -0,0 +1,54 @@ +/** + * Minimal, runtime-agnostic archive abstraction the `@oboku/archive-metadata` + * readers and writers consume. Each runtime (Node/unzipper, browser/JSZip, + * …) supplies a thin adapter that plugs into these interfaces; none of + * the archive libraries bleed into the pure format code. + * + * Design notes: + * - Entries are addressed by their *exact* path inside the archive (same + * casing, same separators). Case-insensitive lookups are the caller's + * responsibility — see `findEntry` for the canonical pattern. + * - `readAsString` vs `readAsUint8Array` both have to be supported: XML + * bodies need UTF-8 decoding while covers are binary. Adapters are + * free to decode lazily; formats code never assumes memoization. + */ +export type ArchiveEntry = { + /** + * Path inside the archive, as reported by the underlying zip library. + * Always normalized to forward slashes; never starts with `./` or `/`. + */ + path: string + /** `true` for directory entries, which have no meaningful bytes. */ + isDir: boolean + /** + * Uncompressed size in bytes when the adapter can surface it cheaply; + * otherwise `undefined`. Readers may use this to short-circuit before + * fully decoding a large entry. + */ + size?: number + readAsString(): Promise + readAsUint8Array(): Promise +} + +export type ArchiveSource = { + /** + * Snapshot of every entry in the archive. Adapters are expected to + * materialize the full list eagerly — streaming-only adapters should + * buffer into memory once before returning. + */ + listEntries(): Promise +} + +/** + * Convenience lookup that adapters don't need to reimplement. Walks the + * entry list with a predicate and returns the first match, skipping + * directory entries. + */ +export const findEntry = async ( + source: ArchiveSource, + predicate: (entry: ArchiveEntry) => boolean, +): Promise => { + const entries = await source.listEntries() + + return entries.find((entry) => !entry.isDir && predicate(entry)) +} diff --git a/packages/archive-metadata/src/comicInfo/index.test.ts b/packages/archive-metadata/src/comicInfo/index.test.ts new file mode 100644 index 000000000..c04e94501 --- /dev/null +++ b/packages/archive-metadata/src/comicInfo/index.test.ts @@ -0,0 +1,248 @@ +// @vitest-environment jsdom +import { describe, expect, it } from "vitest" +import { + parseComicInfo, + resolveArchiveMetadata, +} from "@prose-reader/archive-parser" +import type { ArchiveEntry, ArchiveSource } from "../archive/types" +import { + COMIC_INFO_FILENAME, + buildPatchedComicInfoXml, + findComicInfoEntry, +} from "./index" + +const makeArchive = ( + files: Record, + options: { directories?: string[] } = {}, +): ArchiveSource => { + const directoryEntries: ArchiveEntry[] = (options.directories ?? []).map( + (path) => ({ + path, + isDir: true, + readAsString: () => Promise.reject(new Error("dir entry")), + readAsUint8Array: () => Promise.reject(new Error("dir entry")), + }), + ) + + const fileEntries: ArchiveEntry[] = Object.entries(files).map( + ([path, body]) => ({ + path, + isDir: false, + readAsString: () => Promise.resolve(body), + readAsUint8Array: () => Promise.resolve(new TextEncoder().encode(body)), + }), + ) + + return { + listEntries: () => Promise.resolve([...directoryEntries, ...fileEntries]), + } +} + +const minimalComicInfo = (body = "") => + `` + + `${body}` + +const readComicInfoIsbn = (xml: string): string | undefined => + resolveArchiveMetadata(parseComicInfo(xml)).isbn + +describe("ComicInfo detection (findComicInfoEntry)", () => { + it("finds ComicInfo.xml at the archive root", async () => { + const archive = makeArchive({ + "ComicInfo.xml": minimalComicInfo(), + "page-001.jpg": "binary", + }) + + const entry = await findComicInfoEntry(archive) + + expect(entry?.path).toBe("ComicInfo.xml") + }) + + it("matches the filename case-insensitively", async () => { + const archive = makeArchive({ "ComicInfo.XML": minimalComicInfo() }) + + const entry = await findComicInfoEntry(archive) + + expect(entry?.path).toBe("ComicInfo.XML") + }) + + it("ignores ComicInfo files nested inside sub-folders", async () => { + const archive = makeArchive({ + "meta/ComicInfo.xml": minimalComicInfo(), + "deep/nested/comicinfo.xml": minimalComicInfo(), + }) + + const entry = await findComicInfoEntry(archive) + + expect(entry).toBeUndefined() + }) + + it("returns undefined when there is no ComicInfo entry at all", async () => { + const archive = makeArchive({ + "page-001.jpg": "binary", + "page-002.jpg": "binary", + }) + + const entry = await findComicInfoEntry(archive) + + expect(entry).toBeUndefined() + }) + + it("skips a directory entry that happens to be named ComicInfo.xml", async () => { + const archive = makeArchive( + { "page-001.jpg": "binary" }, + { directories: ["ComicInfo.xml/"] }, + ) + + const entry = await findComicInfoEntry(archive) + + expect(entry).toBeUndefined() + }) + + it("returns the first matching root-level file when several casings co-exist", async () => { + const archive = makeArchive({ + "ComicInfo.xml": minimalComicInfo("first"), + "comicinfo.xml": minimalComicInfo("second"), + }) + + const entry = await findComicInfoEntry(archive) + + expect(entry?.path).toBe("ComicInfo.xml") + }) +}) + +describe("ComicInfo editing (buildPatchedComicInfoXml)", () => { + it("synthesises a minimal ComicInfo document when the archive has none", async () => { + const archive = makeArchive({ "page-001.jpg": "binary" }) + + const xml = await buildPatchedComicInfoXml(archive, { + isbn: "9783161484100", + }) + + expect(xml.startsWith("9783161484100") + expect(readComicInfoIsbn(xml)).toBe("9783161484100") + }) + + it("emits no GTIN element when synthesising with an undefined ISBN", async () => { + const archive = makeArchive({ "page-001.jpg": "binary" }) + + const xml = await buildPatchedComicInfoXml(archive, { isbn: undefined }) + + expect(xml).not.toContain(" { + const archive = makeArchive({ + "ComicInfo.xml": minimalComicInfo( + "Sample" + + "Sample Series" + + "1" + + "Alice", + ), + }) + + const xml = await buildPatchedComicInfoXml(archive, { + isbn: "9783161484100", + }) + + expect(xml).toContain("Sample") + expect(xml).toContain("Sample Series") + expect(xml).toContain("1") + expect(xml).toContain("Alice") + expect(xml).toContain("9783161484100") + }) + + it("replaces an existing GTIN value rather than appending a duplicate", async () => { + const archive = makeArchive({ + "ComicInfo.xml": minimalComicInfo( + "Sample0000000000", + ), + }) + + const xml = await buildPatchedComicInfoXml(archive, { + isbn: "9783161484100", + }) + + const matches = xml.match(//g) ?? [] + expect(matches).toHaveLength(1) + expect(xml).toContain("9783161484100") + expect(xml).not.toContain("0000000000") + }) + + it("removes the GTIN element when the patch clears it", async () => { + const archive = makeArchive({ + "ComicInfo.xml": minimalComicInfo( + "Sample9783161484100", + ), + }) + + const xml = await buildPatchedComicInfoXml(archive, { isbn: undefined }) + + expect(xml).not.toContain("Sample") + expect(readComicInfoIsbn(xml)).toBeUndefined() + }) + + it("locates the existing ComicInfo.xml regardless of its filename casing", async () => { + const archive = makeArchive({ + "comicinfo.xml": minimalComicInfo("Sample"), + }) + + const xml = await buildPatchedComicInfoXml(archive, { + isbn: "9783161484100", + }) + + expect(xml).toContain("Sample") + expect(xml).toContain("9783161484100") + }) + + it("overwrites a malformed existing ComicInfo with a freshly synthesised document", async () => { + const archive = makeArchive({ + "ComicInfo.xml": "oops", + }) + + const xml = await buildPatchedComicInfoXml(archive, { + isbn: "9783161484100", + }) + + expect(xml.startsWith("9783161484100") + expect(xml).not.toContain("oops") + expect(readComicInfoIsbn(xml)).toBe("9783161484100") + }) + + it("throws when the existing root element is not ", async () => { + const archive = makeArchive({ + "ComicInfo.xml": + '', + }) + + await expect( + buildPatchedComicInfoXml(archive, { isbn: "9783161484100" }), + ).rejects.toThrow(/root element is not /i) + }) + + it("emits an XML declaration even when the existing document has none", async () => { + const archive = makeArchive({ + "ComicInfo.xml": "Sample", + }) + + const xml = await buildPatchedComicInfoXml(archive, { + isbn: "9783161484100", + }) + + expect(xml.startsWith(" { + expect(COMIC_INFO_FILENAME).toBe("ComicInfo.xml") + }) +}) diff --git a/packages/archive-metadata/src/comicInfo/index.ts b/packages/archive-metadata/src/comicInfo/index.ts new file mode 100644 index 000000000..c5322d287 --- /dev/null +++ b/packages/archive-metadata/src/comicInfo/index.ts @@ -0,0 +1,99 @@ +import { COMIC_INFO_FILENAME as PROSE_COMIC_INFO_FILENAME } from "@prose-reader/archive-parser" +import { + type ArchiveEntry, + type ArchiveSource, + findEntry, +} from "../archive/types" +import { + type XmlDocument, + parseXml, + serializeXml, + upsertChildElement, +} from "../utils/dom" + +type ComicInfoMetadataPatch = { + isbn?: string | undefined +} + +export { PROSE_COMIC_INFO_FILENAME as COMIC_INFO_FILENAME } + +const COMIC_INFO_LABEL = "ComicInfo.xml" + +const COMIC_INFO_NAMESPACE_ATTRS = { + xmlns_xsi: "http://www.w3.org/2001/XMLSchema-instance", + xmlns_xsd: "http://www.w3.org/2001/XMLSchema", +} + +/** + * Locate a `ComicInfo.xml` entry regardless of casing. Matches only the + * top-level filename — nested `comicinfo.xml` files inside sub-folders + * are not part of the spec and silently ignored. Internal to the + * package; the unified reader and writer consume it. + */ +export const findComicInfoEntry = ( + source: ArchiveSource, +): Promise => + findEntry( + source, + (entry) => + entry.path.toLowerCase() === PROSE_COMIC_INFO_FILENAME.toLowerCase(), + ) + +/** + * Produce the new ComicInfo.xml body for a patched archive. Handles + * the "archive has no ComicInfo yet" case by synthesizing a minimal + * document, and the "archive has a malformed ComicInfo" case by + * synthesizing a fresh document too — the inspection step is the one + * responsible for warning the user that an unreadable ComicInfo.xml + * will be overwritten. + * + * Internal to the package; the public surface is `patchArchiveMetadata`, + * which dispatches to this based on archive shape. + */ +export const buildPatchedComicInfoXml = async ( + source: ArchiveSource, + patch: ComicInfoMetadataPatch, +): Promise => { + const entry = await findComicInfoEntry(source) + const existingXml = entry ? await entry.readAsString() : null + + return serializeComicInfoXml(existingXml, patch) +} + +const createFreshComicInfoDocument = (): XmlDocument => + parseXml( + ``, + COMIC_INFO_LABEL, + ) + +const tryParseExistingComicInfo = (xml: string): XmlDocument | undefined => { + try { + return parseXml(xml, COMIC_INFO_LABEL) + } catch { + return undefined + } +} + +const serializeComicInfoXml = ( + existingXml: string | undefined | null, + patch: ComicInfoMetadataPatch, +): string => { + const parsedExisting = existingXml + ? tryParseExistingComicInfo(existingXml) + : undefined + const doc = parsedExisting ?? createFreshComicInfoDocument() + + const root = doc.documentElement + + if (!root || root.tagName !== "ComicInfo") { + throw new Error("ComicInfo.xml root element is not ") + } + + upsertChildElement(doc, root, "GTIN", patch.isbn) + + const serialized = serializeXml(doc) + + return serialized.startsWith("\n${serialized}` +} diff --git a/packages/archive-metadata/src/index.ts b/packages/archive-metadata/src/index.ts new file mode 100644 index 000000000..f21b467d3 --- /dev/null +++ b/packages/archive-metadata/src/index.ts @@ -0,0 +1,15 @@ +export type { ArchiveEntry, ArchiveSource } from "./archive/types" +export { findEntry } from "./archive/types" + +export type { ArchiveMetadata, ReadArchiveMetadataEvents } from "./reader" +export { readArchiveMetadata } from "./reader" + +export type { + ArchiveMetadataPatch, + ArchiveMetadataTargets, + ArchivePatch, + ArchivePatchedEntry, +} from "./writer" +export { patchArchiveMetadata } from "./writer" + +export { normalizeIsbn } from "@prose-reader/archive-parser" diff --git a/packages/archive-metadata/src/opf/read.test.ts b/packages/archive-metadata/src/opf/read.test.ts new file mode 100644 index 000000000..6eed21d4e --- /dev/null +++ b/packages/archive-metadata/src/opf/read.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from "vitest" +import type { ArchiveEntry, ArchiveSource } from "../archive/types" +import { findOpfEntry } from "./read" + +const makeArchive = ( + files: Record, + options: { directories?: string[] } = {}, +): ArchiveSource => { + const directoryEntries: ArchiveEntry[] = (options.directories ?? []).map( + (path) => ({ + path, + isDir: true, + readAsString: () => Promise.reject(new Error("dir entry")), + readAsUint8Array: () => Promise.reject(new Error("dir entry")), + }), + ) + + const fileEntries: ArchiveEntry[] = Object.entries(files).map( + ([path, body]) => ({ + path, + isDir: false, + readAsString: () => Promise.resolve(body), + readAsUint8Array: () => Promise.resolve(new TextEncoder().encode(body)), + }), + ) + + return { + listEntries: () => Promise.resolve([...directoryEntries, ...fileEntries]), + } +} + +describe("OPF detection (findOpfEntry)", () => { + it("finds an OPF at the canonical OEBPS/content.opf path", async () => { + const archive = makeArchive({ + "META-INF/container.xml": "", + "OEBPS/content.opf": "", + "OEBPS/text/chapter1.xhtml": "", + }) + + const entry = await findOpfEntry(archive) + + expect(entry?.path).toBe("OEBPS/content.opf") + }) + + it("finds an OPF at the archive root", async () => { + const archive = makeArchive({ "package.opf": "" }) + + const entry = await findOpfEntry(archive) + + expect(entry?.path).toBe("package.opf") + }) + + it("matches the .opf extension case-insensitively", async () => { + const archive = makeArchive({ "OEBPS/Content.OPF": "" }) + + const entry = await findOpfEntry(archive) + + expect(entry?.path).toBe("OEBPS/Content.OPF") + }) + + it("returns undefined when the archive holds no .opf file", async () => { + const archive = makeArchive({ + "META-INF/container.xml": "", + "page-001.jpg": "binary", + }) + + const entry = await findOpfEntry(archive) + + expect(entry).toBeUndefined() + }) + + it("returns the first .opf entry when several are present", async () => { + const archive = makeArchive({ + "OEBPS/content.opf": "", + "extras/legacy.opf": "", + }) + + const entry = await findOpfEntry(archive) + + expect(entry?.path).toBe("OEBPS/content.opf") + }) +}) diff --git a/packages/archive-metadata/src/opf/read.ts b/packages/archive-metadata/src/opf/read.ts new file mode 100644 index 000000000..22703c60a --- /dev/null +++ b/packages/archive-metadata/src/opf/read.ts @@ -0,0 +1,15 @@ +import { + type ArchiveEntry, + type ArchiveSource, + findEntry, +} from "../archive/types" + +/** + * Locate the first `*.opf` file inside the archive. EPUBs put it behind + * the `META-INF/container.xml` manifest but in practice the filename + * extension is specific enough to pick the package document directly. + */ +export const findOpfEntry = ( + source: ArchiveSource, +): Promise => + findEntry(source, (entry) => entry.path.toLowerCase().endsWith(".opf")) diff --git a/packages/archive-metadata/src/opf/write.test.ts b/packages/archive-metadata/src/opf/write.test.ts new file mode 100644 index 000000000..d4665fd68 --- /dev/null +++ b/packages/archive-metadata/src/opf/write.test.ts @@ -0,0 +1,250 @@ +// @vitest-environment jsdom +import { describe, expect, it } from "vitest" +import { parseOpf, resolveArchiveMetadata } from "@prose-reader/archive-parser" +import type { ArchiveEntry } from "../archive/types" +import { buildPatchedOpfXml } from "./write" + +const opf = ( + metadata: string, + options: { manifest?: string; spine?: string } = {}, +): string => + '\n' + + '\n' + + ` ${metadata}\n` + + ` ${options.manifest ?? ""}\n` + + ` ${options.spine ?? ""}\n` + + "" + +const makeEntry = (path: string, body: string): ArchiveEntry => ({ + path, + isDir: false, + readAsString: () => Promise.resolve(body), + readAsUint8Array: () => Promise.resolve(new TextEncoder().encode(body)), +}) + +const readOpfMetadata = (xml: string) => resolveArchiveMetadata(parseOpf(xml)) + +describe("OPF editing (buildPatchedOpfXml)", () => { + it('inserts a new opf:scheme="ISBN" identifier when the metadata had none', async () => { + const entry = makeEntry( + "OEBPS/content.opf", + opf( + 'urn:uuid:A1B0D67E-2E81-4DF5-9E67-A64CBE366809' + + "Sample", + ), + ) + + const xml = await buildPatchedOpfXml(entry, { isbn: "9783161484100" }) + + expect(readOpfMetadata(xml).isbn).toBe("9783161484100") + expect(xml).toContain("urn:uuid:A1B0D67E-2E81-4DF5-9E67-A64CBE366809") + expect(xml).toContain("Sample") + }) + + it('updates the existing opf:scheme="ISBN" identifier in place', async () => { + const entry = makeEntry( + "OEBPS/content.opf", + opf( + 'urn:uuid:A1B0D67E-2E81-4DF5-9E67-A64CBE366809' + + '0000000000', + ), + ) + + const xml = await buildPatchedOpfXml(entry, { isbn: "9783161484100" }) + + expect(readOpfMetadata(xml).isbn).toBe("9783161484100") + expect(xml).not.toContain("0000000000") + }) + + it("matches the scheme attribute case-insensitively when updating", async () => { + const entry = makeEntry( + "OEBPS/content.opf", + opf('0000000000'), + ) + + const xml = await buildPatchedOpfXml(entry, { isbn: "9783161484100" }) + + expect(readOpfMetadata(xml).isbn).toBe("9783161484100") + expect(xml).not.toContain("0000000000") + }) + + it('matches a bare scheme="ISBN" attribute when updating', async () => { + const entry = makeEntry( + "OEBPS/content.opf", + opf('0000000000'), + ) + + const xml = await buildPatchedOpfXml(entry, { isbn: "9783161484100" }) + + expect(readOpfMetadata(xml).isbn).toBe("9783161484100") + expect(xml).not.toContain("0000000000") + }) + + it("never touches a UUID identifier carrying the unique-identifier id", async () => { + const entry = makeEntry( + "OEBPS/content.opf", + opf( + 'urn:uuid:A1B0D67E-2E81-4DF5-9E67-A64CBE366809', + ), + ) + + const xml = await buildPatchedOpfXml(entry, { isbn: "9783161484100" }) + + expect(xml).toContain("urn:uuid:A1B0D67E-2E81-4DF5-9E67-A64CBE366809") + expect(xml).toContain('id="pub-id"') + }) + + it("removes the ISBN identifier when the patch clears it with undefined", async () => { + const entry = makeEntry( + "OEBPS/content.opf", + opf( + 'urn:uuid:A1B0D67E-2E81-4DF5-9E67-A64CBE366809' + + '9783161484100', + ), + ) + + const xml = await buildPatchedOpfXml(entry, { isbn: undefined }) + + expect(readOpfMetadata(xml).isbn).toBeUndefined() + expect(xml).toContain("urn:uuid:A1B0D67E-2E81-4DF5-9E67-A64CBE366809") + }) + + it("removes the ISBN identifier when the patch clears it with an empty string", async () => { + const entry = makeEntry( + "OEBPS/content.opf", + opf('9783161484100'), + ) + + const xml = await buildPatchedOpfXml(entry, { isbn: "" }) + + expect(readOpfMetadata(xml).isbn).toBeUndefined() + }) + + it("does nothing when clearing an already-absent ISBN", async () => { + const entry = makeEntry( + "OEBPS/content.opf", + opf( + 'urn:uuid:A1B0D67E-2E81-4DF5-9E67-A64CBE366809', + ), + ) + + const xml = await buildPatchedOpfXml(entry, { isbn: undefined }) + + expect(readOpfMetadata(xml).isbn).toBeUndefined() + expect(xml).toContain("urn:uuid:A1B0D67E-2E81-4DF5-9E67-A64CBE366809") + }) + + it("preserves unrelated metadata fields when inserting an ISBN", async () => { + const entry = makeEntry( + "OEBPS/content.opf", + opf( + "Norwegian Wood" + + "Haruki Murakami" + + "Vintage" + + "en", + ), + ) + + const xml = await buildPatchedOpfXml(entry, { isbn: "9783161484100" }) + + expect(readOpfMetadata(xml)).toMatchObject({ + title: "Norwegian Wood", + authors: ["Haruki Murakami"], + publisher: "Vintage", + languages: ["en"], + isbn: "9783161484100", + }) + }) + + it("preserves manifest and spine when inserting an ISBN", async () => { + const entry = makeEntry( + "OEBPS/content.opf", + opf("Sample", { + manifest: + '' + + '', + spine: '', + }), + ) + + const xml = await buildPatchedOpfXml(entry, { isbn: "9783161484100" }) + + expect(readOpfMetadata(xml)).toMatchObject({ + isbn: "9783161484100", + }) + expect(xml).toContain( + ' { + const entry = makeEntry( + "OEBPS/content.opf", + '' + + "Sample" + + "" + + "" + + "", + ) + + const xml = await buildPatchedOpfXml(entry, { isbn: "9783161484100" }) + + expect(xml.startsWith("", async () => { + const entry = makeEntry( + "OEBPS/content.opf", + '', + ) + + await expect( + buildPatchedOpfXml(entry, { isbn: "9783161484100" }), + ).rejects.toThrow(/root element is not /i) + }) + + it("throws when the OPF document has no element", async () => { + const entry = makeEntry( + "OEBPS/content.opf", + '' + + '' + + "" + + "", + ) + + await expect( + buildPatchedOpfXml(entry, { isbn: "9783161484100" }), + ).rejects.toThrow(/has no element/i) + }) + + it("propagates a labelled parse error when the OPF is malformed", async () => { + const entry = makeEntry("OEBPS/content.opf", "") + + await expect( + buildPatchedOpfXml(entry, { isbn: "9783161484100" }), + ).rejects.toThrow(/OPF is malformed/i) + }) + + it("round-trips: the patched output parses back to the patched ISBN", async () => { + const entry = makeEntry( + "OEBPS/content.opf", + opf("Sample"), + ) + + const xml = await buildPatchedOpfXml(entry, { + isbn: "9783161484100", + }) + + expect(readOpfMetadata(xml).isbn).toBe("9783161484100") + }) +}) diff --git a/packages/archive-metadata/src/opf/write.ts b/packages/archive-metadata/src/opf/write.ts new file mode 100644 index 000000000..74e558783 --- /dev/null +++ b/packages/archive-metadata/src/opf/write.ts @@ -0,0 +1,120 @@ +import type { ArchiveEntry } from "../archive/types" +import { + type XmlDocument, + type XmlElement, + parseXml, + serializeXml, +} from "../utils/dom" + +/** + * Subset of OPF metadata fields the writer can update. Stays narrower + * than the resolved OPF metadata on purpose: every entry here implies + * a round-trip contract (read → mutate → re-read returns the same + * value) we need to keep working across EPUB producers. + * + * Today only `isbn` is writable. Adding a new field means deciding + * which OPF element it maps to *and* updating the ISBN-style + * "find existing or create" logic for that element. + */ +export type OpfMetadataPatch = { + isbn?: string | undefined +} + +const OPF_LABEL = "OPF" + +/** + * Apply a metadata patch to an existing OPF package document and + * return the serialized XML body the caller should write back. The + * archive ownership stays with the caller — same layering choice as + * {@link buildPatchedComicInfoXml}. + * + * Unlike ComicInfo, we do *not* synthesize an OPF when the archive + * has none: that would turn a CBZ into an EPUB, which is well outside + * the scope of "fix metadata in place". Callers should gate on + * {@link ArchiveMetadata.hasOpf} before requesting an OPF target. + */ +export const buildPatchedOpfXml = async ( + entry: ArchiveEntry, + patch: OpfMetadataPatch, +): Promise => { + const xml = await entry.readAsString() + + return serializeOpfXml(xml, patch) +} + +const serializeOpfXml = (xml: string, patch: OpfMetadataPatch): string => { + const doc = parseXml(xml, OPF_LABEL) + const root = doc.documentElement + + if (!root || root.tagName !== "package") { + throw new Error("OPF root element is not ") + } + + const metadata = root.getElementsByTagName("metadata").item(0) + + if (!metadata) { + throw new Error("OPF document has no element") + } + + upsertIsbnIdentifier(doc, metadata, patch.isbn) + + const serialized = serializeXml(doc) + + return serialized.startsWith("\n${serialized}` +} + +/** + * Locate the `` carrying an ISBN scheme, if any. Both + * `opf:scheme="ISBN"` and the bare `scheme="ISBN"` form are accepted, + * with case-insensitive comparison — same rules the read side uses + * when extracting the value, so writes target the same node reads + * pick up. + */ +const findIsbnIdentifier = (parent: XmlElement): XmlElement | undefined => { + const identifiers = parent.getElementsByTagName("dc:identifier") + + for (let i = 0; i < identifiers.length; i += 1) { + const node = identifiers.item(i) + + if (!node) continue + + const scheme = ( + node.getAttribute("opf:scheme") ?? + node.getAttribute("scheme") ?? + "" + ).toLowerCase() + + if (scheme === "isbn") return node + } + + return undefined +} + +// Untagged `` elements may be the publication UUID +// referenced by ``; only ISBN-tagged +// identifiers are touched. +const upsertIsbnIdentifier = ( + doc: XmlDocument, + metadata: XmlElement, + isbn: string | undefined, +): void => { + const existing = findIsbnIdentifier(metadata) + + if (isbn === undefined || isbn === "") { + if (existing) metadata.removeChild(existing) + + return + } + + if (existing) { + existing.textContent = isbn + return + } + + const next = doc.createElement("dc:identifier") + next.setAttribute("opf:scheme", "ISBN") + next.textContent = isbn + metadata.appendChild(next) +} diff --git a/packages/archive-metadata/src/reader.ts b/packages/archive-metadata/src/reader.ts new file mode 100644 index 000000000..943b3b9bf --- /dev/null +++ b/packages/archive-metadata/src/reader.ts @@ -0,0 +1,191 @@ +import { + parseComicInfo, + parseOpf, + resolveArchiveMetadata, + type ArchiveResolveResult, +} from "@prose-reader/archive-parser" +import type { ArchiveEntry, ArchiveSource } from "./archive/types" +import { findComicInfoEntry } from "./comicInfo" +import { findOpfEntry } from "./opf/read" + +/** + * Extensions this reader considers as "images" — both for picking the + * fallback cover when no OPF cover is declared, and for counting pages + * in comic archives. Covers every raster format real-world CBZ/CBR/EPUB + * producers actually use; anything downstream (e.g. the API's `sharp` + * pipeline) can normalize these to a delivery format of its own. + * + * Kept internal on purpose: "what is an image inside a book archive?" + * is a concern that belongs in this package, not something each caller + * should redefine and risk drifting on. + */ +const IMAGE_EXTENSIONS: ReadonlySet = new Set([ + ".jpg", + ".jpeg", + ".png", + ".gif", + ".webp", + ".avif", + ".bmp", + ".tif", + ".tiff", +]) + +const getExtension = (path: string): string => { + const lastDot = path.lastIndexOf(".") + const lastSlash = path.lastIndexOf("/") + + if (lastDot === -1 || lastDot < lastSlash) return "" + + return path.substring(lastDot).toLowerCase() +} + +const isImageEntry = (entry: ArchiveEntry): boolean => + IMAGE_EXTENSIONS.has(getExtension(entry.path)) + +/** + * Source-agnostic view of every metadata container we can extract from + * a single archive. Container-specific metadata is kept separate so + * callers choose their own precedence per field. + * + * `hasOpf`/`hasComicInfo` describe *what metadata containers the + * archive actually carries* — not what kind of archive it is. A CBZ + * can carry ComicInfo, an EPUB can carry ComicInfo alongside its OPF, + * a raw image dump can carry neither. Consumers decide for themselves + * what combinations they're willing to act on; "is this archive + * recognized?" is simply `hasOpf || hasComicInfo`. + */ +export type ArchiveMetadata = { + /** `true` when the archive exposed an OPF package document. */ + hasOpf: boolean + /** `true` when the archive exposed a ComicInfo.xml at the root. */ + hasComicInfo: boolean + opf?: ArchiveResolveResult | undefined + comicInfo?: ArchiveResolveResult | undefined + /** + * Archive-relative path to the cover. For EPUBs this is the OPF + * cover with its folder prefix resolved; for other archives it's + * the first image entry in alphabetic order. `undefined` when the + * archive has no recognizable cover asset. + */ + coverHref?: string | undefined + /** + * Best-effort page count: + * - EPUBs (has OPF): undefined; reader-position counts are a client + * concern. + * - Other archives (CBZ/CBR/loose image archives): number of image + * entries in the archive. + * + * `undefined` when neither signal is available (e.g. a non-EPUB archive + * with no image entries). + */ + pageCount?: number | undefined +} + +export type ReadArchiveMetadataEvents = { + onOpfRead?: (event: { path: string; xml: string }) => void + onComicInfoRead?: (event: { path: string; xml: string }) => void +} + +export const readArchiveMetadata = async ( + source: ArchiveSource, + events?: ReadArchiveMetadataEvents, +): Promise => { + const entries = await source.listEntries() + const fileEntries = entries.filter((entry) => !entry.isDir) + + const opfEntry = await findOpfEntry({ + listEntries: () => Promise.resolve(entries), + }) + const comicInfoEntry = await findComicInfoEntry({ + listEntries: () => Promise.resolve(entries), + }) + + const opfResult = opfEntry ? await loadOpf(opfEntry, events) : undefined + const comicInfoResult = comicInfoEntry + ? await loadComicInfo(comicInfoEntry, events) + : undefined + + const imageEntries = fileEntries.filter(isImageEntry) + + const coverHref = + resolveOpfCover(opfResult) ?? resolveFallbackCover(imageEntries) + const pageCount = resolvePageCount({ + hasOpf: opfResult !== undefined, + imageEntryCount: imageEntries.length, + }) + + return { + hasOpf: opfResult !== undefined, + hasComicInfo: comicInfoResult !== undefined, + opf: opfResult?.metadata, + comicInfo: comicInfoResult, + coverHref, + pageCount, + } +} + +/** + * Decide which signal represents the "page count" for this archive. + * EPUB reading-position counts are left to clients that need them; for + * anything else (comics, loose image archives) we fall back to the + * image-entry count, which is what comic readers use as a page number. + */ +const resolvePageCount = ({ + hasOpf, + imageEntryCount, +}: { + hasOpf: boolean + imageEntryCount: number +}): number | undefined => { + if (hasOpf) return undefined + + return imageEntryCount > 0 ? imageEntryCount : undefined +} + +const loadOpf = async ( + entry: ArchiveEntry, + events: ReadArchiveMetadataEvents | undefined, +) => { + const xml = await entry.readAsString() + + events?.onOpfRead?.({ path: entry.path, xml }) + + const parsed = parseOpf(xml) + const metadata = resolveArchiveMetadata(parsed) + const lastSlash = entry.path.lastIndexOf("/") + const basePath = lastSlash === -1 ? "" : entry.path.substring(0, lastSlash) + + return { metadata, basePath, coverHref: parsed.coverHref } +} + +const loadComicInfo = async ( + entry: ArchiveEntry, + events: ReadArchiveMetadataEvents | undefined, +) => { + const xml = await entry.readAsString() + + events?.onComicInfoRead?.({ path: entry.path, xml }) + + return resolveArchiveMetadata(parseComicInfo(xml)) +} + +const resolveOpfCover = ( + opf: { basePath: string; coverHref: string | undefined } | undefined, +): string | undefined => { + if (!opf?.coverHref) return undefined + + return opf.basePath !== "" + ? `${opf.basePath}/${opf.coverHref}` + : opf.coverHref +} + +const resolveFallbackCover = ( + imageEntries: ArchiveEntry[], +): string | undefined => { + const images = imageEntries + .map((entry) => entry.path) + .sort((a, b) => a.localeCompare(b)) + + return images[0] +} diff --git a/packages/archive-metadata/src/utils/dom.ts b/packages/archive-metadata/src/utils/dom.ts new file mode 100644 index 000000000..f56d23cba --- /dev/null +++ b/packages/archive-metadata/src/utils/dom.ts @@ -0,0 +1,64 @@ +export type XmlDocument = Document +export type XmlElement = Element + +export const parseXml = (xml: string, label: string): XmlDocument => { + const Parser = globalThis.DOMParser + + if (!Parser) { + throw new Error("XML writing requires a DOMParser-compatible web runtime.") + } + + const doc = new Parser().parseFromString(xml, "application/xml") + const parserError = doc.getElementsByTagName("parsererror").item(0) + + if (parserError) { + const message = parserError.textContent?.trim() + + throw new Error( + message ? `${label} is malformed: ${message}` : `${label} is malformed`, + ) + } + + return doc +} + +export const serializeXml = (doc: XmlDocument | XmlElement): string => { + const Serializer = globalThis.XMLSerializer + + if (!Serializer) { + throw new Error( + "XML writing requires an XMLSerializer-compatible web runtime.", + ) + } + + return new Serializer().serializeToString(doc) +} + +/** + * Ensures a single child element with the given tag exists and carries + * `value` as its text. `undefined`/empty removes the child entirely, so + * writers can represent "clear this field" without a separate API. + */ +export const upsertChildElement = ( + doc: XmlDocument, + parent: XmlElement, + tagName: string, + value: string | undefined, +): void => { + const existing = parent.getElementsByTagName(tagName)[0] + + if (value === undefined || value === "") { + if (existing) parent.removeChild(existing) + + return + } + + if (existing) { + existing.textContent = value + return + } + + const next = doc.createElement(tagName) + next.textContent = value + parent.appendChild(next) +} diff --git a/packages/archive-metadata/src/writer.ts b/packages/archive-metadata/src/writer.ts new file mode 100644 index 000000000..326b2f8ea --- /dev/null +++ b/packages/archive-metadata/src/writer.ts @@ -0,0 +1,105 @@ +import type { ArchiveSource } from "./archive/types" +import { COMIC_INFO_FILENAME, buildPatchedComicInfoXml } from "./comicInfo" +import { findOpfEntry } from "./opf/read" +import { buildPatchedOpfXml } from "./opf/write" + +/** + * Fields an archive patch may set. Mirrors the writable subset of + * {@link ArchiveMetadata} — expand in lockstep when a new field + * becomes writable in at least one container. + * + * Today only `isbn` is writable. + */ +export type ArchiveMetadataPatch = { + isbn?: string | undefined +} + +/** + * Output containers the caller wants the patch written into. Set a + * flag to `true` to opt that container in. The package never picks + * targets on its own: deciding *where* metadata should live for a + * given archive is product policy (and may diverge per reader app), + * so it stays with the consumer. + * + * Consumers may call the writer once per container when each target + * needs different values. + */ +export type ArchiveMetadataTargets = { + comicInfo?: boolean + opf?: boolean +} + +/** + * Describes one entry the caller should write back into the archive + * to realize the patch. Each entry's `path` is archive-relative and + * uses forward slashes; `xml` is a UTF-8 string ready to be stored. + * + * We emit a list — rather than a single path + body — so multi-target + * patches (e.g. ComicInfo + OPF for hybrid archives) drop into the + * same shape as single-target ones. + */ +export type ArchivePatchedEntry = { + path: string + xml: string +} + +export type ArchivePatch = { + entries: ArchivePatchedEntry[] +} + +/** + * Apply a metadata patch to an archive and return the XML bodies that + * the caller should write back. The `targets` argument is required: + * the package no longer picks where to write based on what's in the + * archive — that decision belongs to the consumer. + * + * Per-target rules: + * - `targets.comicInfo` → patch the existing `ComicInfo.xml` if any, + * otherwise synthesize a minimal one. This always succeeds and is + * the safe default for archives that carry no embedded metadata. + * - `targets.opf` → patch the existing OPF package document. Throws + * when the archive has no OPF: synthesizing one would turn a CBZ + * into an EPUB, which is well outside this writer's scope. + * + * Calling with no targets selected is a programming error — the + * caller's policy should always pick at least one container. + * + * The caller keeps ownership of how the archive is repacked — each + * runtime plugs in its own write-capable zip library (JSZip on the + * web; something else on the server when that lands). Keeping the + * writer at the XML layer is the same layering choice that + * {@link readArchiveMetadata} makes for reads. + */ +export const patchArchiveMetadata = async ( + source: ArchiveSource, + patch: ArchiveMetadataPatch, + targets: ArchiveMetadataTargets, +): Promise => { + if (!targets.comicInfo && !targets.opf) { + throw new Error( + "patchArchiveMetadata requires at least one target (comicInfo or opf).", + ) + } + + const entries: ArchivePatchedEntry[] = [] + + if (targets.comicInfo) { + const xml = await buildPatchedComicInfoXml(source, { isbn: patch.isbn }) + entries.push({ path: COMIC_INFO_FILENAME, xml }) + } + + if (targets.opf) { + const opfEntry = await findOpfEntry(source) + + if (!opfEntry) { + throw new Error( + "Cannot write OPF metadata: archive does not carry an OPF package document.", + ) + } + + const xml = await buildPatchedOpfXml(opfEntry, { isbn: patch.isbn }) + entries.push({ path: opfEntry.path, xml }) + } + + return { entries } +} diff --git a/packages/archive-metadata/tsconfig.json b/packages/archive-metadata/tsconfig.json new file mode 100644 index 000000000..46a466e49 --- /dev/null +++ b/packages/archive-metadata/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../config/tsconfig.lib.json", + "include": ["src"] +} diff --git a/packages/archive-metadata/vite.config.ts b/packages/archive-metadata/vite.config.ts new file mode 100644 index 000000000..40951d114 --- /dev/null +++ b/packages/archive-metadata/vite.config.ts @@ -0,0 +1,3 @@ +import { definePackageLibConfig } from "../../config/vite.lib" + +export default definePackageLibConfig("oboku-archive-metadata") diff --git a/packages/shared/src/api-types/providers.ts b/packages/shared/src/api-types/providers.ts index d4f29856c..5c6efac0f 100644 --- a/packages/shared/src/api-types/providers.ts +++ b/packages/shared/src/api-types/providers.ts @@ -7,6 +7,12 @@ type ProviderRequest = { export type RefreshBookMetadataRequest = ProviderRequest & { bookId: string + /** + * Hard refresh: bypass every reuse cache (file metadata, + * cover blob) so the file is re-downloaded (when allowed) + * and the cover regenerated even if nothing changed. + */ + force?: boolean } export type RefreshBookMetadataResponse = Record diff --git a/packages/shared/src/errors.ts b/packages/shared/src/errors.ts index eebe6f0b7..96f4bcd01 100644 --- a/packages/shared/src/errors.ts +++ b/packages/shared/src/errors.ts @@ -16,7 +16,6 @@ export enum ObokuErrorCode { ERROR_RESOURCE_NOT_FOUND = "5000", ERROR_LINK_INVALID = "6000", ERROR_NO_LINK = "6001", - ERROR_RESOURCE_NOT_REACHABLE = "6002", ERROR_CONNECTOR_NOT_CONFIGURED = "7000", } @@ -40,7 +39,6 @@ const errorCodeSeverity: Record = { [ObokuErrorCode.ERROR_RESOURCE_NOT_FOUND]: "user", [ObokuErrorCode.ERROR_LINK_INVALID]: "user", [ObokuErrorCode.ERROR_NO_LINK]: "user", - [ObokuErrorCode.ERROR_RESOURCE_NOT_REACHABLE]: "user", [ObokuErrorCode.ERROR_CONNECTOR_NOT_CONFIGURED]: "user", } diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index a16ffcfb7..2ea455c03 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -16,41 +16,6 @@ export const links = { reddit: `https://www.reddit.com/r/oboku/`, } -export type OPF = { - package?: { - manifest?: { - item?: { - id?: string - href?: string - "media-type"?: string - }[] - } - metadata?: { - "dc:title"?: - | string - | { - "#text": string - } - title?: any - "dc:date"?: any - "dc:creator"?: { "#text"?: string } | { "#text"?: string }[] - "dc:subject"?: any - "dc:language"?: any - "dc:publisher"?: { "#text": string; id: string } | string - "dc:rights"?: any - meta?: - | { - name?: "cover" | "unknown" - content?: string - } - | { - name?: "cover" | "unknown" - content?: string - }[] - } - } -} - export * as directives from "./directives" export * from "./db/docTypes" diff --git a/packages/shared/src/metadata/index.ts b/packages/shared/src/metadata/index.ts index 63a5d99e5..e7cf2bb45 100644 --- a/packages/shared/src/metadata/index.ts +++ b/packages/shared/src/metadata/index.ts @@ -73,8 +73,9 @@ export type GoogleBookApiMetadata = BookMetadataVariant< /** * Metadata extracted from the file's contents (EPUB OPF or RAR/ZIP scan). - * No descriptions, ratings, format types, or remote identifiers — those - * are not embedded in the file itself. + * No descriptions, ratings, format types, or remote identifiers (other + * than `isbn`, which EPUB's `dc:identifier` and CBZ's ComicInfo `` + * both embed in the file itself). */ export type FileMetadata = BookMetadataVariant< "file", @@ -88,6 +89,7 @@ export type FileMetadata = BookMetadataVariant< | "coverLink" | "pageCount" | "contentType" + | "isbn" > /** @@ -104,7 +106,7 @@ export type FileMetadata = BookMetadataVariant< export type LinkMetadata = BookMetadataVariant< "link", "title" | "contentType" | "size" | "modifiedAt" -> +> & { title?: string } /** * Metadata supplied directly by the end user. Currently only ISBN is @@ -233,6 +235,7 @@ export const BOOK_METADATA_FIELDS_BY_SOURCE = { "coverLink", "pageCount", "contentType", + "isbn", ], link: ["title", "contentType", "size", "modifiedAt"], user: ["isbn"],