mirror of
https://github.com/lifegpc/bookdownload.git
synced 2026-06-06 05:38:46 +08:00
支持保存书籍为zip
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
12
src/types.ts
12
src/types.ts
@@ -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
4
src/utils/filename.ts
Normal 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
12
src/utils/zip.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user