mirror of
https://github.com/lifegpc/bookdownload.git
synced 2026-06-09 07:08:52 +08:00
Add support to load chapter lists from databases
This commit is contained in:
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user