From a2dbabc5ae9d2f07021101fd5ab8350da6965e02 Mon Sep 17 00:00:00 2001 From: lifegpc Date: Wed, 18 Feb 2026 10:27:27 +0800 Subject: [PATCH] Add support to load chapter lists from databases --- src/db/indexedDb.ts | 68 +++++++++++++++++++++++++++-- src/db/interfaces.ts | 8 +++- src/db/pocketBase.ts | 36 ++++++++++++++- src/manage/qd/BookIndex.tsx | 32 ++++++++++++-- src/manage/qd/BookStatusProvider.ts | 18 +++++++- src/types.ts | 7 +++ src/utils/qd.ts | 46 ++++++++++++++++++- 7 files changed, 204 insertions(+), 11 deletions(-) diff --git a/src/db/indexedDb.ts b/src/db/indexedDb.ts index c9e8b9d..b2c6593 100644 --- a/src/db/indexedDb.ts +++ b/src/db/indexedDb.ts @@ -1,6 +1,6 @@ import { IndexedDbConfig } from "../config"; -import type { QdChapterInfo, QdBookInfo, PagedData } from "../types"; -import { compress, isServiceWorker } from "../utils"; +import type { QdChapterInfo, QdBookInfo, PagedData, QdChapterSimpleInfo } from "../types"; +import { compress, decompress, isServiceWorker } from "../utils"; import { hash_qdchapter_info } from "../utils/qd"; import type { Db } from "./interfaces"; @@ -61,6 +61,37 @@ async function get_datas(db: IDBDatabase, storeName: string, key?: IDBValidKe }); } +async function get_data_with_convert(db: IDBDatabase, storeName: string, convert: (key: IDBValidKey, data: T, list: U[]) => Promise | void , key?: IDBValidKey | IDBKeyRange, index?: string): Promise { + return new Promise((resolve, reject) => { + const list: U[] = []; + const tx = db.transaction(storeName, 'readonly'); + const store = tx.objectStore(storeName); + const req = index ? store.index(index).openCursor(key) : store.openCursor(key); + req.onsuccess = () => { + const cursor = req.result; + if (cursor) { + try { + const res = convert(cursor.primaryKey, cursor.value, list); + if (res instanceof Promise) { + res.then(() => { + cursor.continue(); + }).catch(err => { + reject(err); + }); + return; + } + } catch (err) { + reject(err); + return; + } + cursor.continue(); + } else { + resolve(list); + } + } + }); +} + async function get_keys(db: IDBDatabase, storeName: string, query?: IDBValidKey | IDBKeyRange, options?: GetAllOptions): Promise { return new Promise((resolve, reject) => { const tx = db.transaction(storeName, 'readonly'); @@ -145,7 +176,7 @@ async function get_paged_data(db: IDBDatabase, storeName: string, page: numbe } type CompressedQdChapterInfo = { - compressed: Uint8Array; + compressed: Uint8Array; bookId: number; id: number; time: number; @@ -227,6 +258,37 @@ export class IndexedDb implements Db { async getQdBook(id: number): Promise { return await get_data(this.qddb, 'books', id); } + async getChapterSimpleInfos(bookId: number): Promise { + // chapterId-> [index, time] + const currents: Map = new Map(); + return await get_data_with_convert(this.qddb, 'chapters', async (key, data, list) => { + if ('compressed' in data) { + const decompressed = await decompress(data.compressed); + const decoded = new TextDecoder().decode(decompressed); + data = JSON.parse(decoded) as QdChapterInfo; + } + const value: [number, number] = [list.length, data.time]; + const oldValue = currents.get(data.id); + if (oldValue && value[1] > oldValue[1]) { + value[0] = oldValue[0]; + currents.set(data.id, value); + list[oldValue[0]] = { + primaryKey: key, + id: data.id, + name: data.chapterInfo.chapterName, + bookId: data.bookId, + }; + } else if (!oldValue) { + currents.set(data.id, value); + list.push({ + primaryKey: key, + id: data.id, + name: data.chapterInfo.chapterName, + bookId: data.bookId, + }); + } + }, bookId, 'bookId'); + } close() { this.qddb.close(); } diff --git a/src/db/interfaces.ts b/src/db/interfaces.ts index e21ecd5..f8e9cea 100644 --- a/src/db/interfaces.ts +++ b/src/db/interfaces.ts @@ -1,7 +1,7 @@ import { DbConfig, DbType } from "../config"; import { IndexedDb } from "./indexedDb"; import { PocketBaseDb } from "./pocketBase"; -import type { QdChapterInfo, QdBookInfo, PagedData } from "../types"; +import type { QdChapterInfo, QdBookInfo, PagedData, QdChapterSimpleInfo } from "../types"; export interface Db { init(): Promise; @@ -17,6 +17,12 @@ export interface Db { saveQdBook(info: QdBookInfo): Promise; getQdBook(id: number): Promise; getQdBooks(page: number, pageSize: number): Promise>; + /** + * Retrieve chapter list of a book. if bookId is not found, return empty array. + * Primary key should be the latest (time is biggest) saved chapter. + * @param bookId Book ID + */ + getChapterSimpleInfos(bookId: number): Promise; close(): void; } diff --git a/src/db/pocketBase.ts b/src/db/pocketBase.ts index 2619ec8..fc7b6df 100644 --- a/src/db/pocketBase.ts +++ b/src/db/pocketBase.ts @@ -2,7 +2,7 @@ import type { Db } from "./interfaces"; import PocketBase from "pocketbase"; import type { CollectionModel } from "pocketbase"; import { PocketBaseConfig } from "../config"; -import { QdChapterInfo, QdBookInfo, PagedData } from "../types"; +import { QdChapterInfo, QdBookInfo, PagedData, QdChapterSimpleInfo } from "../types"; import { hash_qdchapter_info } from "../utils/qd"; import { loadConfig, saveConfig } from "../utils"; @@ -218,6 +218,40 @@ export class PocketBaseDb implements Db { }); return records.totalItems > 0 ? records.items[0].data : undefined; } + async getChapterSimpleInfos(bookId: number): Promise { + // chapterId -> [index, time] + const currents: Map = new Map(); + const list = await this.client.collection(`${this.cfg.prefix}qd_chapters`).getFullList({ + filter: `bookId = ${bookId}`, + fields: 'id,chapterId,bookId,time,data.chapterInfo.chapterName', + }); + const re: QdChapterSimpleInfo[] = []; + for (const item of list) { + const data: QdChapterInfo = item.data; + const key: number = item.chapterId; + const value: [number, number] = [re.length, item.time]; + const oldValue = currents.get(key); + if (oldValue && value[1] > oldValue[1]) { + value[0] = oldValue[0]; + currents.set(key, value); + re[oldValue[0]] = { + primaryKey: item.id, + id: item.chapterId, + name: data.chapterInfo.chapterName, + bookId: item.bookId, + }; + } else if (!oldValue) { + currents.set(key, value); + re.push({ + primaryKey: item.id, + id: item.chapterId, + name: data.chapterInfo.chapterName, + bookId: item.bookId, + }); + } + } + return re; + } close(): void { this.client.cancelAllRequests(); this.client.authStore.clear(); diff --git a/src/manage/qd/BookIndex.tsx b/src/manage/qd/BookIndex.tsx index 284adca..0cff654 100644 --- a/src/manage/qd/BookIndex.tsx +++ b/src/manage/qd/BookIndex.tsx @@ -1,8 +1,12 @@ -import { Affix, Flex, Space, Tag, Typography, Switch } from "antd"; +import { Affix, Flex, Space, Tag, Typography, Switch, Skeleton, Result, Button } from "antd"; import { useBookInfo } from "./BookInfoProvider"; import styles from './BookIndex.module.css'; -import { useBookStatus } from "./BookStatusProvider"; +import { loadChapterListsIfNeeded, useBookStatus } from "./BookStatusProvider"; import VolumesList from "./VolumesList"; +import { useEffect, useState } from "react"; +import { useDb } from "../dbProvider"; +import type { Volume } from "../../qdtypes"; +import { get_new_volumes } from "../../utils/qd"; const { Paragraph, Link } = Typography; @@ -10,10 +14,30 @@ const QD_BOOK_TAG_COLOR = ['blue', 'cyan', 'orange']; export default function BookIndex() { const bookInfo = useBookInfo(); + const db = useDb(); const [bookStatus, setBookStatus] = useBookStatus(); + const [err, setErr] = useState(null); function setShowSavedOnly(showSavedOnly: boolean) { setBookStatus({ ...bookStatus, showSavedOnly }); } + function handle() { + if (err) { + setErr(null); + } + loadChapterListsIfNeeded(bookInfo.id, bookStatus, setBookStatus, db).catch(e => { + console.log(e); + setErr(e instanceof Error ? e.message : String(e)); + }); + } + useEffect(() => { + handle(); + }, [bookInfo.id]); + let vols: Volume[] = bookInfo.volumes; + if (bookStatus.chapterLists) { + vols = get_new_volumes(bookStatus.chapterLists, bookInfo.volumes, !bookStatus.showSavedOnly); + } else if (bookStatus.showSavedOnly) { + vols = []; + } return (
@@ -41,7 +65,9 @@ export default function BookIndex() { - {!bookStatus.showSavedOnly && } + {bookStatus.showSavedOnly && err && 重试} />} + {bookStatus.showSavedOnly && !bookStatus.chapterLists && !err && } + {vols.length > 0 && }
); } diff --git a/src/manage/qd/BookStatusProvider.ts b/src/manage/qd/BookStatusProvider.ts index c8c89a6..3a99435 100644 --- a/src/manage/qd/BookStatusProvider.ts +++ b/src/manage/qd/BookStatusProvider.ts @@ -1,7 +1,10 @@ -import { createContext, useContext, Dispatch } from "react"; +import { createContext, useContext, Dispatch, SetStateAction } from "react"; +import { QdChapterSimpleInfo } from "../../types"; +import { Db } from "../../db/interfaces"; export type BookStatus = { showSavedOnly: boolean; + chapterLists?: QdChapterSimpleInfo[]; } export function createBookStatus(): BookStatus { @@ -10,7 +13,18 @@ export function createBookStatus(): BookStatus { } } -export const BookStatusContext = createContext<[BookStatus, Dispatch]>(null as any); +export async function loadChapterLists(bookId: number, setBookStatus: Dispatch>, db: Db) { + const list = await db.getChapterSimpleInfos(bookId); + setBookStatus((status) => ({ ...status, chapterLists: list })); +} + +export async function loadChapterListsIfNeeded(bookId: number, bookStatus: BookStatus, setBookStatus: Dispatch>, db: Db) { + if (!bookStatus.chapterLists) { + await loadChapterLists(bookId, setBookStatus, db); + } +} + +export const BookStatusContext = createContext<[BookStatus, Dispatch>]>(null as any); export function useBookStatus() { return useContext(BookStatusContext); diff --git a/src/types.ts b/src/types.ts index d4d2f1e..d2ac0c2 100644 --- a/src/types.ts +++ b/src/types.ts @@ -22,6 +22,13 @@ export type QdChapterInfo = { hash?: string; } +export type QdChapterSimpleInfo = { + primaryKey: unknown; + id: number; + name: string; + bookId: number; +} + export type QdBookInfo = { bookInfo: QdTypes.BookGData; bookName: string; diff --git a/src/utils/qd.ts b/src/utils/qd.ts index 318706e..0f98b4c 100644 --- a/src/utils/qd.ts +++ b/src/utils/qd.ts @@ -1,6 +1,7 @@ import { SHA256 } from "@stablelib/sha256"; -import type { QdChapterInfo } from "../types"; +import type { QdChapterInfo, QdChapterSimpleInfo } from "../types"; import { get_chapter_content, toHex } from "../utils"; +import type { Chapter, Volume } from "../qdtypes"; export function hash_qdchapter_info(info: QdChapterInfo): string { const encoder = new TextEncoder(); @@ -27,3 +28,46 @@ export function hash_qdchapter_info(info: QdChapterInfo): string { const hash = h.digest(); return toHex(hash); } + +export function get_new_volumes(chapterLists: QdChapterSimpleInfo[], volumes: Volume[], keep=true): Volume[] { + const vols: Volume[] = []; + if (keep) { + const volMap: Map = new Map(); + for (const vol of volumes) { + const id = vol.id; + for (const ch of vol.chapters) { + volMap.set(ch.id, vol.name); + } + vols.push(vol); + } + const volCh: Chapter[] = []; + for (const ch of chapterLists) { + if (!volMap.has(ch.id)) { + volCh.push({ + id: ch.id, + name: ch.name, + }); + } + } + if (volCh.length > 0) { + vols.unshift({ + id: 'vol_new', + name: '其他已保存章节', + chapters: volCh, + isVip: false, + }); + } + } else { + const chIds = new Set(chapterLists.map(ch => ch.id)); + for (const vol of volumes) { + const newChs = vol.chapters.filter(ch => chIds.has(ch.id)); + if (newChs.length > 0) { + vols.push({ + ...vol, + chapters: newChs, + }); + } + } + } + return vols; +}