mirror of
https://github.com/lifegpc/bookdownload.git
synced 2026-07-08 01:31:31 +08:00
新增PocketBase支持
This commit is contained in:
@@ -9,6 +9,7 @@
|
|||||||
"antd": "^6.3.0",
|
"antd": "^6.3.0",
|
||||||
"colors": "^1.4.0",
|
"colors": "^1.4.0",
|
||||||
"esbuild": "^0.27.3",
|
"esbuild": "^0.27.3",
|
||||||
|
"pocketbase": "^0.26.8",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4"
|
"react-dom": "^19.2.4"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -36,13 +36,22 @@ type IndexedDbConfigData = {
|
|||||||
compress?: boolean;
|
compress?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PocketBaseConfigData = {
|
||||||
|
url?: string;
|
||||||
|
username?: string;
|
||||||
|
password?: string;
|
||||||
|
prefix?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export enum DbType {
|
export enum DbType {
|
||||||
IndexedDb,
|
IndexedDb,
|
||||||
|
PocketBase,
|
||||||
}
|
}
|
||||||
|
|
||||||
type DbConfigData = {
|
type DbConfigData = {
|
||||||
IndexedDb?: IndexedDbConfigData;
|
IndexedDb?: IndexedDbConfigData;
|
||||||
DbType?: DbType;
|
DbType?: DbType;
|
||||||
|
PocketBase?: PocketBaseConfigData;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class IndexedDbConfig {
|
export class IndexedDbConfig {
|
||||||
@@ -58,6 +67,37 @@ export class IndexedDbConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class PocketBaseConfig {
|
||||||
|
config: PocketBaseConfigData;
|
||||||
|
constructor(config: PocketBaseConfigData) {
|
||||||
|
this.config = config;
|
||||||
|
}
|
||||||
|
get url(): string {
|
||||||
|
return this.config.url ?? 'http://localhost:8090';
|
||||||
|
}
|
||||||
|
set url(value: string) {
|
||||||
|
this.config.url = value;
|
||||||
|
}
|
||||||
|
get username(): string {
|
||||||
|
return this.config.username ?? '';
|
||||||
|
}
|
||||||
|
set username(value: string) {
|
||||||
|
this.config.username = value;
|
||||||
|
}
|
||||||
|
get password(): string {
|
||||||
|
return this.config.password ?? '';
|
||||||
|
}
|
||||||
|
set password(value: string) {
|
||||||
|
this.config.password = value;
|
||||||
|
}
|
||||||
|
get prefix(): string {
|
||||||
|
return this.config.prefix ?? '';
|
||||||
|
}
|
||||||
|
set prefix(value: string) {
|
||||||
|
this.config.prefix = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export class DbConfig {
|
export class DbConfig {
|
||||||
static STORAGE_KEY = 'db_config';
|
static STORAGE_KEY = 'db_config';
|
||||||
config?: DbConfigData;
|
config?: DbConfigData;
|
||||||
@@ -93,4 +133,13 @@ export class DbConfig {
|
|||||||
}
|
}
|
||||||
this.config.DbType = value;
|
this.config.DbType = value;
|
||||||
}
|
}
|
||||||
|
get PocketBase(): PocketBaseConfig {
|
||||||
|
if (!this.config) {
|
||||||
|
throw new Error('Config not initialized');
|
||||||
|
}
|
||||||
|
if (!this.config.PocketBase) {
|
||||||
|
this.config.PocketBase = {};
|
||||||
|
}
|
||||||
|
return new PocketBaseConfig(this.config.PocketBase);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { DbConfig, DbType } from "../config";
|
import { DbConfig, DbType } from "../config";
|
||||||
import { IndexedDb } from "./indexedDb";
|
import { IndexedDb } from "./indexedDb";
|
||||||
|
import { PocketBaseDb } from "./pocketBase";
|
||||||
import type { QdChapterInfo } from "../types";
|
import type { QdChapterInfo } from "../types";
|
||||||
|
|
||||||
export interface Db {
|
export interface Db {
|
||||||
@@ -17,8 +18,11 @@ export async function createDb(): Promise<Db> {
|
|||||||
await config.init();
|
await config.init();
|
||||||
switch (config.DbType) {
|
switch (config.DbType) {
|
||||||
case DbType.IndexedDb:
|
case DbType.IndexedDb:
|
||||||
const db = new IndexedDb(config.IndexedDb);
|
const db1 = new IndexedDb(config.IndexedDb);
|
||||||
return db;
|
return db1;
|
||||||
|
case DbType.PocketBase:
|
||||||
|
const db2 = new PocketBaseDb(config.PocketBase);
|
||||||
|
return db2;
|
||||||
default:
|
default:
|
||||||
throw new Error('Unsupported database type');
|
throw new Error('Unsupported database type');
|
||||||
}
|
}
|
||||||
|
|||||||
129
src/db/pocketBase.ts
Normal file
129
src/db/pocketBase.ts
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
import type { Db } from "./interfaces";
|
||||||
|
import PocketBase from "pocketbase";
|
||||||
|
import type { CollectionModel } from "pocketbase";
|
||||||
|
import { PocketBaseConfig } from "../config";
|
||||||
|
import { QdChapterInfo } from "../types";
|
||||||
|
import { hash_qdchapter_info } from "../utils/qd";
|
||||||
|
|
||||||
|
const QD_CHAPTERS_FIELDS = [
|
||||||
|
{
|
||||||
|
'name': 'chapterId',
|
||||||
|
'type': 'number',
|
||||||
|
'required': true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'name': 'bookId',
|
||||||
|
'type': 'number',
|
||||||
|
'required': true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'name': 'time',
|
||||||
|
'type': 'number',
|
||||||
|
'required': true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'name': 'hash',
|
||||||
|
'type': 'text',
|
||||||
|
'required': true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'name': 'data',
|
||||||
|
'type': 'json',
|
||||||
|
'required': true,
|
||||||
|
}
|
||||||
|
];
|
||||||
|
const QD_CHAPTERS_INDEXES = [
|
||||||
|
'CREATE INDEX `idx_cid` ON `{name}` (chapterId)',
|
||||||
|
'CREATE INDEX `idx_bid` ON `{name}` (bookId)',
|
||||||
|
'CREATE INDEX `idx_hash` ON `{name}` (chapterId,bookId,hash)',
|
||||||
|
]
|
||||||
|
|
||||||
|
export class PocketBaseDb implements Db {
|
||||||
|
client: PocketBase;
|
||||||
|
cfg: PocketBaseConfig;
|
||||||
|
constructor(cfg: PocketBaseConfig) {
|
||||||
|
this.cfg = cfg;
|
||||||
|
this.client = new PocketBase(cfg.url);
|
||||||
|
}
|
||||||
|
async init() {
|
||||||
|
await this.client.collection('_superusers').authWithPassword(this.cfg.username, this.cfg.password);
|
||||||
|
if (!this.client.authStore.isValid) {
|
||||||
|
throw new Error('Failed to authenticate with PocketBase. Please check your credentials.');
|
||||||
|
}
|
||||||
|
const collections = await this.client.collections.getFullList({filter: this.cfg.prefix ? `name ~ "${this.cfg.prefix}%"` : undefined});
|
||||||
|
const collectionNames = new Set(collections.map(c => c.name));
|
||||||
|
if (!collectionNames.has(`${this.cfg.prefix}qd_chapters`)) {
|
||||||
|
await this.createCollection('qd_chapters', QD_CHAPTERS_FIELDS, QD_CHAPTERS_INDEXES);
|
||||||
|
} else {
|
||||||
|
const target = collections.find(c => c.name === `${this.cfg.prefix}qd_chapters`)!;
|
||||||
|
if (!this.checkCollection(target, QD_CHAPTERS_FIELDS, QD_CHAPTERS_INDEXES)) {
|
||||||
|
await this.updateCollection('qd_chapters', QD_CHAPTERS_FIELDS, QD_CHAPTERS_INDEXES);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async createCollection(name: string, fields: Record<string, unknown>[], indexes: string[]) {
|
||||||
|
await this.client.collections.create({
|
||||||
|
name: `${this.cfg.prefix}${name}`,
|
||||||
|
type: 'base',
|
||||||
|
fields: fields,
|
||||||
|
indexes: indexes,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async updateCollection(name: string, fields: Record<string, unknown>[], indexes: string[]) {
|
||||||
|
const nidexes = indexes.map(i => i.replace('{name}', `${this.cfg.prefix}${name}`));
|
||||||
|
await this.client.collections.update(`${this.cfg.prefix}${name}`, {
|
||||||
|
fields: fields,
|
||||||
|
indexes: nidexes,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
checkCollection(col: CollectionModel, fields: Record<string, unknown>[], indexes: string[]) {
|
||||||
|
for (const field of fields) {
|
||||||
|
const name = field.name;
|
||||||
|
const target = col.fields.find(f => f.name === name);
|
||||||
|
if (!target) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for (const key in field) {
|
||||||
|
if (field[key] !== target[key]) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const index of indexes) {
|
||||||
|
const tindex = index.replace('{name}', col.name);
|
||||||
|
if (!col.indexes.includes(tindex)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
async saveQdChapter(info: QdChapterInfo) {
|
||||||
|
const hash = hash_qdchapter_info(info);
|
||||||
|
const existed = await this.hasQdChapter(info.id, info.bookId, hash);
|
||||||
|
if (existed) {
|
||||||
|
console.log(`Chapter ${info.id} of book ${info.bookId} already exists in database, skipping`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
info.hash = undefined;
|
||||||
|
const re = await this.client.collection(`${this.cfg.prefix}qd_chapters`).create({
|
||||||
|
chapterId: info.id,
|
||||||
|
bookId: info.bookId,
|
||||||
|
time: info.time,
|
||||||
|
hash: hash,
|
||||||
|
data: info,
|
||||||
|
});
|
||||||
|
console.log(re);
|
||||||
|
}
|
||||||
|
async hasQdChapter(id: number, bookId: number, hash: string) {
|
||||||
|
const records = await this.client.collection(`${this.cfg.prefix}qd_chapters`).getList(1, 1, {
|
||||||
|
filter: `chapterId = ${id} && bookId = ${bookId} && hash = "${hash}"`,
|
||||||
|
fields: 'id',
|
||||||
|
});
|
||||||
|
return records.totalItems > 0;
|
||||||
|
}
|
||||||
|
close(): void {
|
||||||
|
this.client.cancelAllRequests();
|
||||||
|
this.client.authStore.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,9 +1,12 @@
|
|||||||
import { FloatButton, Affix, Button, Space, Select, Input } from "antd";
|
import { FloatButton, Affix, Button, Space, Select, Input, Typography, Alert } from "antd";
|
||||||
import { SaveTwoTone, SaveOutlined, SyncOutlined } from "@ant-design/icons";
|
import { SaveTwoTone, SaveOutlined, SyncOutlined } from "@ant-design/icons";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { DbConfig, DbType, IndexedDbConfig } from "../config";
|
import { DbConfig, DbType, IndexedDbConfig, PocketBaseConfig } from "../config";
|
||||||
import AlertWarn from "../components/AlertWarn";
|
import AlertWarn from "../components/AlertWarn";
|
||||||
import SwitchLabel from "../components/SwitchLabel";
|
import SwitchLabel from "../components/SwitchLabel";
|
||||||
|
import PocketBase from "pocketbase";
|
||||||
|
|
||||||
|
const { Text } = Typography;
|
||||||
|
|
||||||
function IndexedDbSettings({config}: { config: IndexedDbConfig }) {
|
function IndexedDbSettings({config}: { config: IndexedDbConfig }) {
|
||||||
const [compress, setCompress] = useState(false);
|
const [compress, setCompress] = useState(false);
|
||||||
@@ -19,6 +22,68 @@ function IndexedDbSettings({config}: { config: IndexedDbConfig }) {
|
|||||||
</>)
|
</>)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function testPocketBaseConnection(config: PocketBaseConfig) {
|
||||||
|
const client = new PocketBase(config.url);
|
||||||
|
const authData = await client.collection('_superusers').authWithPassword(config.username, config.password);
|
||||||
|
return client.authStore.isValid;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PocketBaseSettings({config}: { config: PocketBaseConfig }) {
|
||||||
|
const [url, setUrl] = useState('http://localhost:8090');
|
||||||
|
const [username, setUsername] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [prefix, setPrefix] = useState('');
|
||||||
|
const [testing, setTesting] = useState(false);
|
||||||
|
const [testResult, setTestResult] = useState<string | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
setUrl(config.url);
|
||||||
|
setUsername(config.username);
|
||||||
|
setPassword(config.password);
|
||||||
|
setPrefix(config.prefix);
|
||||||
|
}, [config]);
|
||||||
|
async function handleTestConnection() {
|
||||||
|
setTesting(true);
|
||||||
|
setTestResult(null);
|
||||||
|
try {
|
||||||
|
const result = await testPocketBaseConnection(config);
|
||||||
|
setTestResult(result ? "连接成功!" : "连接失败!");
|
||||||
|
} catch (e) {
|
||||||
|
setTestResult(e instanceof Error ? e.message : "未知错误");
|
||||||
|
} finally {
|
||||||
|
setTesting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function handleUrlChange(value: string) {
|
||||||
|
setUrl(value);
|
||||||
|
config.url = value;
|
||||||
|
}
|
||||||
|
function handleUsernameChange(value: string) {
|
||||||
|
setUsername(value);
|
||||||
|
config.username = value;
|
||||||
|
}
|
||||||
|
function handlePasswordChange(value: string) {
|
||||||
|
setPassword(value);
|
||||||
|
config.password = value;
|
||||||
|
}
|
||||||
|
function handlePrefixChange(value: string) {
|
||||||
|
setPrefix(value);
|
||||||
|
config.prefix = value;
|
||||||
|
}
|
||||||
|
return (<Space orientation="vertical">
|
||||||
|
<Text>服务器地址</Text>
|
||||||
|
<Input placeholder="服务器地址" value={url} onChange={e => handleUrlChange(e.target.value)} allowClear />
|
||||||
|
<Alert title="用户帐户需要是超级用户(superuser)才能正常工作" type="info" />
|
||||||
|
<Text>用户名</Text>
|
||||||
|
<Input placeholder="用户名" value={username} onChange={e => handleUsernameChange(e.target.value)} allowClear />
|
||||||
|
<Text>密码</Text>
|
||||||
|
<Input.Password placeholder="密码" value={password} onChange={e => handlePasswordChange(e.target.value)} allowClear />
|
||||||
|
<Text>前缀(可选)</Text>
|
||||||
|
<Input placeholder="前缀(可选)" value={prefix} onChange={e => handlePrefixChange(e.target.value)} allowClear />
|
||||||
|
<Button onClick={handleTestConnection} disabled={testing}>{testing ? "测试中..." : "测试连接"}</Button>
|
||||||
|
{testResult && <div>{testResult}</div>}
|
||||||
|
</Space>)
|
||||||
|
}
|
||||||
|
|
||||||
export default function DbSettings() {
|
export default function DbSettings() {
|
||||||
const [container, setContainer] = useState<HTMLElement | null>(null);
|
const [container, setContainer] = useState<HTMLElement | null>(null);
|
||||||
const [config] = useState(new DbConfig());
|
const [config] = useState(new DbConfig());
|
||||||
@@ -56,13 +121,19 @@ export default function DbSettings() {
|
|||||||
</Affix>
|
</Affix>
|
||||||
<br />
|
<br />
|
||||||
<Space orientation="vertical">
|
<Space orientation="vertical">
|
||||||
|
<Text>数据库类型</Text>
|
||||||
<Select value={dbType} onChange={value => setDbType(value)} style={{ width: 200 }} options={[
|
<Select value={dbType} onChange={value => setDbType(value)} style={{ width: 200 }} options={[
|
||||||
{
|
{
|
||||||
label: "IndexedDb",
|
label: "IndexedDb",
|
||||||
value: DbType.IndexedDb,
|
value: DbType.IndexedDb,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: "PocketBase",
|
||||||
|
value: DbType.PocketBase,
|
||||||
|
},
|
||||||
]} />
|
]} />
|
||||||
{dbType === DbType.IndexedDb && <IndexedDbSettings config={indexedDbConfig} />}
|
{dbType === DbType.IndexedDb && <IndexedDbSettings config={indexedDbConfig} />}
|
||||||
|
{dbType === DbType.PocketBase && <PocketBaseSettings config={config.PocketBase} />}
|
||||||
</Space>
|
</Space>
|
||||||
<FloatButton icon={<SaveTwoTone />} tooltip="保存设置" onClick={saveSettings} />
|
<FloatButton icon={<SaveTwoTone />} tooltip="保存设置" onClick={saveSettings} />
|
||||||
{alert && <AlertWarn title={alert.title} content={alert.content} onClose={() => setAlert(null)} />}
|
{alert && <AlertWarn title={alert.title} content={alert.content} onClose={() => setAlert(null)} />}
|
||||||
|
|||||||
@@ -796,6 +796,11 @@ json2mq@^0.2.0:
|
|||||||
dependencies:
|
dependencies:
|
||||||
string-convert "^0.2.0"
|
string-convert "^0.2.0"
|
||||||
|
|
||||||
|
pocketbase@^0.26.8:
|
||||||
|
version "0.26.8"
|
||||||
|
resolved "https://registry.yarnpkg.com/pocketbase/-/pocketbase-0.26.8.tgz#1b800b5f69f2ab08c3e3da45fce25f45c6724c45"
|
||||||
|
integrity sha512-aQ/ewvS7ncvAE8wxoW10iAZu6ElgbeFpBhKPnCfvRovNzm2gW8u/sQNPGN6vNgVEagz44kK//C61oKjfa+7Low==
|
||||||
|
|
||||||
react-dom@^19.2.4:
|
react-dom@^19.2.4:
|
||||||
version "19.2.4"
|
version "19.2.4"
|
||||||
resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-19.2.4.tgz#6fac6bd96f7db477d966c7ec17c1a2b1ad8e6591"
|
resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-19.2.4.tgz#6fac6bd96f7db477d966c7ec17c1a2b1ad8e6591"
|
||||||
|
|||||||
Reference in New Issue
Block a user