支持保存书籍为zip

This commit is contained in:
2026-03-20 13:35:29 +08:00
parent 30e3075bab
commit 6af00021ef
7 changed files with 137 additions and 37 deletions

View File

@@ -16,6 +16,13 @@ function Download() {
code: 0,
for: m.type,
} as Message);
} else if (m.type === 'DownloadQdBookAsTxtZip') {
setMessage(m);
sendResponse({
ok: true,
code: 0,
for: m.type,
} as Message);
}
});
}, []);
@@ -23,6 +30,9 @@ function Download() {
{message && message.type === 'DownloadQdBookAsEpub' &&
<QdBook info={message.info} options={message.options} save_type='epub' />
}
{message && message.type === 'DownloadQdBookAsTxtZip' &&
<QdBook info={message.info} options={message.options} txtzip={message.txtzip} save_type='txtzip' />
}
{!message && <Result
title="等待下载指令..."
status="info"

View File

@@ -1,4 +1,4 @@
import type { QdBookDownloadOptions, QdBookInfo, QdChapterInfo } from "../../types";
import type { QdBookDownloadOptions, QdBookInfo, QdBookTxtZipOptions, QdChapterInfo } from "../../types";
import { Epub } from "../../epub/base";
import { EpubNavItem } from "../../epub/nav";
import { useEffect, useState } from "react";
@@ -6,12 +6,21 @@ import { Result } from "antd";
import { ChapterShowMode, get_new_volumes } from "../../utils/qd";
import { createDb } from "../../db/interfaces";
import { get_chapter_content } from "../../utils";
import { makesure_zip_configured } from "../../utils/zip";
import { ZipWriter, TextReader } from "@zip.js/zip.js";
import { filter_filename } from "../../utils/filename";
export interface QdBookProps {
info: QdBookInfo,
options?: QdBookDownloadOptions,
save_type: 'epub',
save_type: 'epub' | 'txtzip',
txtzip?: QdBookTxtZipOptions,
}
const ExtMap = {
'epub': 'epub',
'txtzip': 'zip',
}
async function download_cover(url: string) {
@@ -43,7 +52,19 @@ function generate_titlepage(bookInfo: QdBookInfo) {
return new XMLSerializer().serializeToString(xhtml);
}
function generate_chapter_page(ch: QdChapterInfo) {
function generate_chapter_page(ch: QdChapterInfo, use_xhtml = true) {
if (!use_xhtml) {
let content = `${ch.chapterInfo.chapterName}\n\n`;
const contents = ch.contents ?? get_chapter_content(ch.chapterInfo.content);
for (const line of contents) {
let c = line;
if (c.endsWith('\r')) {
c = c.slice(0, -1);
}
content += c + '\n';
}
return content;
}
const xhtml = document.implementation.createDocument(null, 'html', null);
const html = xhtml.documentElement;
xhtml.insertBefore(xhtml.createProcessingInstruction('xml', 'version="1.0" encoding="UTF-8"'), html);
@@ -71,7 +92,10 @@ function generate_chapter_page(ch: QdChapterInfo) {
return new XMLSerializer().serializeToString(xhtml);
}
function generate_empty_chapter(title: string, status = '未保存') {
function generate_empty_chapter(title: string, status = '未保存', use_xhtml = true) {
if (!use_xhtml) {
return `${title}${status}\n\n`;
}
const xhtml = document.implementation.createDocument(null, 'html', null);
const html = xhtml.documentElement;
xhtml.insertBefore(xhtml.createProcessingInstruction('xml', 'version="1.0" encoding="UTF-8"'), html);
@@ -89,23 +113,29 @@ function generate_empty_chapter(title: string, status = '未保存') {
return new XMLSerializer().serializeToString(xhtml);
}
export default function Book({info, options, save_type}: QdBookProps) {
export default function Book({info, options, save_type, txtzip}: QdBookProps) {
const [err, setErr] = useState<string | null>(null);
const [ok, setOk] = useState(false);
const [total, setTotal] = useState<number>(0);
const [current, setCurrent] = useState<number>(0);
const [msg, setMsg] = useState<string>('');
async function save() {
const filePickerType: FilePickerAcceptType = save_type === 'epub' ? {
description: 'EPUB File',
accept: {'application/epub+zip': ['.epub']}
} : {
description: 'ZIP File',
accept: {'application/zip': ['.zip']}
};
const pickerOptions: SaveFilePickerOptions = {
suggestedName: `${info.bookName}.${save_type}`,
types: [{
description: 'EPUB File',
accept: {'application/epub+zip': ['.epub']}
}],
suggestedName: `${info.bookName}.${ExtMap[save_type]}`,
types: [filePickerType],
};
const fileHandle = await window.showSaveFilePicker(pickerOptions);
const writable = await fileHandle.createWritable();
const epub = new Epub(writable);
const epub = save_type === 'epub' ? new Epub(writable) : undefined;
const zip = save_type === 'txtzip' ? (makesure_zip_configured(), new ZipWriter(writable)) : undefined;
const xhtml = save_type === 'epub';
if (epub) {
setMsg('初始化EPUB文件');
await epub.init();
@@ -158,6 +188,7 @@ export default function Book({info, options, save_type}: QdBookProps) {
let batchChs: (QdChapterInfo | undefined)[] = []
const batchSize = db.batchSize();
const chKeys: Map<number, unknown> = new Map();
const nameSets: Set<string> = new Set();
let c = 0;
for (const ch of chapters) {
chKeys.set(ch.id, ch.primaryKey);
@@ -210,9 +241,9 @@ export default function Book({info, options, save_type}: QdBookProps) {
if (skipNotBoughtChapters && !ch.chapterInfo.isBuy) {
continue;
}
page = generate_chapter_page(ch);
page = generate_chapter_page(ch, xhtml);
} else {
page = generate_empty_chapter(chapter.name);
page = generate_empty_chapter(chapter.name, '未保存', xhtml);
it.name += '(未保存)';
}
if (epub) {
@@ -224,6 +255,17 @@ export default function Book({info, options, save_type}: QdBookProps) {
linear: vol.name !== '作品相关',
});
}
if (zip) {
const addVolFolder = txtzip?.addVolumeFolder ?? true;
const useChapterNameAsFileName = txtzip?.useChapterNameAsFileName ?? true;
const fileName = useChapterNameAsFileName ? filter_filename(it.name) : `ch_${chapter.id}`;
let path = addVolFolder ? `${filter_filename(vol.name)}/${fileName}` : fileName;
if (nameSets.has(path)) {
path += `_${chapter.id}`;
}
nameSets.add(path);
await zip.add(path + '.txt', new TextReader(page));
}
volNav.children!.push(it);
if (volNav.href === undefined) {
volNav.href = link;
@@ -240,6 +282,10 @@ export default function Book({info, options, save_type}: QdBookProps) {
await epub.save();
setOk(true);
}
if (zip) {
await zip.close();
setOk(true);
}
}
useEffect(() => {
save().catch(e => {

View File

@@ -1,24 +1,17 @@
import { configure, ZipWriter, TextReader, BlobReader, WritableWriter } from "@zip.js/zip.js";
import { ZipWriter, TextReader, BlobReader, WritableWriter } from "@zip.js/zip.js";
import type { Reader, ReadableReader, ZipWriterAddDataOptions } from "@zip.js/zip.js";
import { EpubPackage, EpubManifestItem, EpubItemRef } from "./package";
import { EpubNav, to_nav_xhtml } from "./nav";
import { makesure_zip_configured } from "../utils/zip";
type StreamType<T> = Reader<T> | ReadableReader | ReadableStream | Reader<unknown>[] | ReadableReader[] | ReadableStream[];
let configured = false;
export class Epub<T extends WritableStream | WritableWriter> {
zip: ZipWriter<T>;
package;
#inited;
constructor(blob: T) {
if (!configured) {
configure({
useWebWorkers: false,
useCompressionStream: true,
})
configured = true;
}
makesure_zip_configured();
this.zip = new ZipWriter(blob);
this.package = new EpubPackage();
this.#inited = false;

View File

@@ -9,7 +9,7 @@ import type { Volume } from "../../qdtypes";
import { ChapterShowMode, get_new_volumes } from "../../utils/qd";
import ShowMode from "./ShowMode";
import { sendMessageToTab, waitTabLoaded } from "../../utils";
import { QdBookDownloadOptions } from "../../types";
import { QdBookDownloadOptions, QdBookTxtZipOptions } from "../../types";
import SwitchLabel from "../../components/SwitchLabel";
import { useNavigate } from "react-router";
@@ -23,8 +23,9 @@ export default function BookIndex() {
const [bookStatus, setBookStatus] = useBookStatus();
const setItems = useBookContext();
const [err, setErr] = useState<string | null>(null);
const [saveChapterOpenAsEpub, setSaveChapterOpenAsEpub] = useState(false);
const [saveChapterOpenSaveAs, setSaveChapterOpenSaveAs] = useState<'epub' | 'txtzip' | false>(false);
const [downloadOptions, setDownloadOptions] = useState<QdBookDownloadOptions>({});
const [txtzipOptions, setTxtZipOptions] = useState<QdBookTxtZipOptions>({});
const navigate = useNavigate();
function setChapterShowMode(chapterShowMode: ChapterShowMode) {
setBookStatus({ ...bookStatus, chapterShowMode });
@@ -38,17 +39,26 @@ export default function BookIndex() {
setErr(e instanceof Error ? e.message : String(e));
});
}
async function handleSaveAsEpub() {
async function handleSave() {
const url = chrome.runtime.getURL('dist/download.html');
const tab = await chrome.tabs.create({ url });
if (tab.status !== 'complete') {
await waitTabLoaded(tab.id!);
}
await sendMessageToTab(tab.id!, {
type: 'DownloadQdBookAsEpub',
info: bookInfo,
options: downloadOptions,
});
if (saveChapterOpenSaveAs === 'epub') {
await sendMessageToTab(tab.id!, {
type: 'DownloadQdBookAsEpub',
info: bookInfo,
options: downloadOptions,
});
} else if (saveChapterOpenSaveAs === 'txtzip') {
await sendMessageToTab(tab.id!, {
type: 'DownloadQdBookAsTxtZip',
info: bookInfo,
options: downloadOptions,
txtzip: txtzipOptions,
});
}
}
useEffect(() => {
handle();
@@ -87,15 +97,16 @@ export default function BookIndex() {
</Space>
</Flex>
<Flex align="center" className={styles.actions}>
<Button type="primary" onClick={() => setSaveChapterOpenAsEpub(true)}>EPUB</Button>
<Button type="primary" onClick={() => setSaveChapterOpenSaveAs('epub')}>EPUB</Button>
<Button type="primary" onClick={() => setSaveChapterOpenSaveAs('txtzip')}>TXT ZIP</Button>
<Modal
open={saveChapterOpenAsEpub}
onCancel={() => setSaveChapterOpenAsEpub(false)}
open={saveChapterOpenSaveAs !== false}
onCancel={() => setSaveChapterOpenSaveAs(false)}
onOk={() => {
setSaveChapterOpenAsEpub(false);
handleSaveAsEpub();
setSaveChapterOpenSaveAs(false);
handleSave();
}}
title="保存为EPUB"
title={`保存为${saveChapterOpenSaveAs === 'epub' ? 'EPUB' : 'TXT ZIP'}`}
okText="保存"
cancelText="取消"
>
@@ -109,6 +120,18 @@ export default function BookIndex() {
onChange={(checked) => setDownloadOptions({ ...downloadOptions, skipUnsavedChapters: checked })}
label="跳过未保存章节"
/>
{saveChapterOpenSaveAs === 'txtzip' && (<>
<SwitchLabel
checked={txtzipOptions.addVolumeFolder ?? true}
onChange={(checked) => setTxtZipOptions({ ...txtzipOptions, addVolumeFolder: checked })}
label="为每个卷创建文件夹"
/>
<SwitchLabel
checked={txtzipOptions.useChapterNameAsFileName ?? true}
onChange={(checked) => setTxtZipOptions({ ...txtzipOptions, useChapterNameAsFileName: checked })}
label="使用章节名称作为文件名(否则使用章节ID)"
/>
</>)}
</Modal>
<Button onClick={() => navigate('chapter/new')}></Button>
</Flex>

View File

@@ -55,6 +55,13 @@ export interface QdBookDownloadOptions {
skipNotBoughtChapters?: boolean;
}
export interface QdBookTxtZipOptions {
/**@default {true} */
addVolumeFolder?: boolean;
/**@default {true} */
useChapterNameAsFileName?: boolean;
}
export type SendMessageMap = {
GetQdChapterInfo: {};
GetQdBookInfo: {};
@@ -64,6 +71,11 @@ export type SendMessageMap = {
DownloadQdBookAsEpub: {
info: QdBookInfo;
options?: QdBookDownloadOptions;
},
DownloadQdBookAsTxtZip: {
info: QdBookInfo;
options?: QdBookDownloadOptions;
txtzip?: QdBookTxtZipOptions;
}
}

4
src/utils/filename.ts Normal file
View File

@@ -0,0 +1,4 @@
export function filter_filename(name: string) {
// Remove characters that are not allowed in file names on Windows, macOS, and Linux
return name.replace(/[/\\?%*:|"<>]/g, '_');
}

12
src/utils/zip.ts Normal file
View File

@@ -0,0 +1,12 @@
import { configure } from '@zip.js/zip.js';
let configured = false;
export function makesure_zip_configured() {
if (!configured) {
configure({
useWebWorkers: false,
useCompressionStream: true,
})
configured = true;
}
}