diff --git a/src/download.tsx b/src/download.tsx index 43aae35..0e10d29 100644 --- a/src/download.tsx +++ b/src/download.tsx @@ -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' && } + {message && message.type === 'DownloadQdBookAsTxtZip' && + + } {!message && (null); const [ok, setOk] = useState(false); const [total, setTotal] = useState(0); const [current, setCurrent] = useState(0); const [msg, setMsg] = useState(''); 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 = new Map(); + const nameSets: Set = 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 => { diff --git a/src/epub/base.ts b/src/epub/base.ts index b05ba3a..1a606ab 100644 --- a/src/epub/base.ts +++ b/src/epub/base.ts @@ -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 = Reader | ReadableReader | ReadableStream | Reader[] | ReadableReader[] | ReadableStream[]; -let configured = false; - export class Epub { zip: ZipWriter; 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; diff --git a/src/manage/qd/BookIndex.tsx b/src/manage/qd/BookIndex.tsx index 2d8f962..8ff56df 100644 --- a/src/manage/qd/BookIndex.tsx +++ b/src/manage/qd/BookIndex.tsx @@ -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(null); - const [saveChapterOpenAsEpub, setSaveChapterOpenAsEpub] = useState(false); + const [saveChapterOpenSaveAs, setSaveChapterOpenSaveAs] = useState<'epub' | 'txtzip' | false>(false); const [downloadOptions, setDownloadOptions] = useState({}); + const [txtzipOptions, setTxtZipOptions] = useState({}); 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() { - + + 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' && (<> + setTxtZipOptions({ ...txtzipOptions, addVolumeFolder: checked })} + label="为每个卷创建文件夹" + /> + setTxtZipOptions({ ...txtzipOptions, useChapterNameAsFileName: checked })} + label="使用章节名称作为文件名(否则使用章节ID)" + /> + )} diff --git a/src/types.ts b/src/types.ts index ec33650..72a44ff 100644 --- a/src/types.ts +++ b/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; } } diff --git a/src/utils/filename.ts b/src/utils/filename.ts new file mode 100644 index 0000000..45b5ad5 --- /dev/null +++ b/src/utils/filename.ts @@ -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, '_'); +} diff --git a/src/utils/zip.ts b/src/utils/zip.ts new file mode 100644 index 0000000..bf76b55 --- /dev/null +++ b/src/utils/zip.ts @@ -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; + } +}