Add support to save chapter to databases

This commit is contained in:
2026-02-14 17:42:42 +08:00
parent d1a5b4aa3c
commit e510467197
9 changed files with 199 additions and 11 deletions

115
src/db/indexedDb.ts Normal file
View File

@@ -0,0 +1,115 @@
import { IndexedDbConfig } from "../config";
import type { QdChapterInfo } from "../types";
import { compress } from "../utils";
async function make_storage_persist() {
const persisted = await navigator.storage.persisted();
if (!persisted) {
await navigator.storage.persist();
}
}
async function save_data<T>(db: IDBDatabase, storeName: string, data: T, key?: IDBValidKey) {
return new Promise<IDBValidKey>((resolve, reject) => {
const tx = db.transaction(storeName, 'readwrite');
const store = tx.objectStore(storeName);
const req = key !== undefined ? store.put(data, key) : store.put(data);
req.onsuccess = () => {
resolve(req.result);
}
req.onerror = () => {
reject(req.error);
}
});
}
type CompressedQdChapterInfo = {
compressed: Uint8Array;
bookId: number;
id: number;
time: number;
}
export class IndexedDb {
compress: boolean;
_qddb?: IDBDatabase;
constructor(cfg: IndexedDbConfig) {
this.compress = cfg.compress;
}
get qddb() {
if (!this._qddb) {
throw new Error('Database not initialized');
}
return this._qddb;
}
init_qddb() {
return new Promise<void>((resolve, reject) => {
let dbreq = indexedDB.open('qd', 1);
dbreq.onupgradeneeded = function (event) {
let db = this.result;
console.log('Upgrading qd database from version', event.oldVersion, 'to', event.newVersion);
const nan = isNaN(event.oldVersion);
if (nan || event.oldVersion < 1) {
db.createObjectStore('books', { keyPath: 'id' });
const chapters = db.createObjectStore('chapters', { keyPath: ['id', 'bookId', 'time'] });
chapters.createIndex('bookId', 'bookId');
chapters.createIndex('id', 'id');
}
}
dbreq.onerror = () => {
reject(dbreq.error);
}
dbreq.onsuccess = () => {
this._qddb = dbreq.result;
resolve();
}
});
}
async init() {
make_storage_persist();
await this.init_qddb();
}
async saveQdChapter(info: QdChapterInfo) {
if (this.compress) {
const data = JSON.stringify(info);
const encoded = new TextEncoder().encode(data);
const compressed = await compress(encoded);
const compressedInfo: CompressedQdChapterInfo = {
compressed,
bookId: info.bookId,
id: info.id,
time: info.time,
}
await save_data(this.qddb, 'chapters', compressedInfo);
} else {
await save_data(this.qddb, 'chapters', info);
}
}
close() {
this.qddb.close();
}
}
function deleteIndexedDb(name: string) {
return new Promise<void>((resolve, reject) => {
const req = indexedDB.deleteDatabase(name);
req.onsuccess = () => {
resolve();
}
req.onerror = () => {
reject(req.error);
}
req.onblocked = () => {
reject(new Error('Delete blocked'));
}
});
}
export async function clearIndexedDb() {
for (const info of await indexedDB.databases()) {
if (info?.name) {
await deleteIndexedDb(info.name);
}
}
}

21
src/db/interfaces.ts Normal file
View File

@@ -0,0 +1,21 @@
import { DbConfig, DbType } from "../config";
import { IndexedDb } from "./indexedDb";
import type { QdChapterInfo } from "../types";
export interface Db {
init(): Promise<void>;
saveQdChapter(info: QdChapterInfo): Promise<void>;
close(): void;
}
export async function createDb(): Promise<Db> {
const config = new DbConfig();
await config.init();
switch (config.DbType) {
case DbType.IndexedDb:
const db = new IndexedDb(config.IndexedDb);
return db;
default:
throw new Error('Unsupported database type');
}
}

View File

@@ -1,16 +1,17 @@
import React from 'react';
import { Descriptions, Card, Typography, Tag, Space, Button } from 'antd';
import type { BookInfo, ChapterInfo } from '../qdtypes';
import type { QdChapterInfo } from '../types';
import { get_chapter_content, saveAsFile } from '../utils';
import { createDb } from '../db/interfaces';
const { Title, Text, Paragraph } = Typography;
interface QdChapterInfoProps {
bookInfo: BookInfo;
chapterInfo: ChapterInfo;
info: QdChapterInfo;
}
export default function QdChapterInfo({ bookInfo, chapterInfo }: QdChapterInfoProps) {
export default function QdChapterInfo({ info }: QdChapterInfoProps) {
const { chapterInfo, bookInfo, contents } = info;
// 格式化更新时间
const formatTime = (timestamp: number) => {
return new Date(timestamp).toLocaleString('zh-CN');
@@ -34,7 +35,7 @@ export default function QdChapterInfo({ bookInfo, chapterInfo }: QdChapterInfoPr
return <Tag color={statusMap[status] || 'default'}>{status}</Tag>;
};
const chapters = get_chapter_content(chapterInfo.content);
const chapters = contents ?? get_chapter_content(chapterInfo.content);
function saveAsTxt() {
const chapterName = chapterInfo.chapterName.replace(/[\/\\?%*:|"<>]/g, '_');
@@ -44,6 +45,13 @@ export default function QdChapterInfo({ bookInfo, chapterInfo }: QdChapterInfoPr
saveAsFile(filename, content);
}
async function saveToDb() {
const db = await createDb();
await db.init();
await db.saveQdChapter(info);
db.close();
}
return (
<Space orientation="vertical" size="middle" style={{ width: '100%' }}>
{/* 章节信息 */}
@@ -73,6 +81,7 @@ export default function QdChapterInfo({ bookInfo, chapterInfo }: QdChapterInfoPr
{/* 操作区域*/}
<Card title="操作" size="small">
<Button type="primary" onClick={saveAsTxt}></Button>
<Button onClick={saveToDb} style={{ marginLeft: 8 }}></Button>
</Card>
{/* 章节导航信息 */}

View File

@@ -1,10 +1,10 @@
import { useEffect, useState } from "react";
import { createRoot } from "react-dom/client";
import type { Message } from "./types";
import type { Message, QdChapterInfo } from "./types";
import { getCurrentTab, parseUrlParams, sendMessageToTab } from "./utils";
import * as styles from "./popup.module.css";
import { Spin, Result } from "antd";
import QdChapterInfo from "./models/QdChatperInfo";
import QdChapterInfoModel from "./models/QdChatperInfo";
function PopupBody() {
const [result, setResult] = useState<Message | null>(null);
@@ -58,8 +58,10 @@ function PopupBody() {
if (result) {
console.log(result);
if (result.ok && result.body?.type === 'QdChapterInfo') {
const { bookInfo, chapterInfo } = result.body;
return <QdChapterInfo bookInfo={bookInfo} chapterInfo={chapterInfo} />;
const body: QdChapterInfo = result.body;
/**@ts-ignore*/
delete body.type;
return <QdChapterInfoModel info={body} />;
}
return <Result status="error" title="错误" subTitle={result.msg || '未知错误'} />;
}

View File

@@ -19,6 +19,15 @@ function load() {
}
}
function getContents() {
const datas: string[] = [];
document.querySelectorAll('span.content-text').forEach(span => {
const e = span as HTMLElement;
datas.push(e.innerText);
})
return datas;
}
chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
const m = message as SendMessage;
try {
@@ -34,13 +43,25 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
sendResponse(msg);
return;
}
const chapterInfo = pageData.pageContext.pageProps.pageData.chapterInfo;
const bookInfo = pageData.pageContext.pageProps.pageData.bookInfo;
let contents: string[] | undefined = undefined;
if (chapterInfo.vipStatus !== 0) {
contents = getContents();
// Clear encrypted content to reduce size.
chapterInfo.content = '';
}
const msg: Message = {
ok: true,
code: 0,
body: {
type: 'QdChapterInfo',
chapterInfo: pageData.pageContext.pageProps.pageData.chapterInfo,
bookInfo: pageData.pageContext.pageProps.pageData.bookInfo,
chapterInfo,
bookInfo,
bookId: bookInfo.bookId,
id: chapterInfo.chapterId,
contents,
time: Date.now(),
},
for: m.type,
};

View File

@@ -12,6 +12,13 @@ export type DiscriminatedUnion<
export type QdChapterInfo = {
chapterInfo: QdTypes.ChapterInfo;
bookInfo: QdTypes.BookInfo;
bookId: number;
/**Chapter ID */
id: number;
/**Decrypted contents of the chapter. May not obtained if chapter is a free chapter */
contents?: string[];
/**Timestamp of the chapter */
time: number;
}
export type SendMessageMap = {