Add support to load chapter lists from databases

This commit is contained in:
2026-02-18 10:27:27 +08:00
parent e160b1da53
commit a2dbabc5ae
7 changed files with 204 additions and 11 deletions

View File

@@ -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<T>(db: IDBDatabase, storeName: string, key?: IDBValidKe
});
}
async function get_data_with_convert<T, U>(db: IDBDatabase, storeName: string, convert: (key: IDBValidKey, data: T, list: U[]) => Promise<void> | void , key?: IDBValidKey | IDBKeyRange, index?: string): Promise<U[]> {
return new Promise<U[]>((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<K extends IDBValidKey = IDBValidKey>(db: IDBDatabase, storeName: string, query?: IDBValidKey | IDBKeyRange, options?: GetAllOptions): Promise<K[]> {
return new Promise<K[]>((resolve, reject) => {
const tx = db.transaction(storeName, 'readonly');
@@ -145,7 +176,7 @@ async function get_paged_data<T>(db: IDBDatabase, storeName: string, page: numbe
}
type CompressedQdChapterInfo = {
compressed: Uint8Array;
compressed: Uint8Array<ArrayBuffer>;
bookId: number;
id: number;
time: number;
@@ -227,6 +258,37 @@ export class IndexedDb implements Db {
async getQdBook(id: number): Promise<QdBookInfo | undefined> {
return await get_data(this.qddb, 'books', id);
}
async getChapterSimpleInfos(bookId: number): Promise<QdChapterSimpleInfo[]> {
// chapterId-> [index, time]
const currents: Map<number, [number, number]> = new Map();
return await get_data_with_convert<QdChapterInfo | CompressedQdChapterInfo, QdChapterSimpleInfo>(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();
}

View File

@@ -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<void>;
@@ -17,6 +17,12 @@ export interface Db {
saveQdBook(info: QdBookInfo): Promise<void>;
getQdBook(id: number): Promise<QdBookInfo | undefined>;
getQdBooks(page: number, pageSize: number): Promise<PagedData<QdBookInfo>>;
/**
* 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<QdChapterSimpleInfo[]>;
close(): void;
}

View File

@@ -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<QdChapterSimpleInfo[]> {
// chapterId -> [index, time]
const currents: Map<number, [number, number]> = 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();

View File

@@ -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<string | null>(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 (
<div className={styles.c}>
<Flex justify="center" align="center">
@@ -41,7 +65,9 @@ export default function BookIndex() {
<Switch checked={bookStatus.showSavedOnly} onChange={setShowSavedOnly} checkedChildren={"仅显示已保存章节"} unCheckedChildren={"显示所有章节"} />
</Flex>
</Affix>
{!bookStatus.showSavedOnly && <VolumesList bookId={bookInfo.id} volumes={bookInfo.volumes} />}
{bookStatus.showSavedOnly && err && <Result status="error" title="加载章节列表失败" subTitle={err} extra={<Button type="primary" onClick={handle}></Button>} />}
{bookStatus.showSavedOnly && !bookStatus.chapterLists && !err && <Skeleton active />}
{vols.length > 0 && <VolumesList bookId={bookInfo.id} volumes={vols} />}
</div>
);
}

View File

@@ -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<BookStatus>]>(null as any);
export async function loadChapterLists(bookId: number, setBookStatus: Dispatch<SetStateAction<BookStatus>>, db: Db) {
const list = await db.getChapterSimpleInfos(bookId);
setBookStatus((status) => ({ ...status, chapterLists: list }));
}
export async function loadChapterListsIfNeeded(bookId: number, bookStatus: BookStatus, setBookStatus: Dispatch<SetStateAction<BookStatus>>, db: Db) {
if (!bookStatus.chapterLists) {
await loadChapterLists(bookId, setBookStatus, db);
}
}
export const BookStatusContext = createContext<[BookStatus, Dispatch<SetStateAction<BookStatus>>]>(null as any);
export function useBookStatus() {
return useContext(BookStatusContext);

View File

@@ -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;

View File

@@ -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<number, string> = 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;
}