diff --git a/package-lock.json b/package-lock.json index ed3af8a..8f7a2b4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "MIT", "dependencies": { "@popperjs/core": "^2.11.8", + "docx": "^9.5.0", "uuid": "^11.1.0" }, "devDependencies": { @@ -1028,6 +1029,12 @@ "license": "MIT", "peer": true }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -1097,6 +1104,32 @@ "node": ">=6.0.0" } }, + "node_modules/docx": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/docx/-/docx-9.5.0.tgz", + "integrity": "sha512-WZggg9vVujFcTyyzfIVBBIxlCk51QvhLWl87wtI2zuBdz8C8C0mpRhEVwA2DZd7dXyY0AVejcEVDT9vn7Xm9FA==", + "license": "MIT", + "dependencies": { + "@types/node": "^22.7.5", + "hash.js": "^1.1.7", + "jszip": "^3.10.1", + "nanoid": "^5.1.3", + "xml": "^1.0.1", + "xml-js": "^1.6.8" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/docx/node_modules/@types/node": { + "version": "22.15.27", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.27.tgz", + "integrity": "sha512-5fF+eu5mwihV2BeVtX5vijhdaZOfkQTATrePEaXTcKqI16LhJ7gi2/Vhd9OZM0UojcdmiOCVg5rrax+i1MdoQQ==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, "node_modules/esbuild": { "version": "0.17.3", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.17.3.tgz", @@ -1622,6 +1655,16 @@ "node": ">=8" } }, + "node_modules/hash.js": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.7.tgz", + "integrity": "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "minimalistic-assert": "^1.0.1" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -1632,6 +1675,12 @@ "node": ">= 4" } }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -1678,9 +1727,7 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true, - "license": "ISC", - "peer": true + "license": "ISC" }, "node_modules/is-extglob": { "version": "2.1.1", @@ -1726,6 +1773,12 @@ "node": ">=8" } }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -1772,6 +1825,18 @@ "license": "MIT", "peer": true }, + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -1798,6 +1863,15 @@ "node": ">= 0.8.0" } }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -1847,6 +1921,12 @@ "node": ">=8.6" } }, + "node_modules/minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", + "license": "ISC" + }, "node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -1878,6 +1958,24 @@ "dev": true, "license": "MIT" }, + "node_modules/nanoid": { + "version": "5.1.5", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.5.tgz", + "integrity": "sha512-Ir/+ZpE9fDsNH0hQ3C68uyThDXzYcim2EqcZ8zn8Chtt1iylPT9xXJB0kPCnqzgcEGikO9RxSrh63MsmVCU7Fw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.js" + }, + "engines": { + "node": "^18 || >=20" + } + }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -1965,6 +2063,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -2046,6 +2150,12 @@ "node": ">= 0.8.0" } }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -2078,6 +2188,21 @@ ], "license": "MIT" }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, "node_modules/regexpp": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/regexpp/-/regexpp-3.2.0.tgz", @@ -2155,6 +2280,18 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/sax": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.4.1.tgz", + "integrity": "sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg==", + "license": "ISC" + }, "node_modules/semver": { "version": "7.7.1", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", @@ -2168,6 +2305,12 @@ "node": ">=10" } }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "license": "MIT" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -2203,6 +2346,15 @@ "node": ">=8" } }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", @@ -2346,6 +2498,12 @@ "node": ">=4.2.0" } }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "license": "MIT" + }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", @@ -2357,6 +2515,12 @@ "punycode": "^2.1.0" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, "node_modules/uuid": { "version": "11.1.0", "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", @@ -2414,6 +2578,24 @@ "license": "ISC", "peer": true }, + "node_modules/xml": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/xml/-/xml-1.0.1.tgz", + "integrity": "sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw==", + "license": "MIT" + }, + "node_modules/xml-js": { + "version": "1.6.11", + "resolved": "https://registry.npmjs.org/xml-js/-/xml-js-1.6.11.tgz", + "integrity": "sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g==", + "license": "MIT", + "dependencies": { + "sax": "^1.2.4" + }, + "bin": { + "xml-js": "bin/cli.js" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index eea1035..361cb8a 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ }, "dependencies": { "@popperjs/core": "^2.11.8", + "docx": "^9.5.0", "uuid": "^11.1.0" } } diff --git a/src/components/ChapterTree.ts b/src/components/ChapterTree.ts index 2afc9bb..8c83b27 100644 --- a/src/components/ChapterTree.ts +++ b/src/components/ChapterTree.ts @@ -260,8 +260,8 @@ export class ChapterTree { // 只有当文件不排除之外时,才显示"标记完成章节"选项 if (!node.exclude) { menu.addItem((item) => { - item.setTitle(node.default_status === 'done' ? - i18n.t('MARK_AS_DRAFT') : + item.setTitle(node.default_status === 'done' ? + i18n.t('MARK_AS_DRAFT') : i18n.t('MARK_AS_COMPLETE')); item.setIcon(node.default_status === 'done' ? "x-circle" : "check-circle"); item.onClick(async () => { @@ -284,16 +284,30 @@ export class ChapterTree { // 添加排除选项 menu.addItem((item) => { - item.setTitle(node.exclude ? - i18n.t('INCLUDE_IN_STATS') : + item.setTitle(node.exclude ? + i18n.t('INCLUDE_IN_STATS') : i18n.t('EXCLUDE_FROM_STATS')); item.setIcon(node.exclude ? "plus-circle" : "minus-circle"); + // 修改排除选项的onClick处理函数 item.onClick(async () => { node.exclude = !node.exclude; // 无论是包含还是排除,都设置为草稿状态 node.default_status = 'draft'; + // 如果是文件夹,并且被排除,则递归排除所有子节点 + if (node.type === 'group' && node.exclude && node.children?.length) { + this.recursiveExclude(node.children, true); + } + + // 如果是文件夹,并且被包含,则递归包含所有子节点 + if (node.type === 'group' && !node.exclude && node.children?.length) { + this.recursiveExclude(node.children, false); + } + + // 更新父目录的排除状态 + this.updateParentExcludeStatus(node); + // 计算新的章节进度 const progress = this.calculateChapterProgress(this.book.structure.tree); // 更新节点的状态并保存 @@ -409,8 +423,8 @@ export class ChapterTree { item.setTitle(i18n.t('DELETE')); item.setIcon("trash"); item.onClick(() => { - const title = node.type === 'file' ? - i18n.t('DELETE_FILE_TITLE') : + const title = node.type === 'file' ? + i18n.t('DELETE_FILE_TITLE') : i18n.t('DELETE_FOLDER_TITLE'); const message = node.type === 'file' ? i18n.t('DELETE_FILE_DESC', { title: node.title }) @@ -439,6 +453,49 @@ export class ChapterTree { }); } + // 添加一个新方法来更新父目录的排除状态 + private updateParentExcludeStatus(node: ChapterNode) { + // 找到当前节点的父节点 + const findParentNode = (searchNode: ChapterNode, nodes: ChapterNode[]): ChapterNode | null => { + for (const n of nodes) { + if (n.children?.some(child => child.id === searchNode.id)) { + return n; + } + if (n.children?.length) { + const parent = findParentNode(searchNode, n.children); + if (parent) return parent; + } + } + return null; + }; + + const parent = findParentNode(node, this.book.structure.tree); + if (parent && parent.type === 'group') { + // 检查父目录的所有子节点是否都被排除 + const allExcluded = parent.children?.every(child => + child.exclude || (child.type === 'group' && this.isFolderExcluded(child)) + ); + + // 更新父目录的排除状态 + if (allExcluded !== parent.exclude) { + parent.exclude = allExcluded; + + // 递归更新上层目录 + this.updateParentExcludeStatus(parent); + } + } + } + + // 添加递归排除/包含子节点的方法 + private recursiveExclude(nodes: ChapterNode[], exclude: boolean) { + for (const node of nodes) { + node.exclude = exclude; + if (node.type === 'group' && node.children?.length) { + this.recursiveExclude(node.children, exclude); + } + } + } + // === 拖拽相关 === private setupDragAndDrop(header: HTMLElement, item: HTMLElement, node: ChapterNode) { header.setAttribute('draggable', 'true'); diff --git a/src/modals/EbookModal.ts b/src/modals/EbookModal.ts index ac3acc4..d497c19 100644 --- a/src/modals/EbookModal.ts +++ b/src/modals/EbookModal.ts @@ -1,8 +1,27 @@ +import { App, Notice } from "obsidian"; import { BaseModal } from "./BaseModal"; +import { BookManager } from "../services/BookManager"; +import { Book } from "../types/book"; +import { ExportService } from "../services/ExportService"; +import { i18n } from "../i18n/i18n"; export class EbookModal extends BaseModal { - constructor(container: HTMLElement) { + private bookManager: BookManager; + private exportService: ExportService; + private books: Book[] = []; + private selectedBook: Book | null = null; + private selectedFormat: string = 'txt'; + + constructor(container: HTMLElement, private app: App, private settings: any) { super(container, '生成电子书'); + this.bookManager = new BookManager(app, settings); + this.exportService = new ExportService(app, settings); + } + + async open() { + // 加载所有书籍 + this.books = await this.bookManager.getAllBooks(); + super.open(); } protected createContent() { @@ -11,50 +30,106 @@ export class EbookModal extends BaseModal { // 电子书信息表单 const form = content.createDiv({ cls: 'ebook-form' }); - // 书名 - this.createFormField(form, '书名', 'title', 'text', '请输入书名'); - - // 作者 - this.createFormField(form, '作者', 'author', 'text', '请输入作者名'); - - // 封面 - const coverField = this.createFormField(form, '封面', 'cover', 'file'); - const fileInput = coverField.querySelector('input'); - if (fileInput) { - const fileButton = coverField.createDiv({ cls: 'file-select-button', text: '选择文件' }); - const fileLabel = coverField.createDiv({ cls: 'file-label', text: '未选择任何文件' }); - - fileButton.addEventListener('click', () => { - fileInput.click(); - }); - - fileInput.addEventListener('change', () => { - if (fileInput.files && fileInput.files.length > 0) { - fileLabel.setText(fileInput.files[0].name); - } else { - fileLabel.setText('未选择任何文件'); - } - }); - } + // 书籍选择 + this.createBookSelector(form); // 格式选择 const formatField = this.createFormField(form, '格式', 'format', 'select'); const formatSelect = formatField.querySelector('select'); if (formatSelect) { - this.createSelectOption(formatSelect, 'pdf', 'PDF'); + // 添加支持的格式 this.createSelectOption(formatSelect, 'txt', 'TXT'); + this.createSelectOption(formatSelect, 'docx', 'DOCX'); + this.createSelectOption(formatSelect, 'pdf', 'PDF'); this.createSelectOption(formatSelect, 'epub', 'EPUB'); - this.createSelectOption(formatSelect, 'mobi', 'MOBI'); + formatSelect.addEventListener('change', (e) => { + this.selectedFormat = (e.target as HTMLSelectElement).value; + }); } // 生成按钮 const generateBtn = content.createDiv({ cls: 'generate-btn', text: '生成电子书' }); generateBtn.addEventListener('click', () => { - this.showNotice('电子书生成功能开发中,敬请期待!'); + this.exportBook(); }); } + private createBookSelector(container: HTMLElement) { + const field = this.createFormField(container, '选择书籍', 'book', 'select'); + const select = field.querySelector('select'); + + if (select && this.books.length > 0) { + // 添加书籍选项 + for (const book of this.books) { + this.createSelectOption(select, book.basic.uuid, book.basic.title); + } + + // 默认选择第一本书 + this.selectedBook = this.books[0]; + + // 监听选择变化 + select.addEventListener('change', (e) => { + const uuid = (e.target as HTMLSelectElement).value; + this.selectedBook = this.books.find(book => book.basic.uuid === uuid) || null; + }); + } else if (select) { + // 没有书籍时显示提示 + this.createSelectOption(select, '', '没有可用的书籍'); + select.disabled = true; + } + } + + private async exportBook() { + if (!this.selectedBook) { + this.showNotice('请先选择一本书籍'); + return; + } + + try { + // 调用导出服务生成内容 + const content = await this.exportService.exportBook( + this.selectedFormat, + this.selectedBook + ); + + // 如果导出功能尚未实现,显示提示 + if (content.content.startsWith(`${this.selectedFormat.toUpperCase()}导出功能开发中`)) { + this.showNotice(`${this.selectedFormat.toUpperCase()}导出功能尚在开发中,敬请期待!`); + return; + } + + // 根据格式处理下载 + if (this.selectedFormat === 'docx') { + // 对于DOCX,内容是base64字符串 + const dataUrl = content.content; + const a = document.createElement('a'); + a.href = dataUrl; + a.download = content.fileName; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + } else { + // 对于其他格式(如TXT),使用Blob + const blob = new Blob([content.content], { type: 'text/plain' }); + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = content.fileName; + document.body.appendChild(a); + a.click(); + window.URL.revokeObjectURL(url); + document.body.removeChild(a); + } + + this.showNotice(`导出成功!`); + this.close(); + } catch (error) { + console.error('导出错误:', error); + this.showNotice(`导出失败: ${error.message}`); + } + } + private createFormField(container: HTMLElement, label: string, id: string, type: string, placeholder?: string) { const field = container.createDiv({ cls: 'form-field' }); field.createDiv({ cls: 'field-label', text: label }); diff --git a/src/services/ExportService.ts b/src/services/ExportService.ts new file mode 100644 index 0000000..df7b14c --- /dev/null +++ b/src/services/ExportService.ts @@ -0,0 +1,434 @@ +import { App, TFile, TFolder } from 'obsidian'; +import { Book, ChapterNode } from '../types/book'; +import { i18n } from '../i18n/i18n'; + +// 导出格式接口 +export interface ExportStrategy { + export(app: App, book: Book, selectedChapters?: ChapterNode[]): Promise; +} + +// TXT导出策略 - 实际实现 +export class TxtExportStrategy implements ExportStrategy { + private rootPath: string; + constructor(rootPath: string) { + this.rootPath = rootPath; + } + async export(app: App, book: Book, selectedChapters?: ChapterNode[]): Promise { + try { + const content = await this.generateContent(app, book, selectedChapters); + return content; + } catch (error) { + console.error('TXT导出错误:', error); + throw new Error(`TXT导出失败: ${error.message}`); + } + } + + private async generateContent(app: App, book: Book, selectedChapters?: ChapterNode[]): Promise { + let content = `${book.basic.title}\n`; + if (book.basic.subtitle) { + content += `${book.basic.subtitle}\n`; + } + content += `作者: ${book.basic.author.join(', ')}\n\n`; + + if (book.basic.desc) { + content += `${book.basic.desc}\n\n`; + } + + const chapters = selectedChapters || book.structure.tree; + content += await this.processChapters(app, book, chapters); + + return content; + } + + private async processChapters(app: App, book: Book, chapters: ChapterNode[], level: number = 0): Promise { + let content = ''; + for (const chapter of chapters) { + if (chapter.exclude) continue; + + // 添加章节标题 + content += `${chapter.title}\n\n`; + if (chapter.type === 'file') { + // 读取文件内容 + const filePath = `${this.rootPath}/${book?.basic.title}/${chapter.path}`; + const file = app.vault.getAbstractFileByPath(filePath); + if (file instanceof TFile) { + const fileContent = await app.vault.read(file); + // 处理Markdown语法,转换为纯文本 + const plainTextContent = this.convertMarkdownToPlainText(fileContent); + content += `${plainTextContent}\n\n`; + } + } + + // 处理子章节 + if (chapter.children && chapter.children.length > 0) { + content += await this.processChapters(app, book, chapter.children, level + 1); + } + } + return content; + } + + /** + * 将Markdown格式转换为纯文本 + * @param markdown Markdown格式的文本 + * @returns 转换后的纯文本 + */ + private convertMarkdownToPlainText(markdown: string): string { + let plainText = markdown; + + // 移除标题格式 (# 标题) + plainText = plainText.replace(/^#{1,6}\s+(.+)$/gm, '$1'); + + // 移除加粗和斜体 (**文本** 或 *文本*) + plainText = plainText.replace(/\*\*(.+?)\*\*/g, '$1'); + plainText = plainText.replace(/\*(.+?)\*/g, '$1'); + plainText = plainText.replace(/__(.+?)__/g, '$1'); + plainText = plainText.replace(/_(.+?)_/g, '$1'); + + // 移除双链接 [[链接]] 或 [[链接|显示文本]] + plainText = plainText.replace(/\[\[([^\|\]]+)\|([^\]]+)\]\]/g, '$2'); + plainText = plainText.replace(/\[\[([^\]]+)\]\]/g, '$1'); + + // 移除普通链接 [显示文本](链接) + plainText = plainText.replace(/\[([^\]]+)\]\([^\)]+\)/g, '$1'); + + // 移除代码块 + plainText = plainText.replace(/```[\s\S]*?```/g, ''); + + // 移除行内代码 + plainText = plainText.replace(/`([^`]+)`/g, '$1'); + + // 移除引用块 + plainText = plainText.replace(/^>\s+(.+)$/gm, '$1'); + + // 移除水平分割线 + plainText = plainText.replace(/^-{3,}|^\*{3,}|^_{3,}/gm, ''); + + // 移除任务列表标记 [x] 或 [ ] + plainText = plainText.replace(/^\s*- \[[x\s]\]\s+(.+)$/gm, '- $1'); + + // 处理HTML标签 + plainText = plainText.replace(/<[^>]+>/g, ''); + + // 处理转义字符 + plainText = plainText.replace(/\\([\*_\[\]\(\)\`\#\+\-\.\!\\])/g, '$1'); + + // 移除多余的空行(连续两个以上的换行符替换为两个) + plainText = plainText.replace(/\n{3,}/g, '\n\n'); + + return plainText; + } +} + +// 其他导出策略 - 返回提示信息 +// DOCX导出策略 - 实际实现 +export class DocxExportStrategy implements ExportStrategy { + private rootPath: string; + constructor(rootPath: string) { + this.rootPath = rootPath; + } + + async export(app: App, book: Book, selectedChapters?: ChapterNode[]): Promise { + try { + const docxBlob = await this.generateDocx(app, book, selectedChapters); + // 将Blob转换为base64字符串 + return await this.blobToBase64(docxBlob); + } catch (error) { + console.error('DOCX导出错误:', error); + throw new Error(`DOCX导出失败: ${error.message}`); + } + } + + private async generateDocx(app: App, book: Book, selectedChapters?: ChapterNode[]): Promise { + // 导入docx库 + const { Document, Packer, Paragraph, TextRun, HeadingLevel, AlignmentType, BorderStyle } = await import('docx'); + + // 文档内容 + const children = []; + + // 添加标题 + children.push( + new Paragraph({ + text: book.basic.title, + heading: HeadingLevel.TITLE, + alignment: AlignmentType.CENTER, + spacing: { after: 200 } + }) + ); + + // 添加副标题 + if (book.basic.subtitle) { + children.push( + new Paragraph({ + text: book.basic.subtitle, + heading: HeadingLevel.HEADING_1, + alignment: AlignmentType.CENTER, + spacing: { after: 200 } + }) + ); + } + + // 添加作者 + children.push( + new Paragraph({ + text: `作者: ${book.basic.author.join(', ')}`, + alignment: AlignmentType.CENTER, + spacing: { after: 400 } + }) + ); + + // 添加描述 + if (book.basic.desc) { + children.push( + new Paragraph({ + text: book.basic.desc, + alignment: AlignmentType.JUSTIFIED, + spacing: { after: 400 } + }) + ); + } + // 处理章节 + const chapters = selectedChapters || book.structure.tree; + const chapterParagraphs = await this.processChapters(app, book, chapters); + children.push(...chapterParagraphs); + + // 创建文档并设置内容 + const doc = new Document({ + title: book.basic.title, + description: book.basic.desc || '', + creator: book.basic.author.join(', '), + sections: [ + { + children: children + } + ] + }); + + // 生成DOCX文件 + return await Packer.toBlob(doc); + } + + private async processChapters(app: App, book: Book, chapters: ChapterNode[], level: number = 1): Promise { + const { Paragraph, TextRun, HeadingLevel } = await import('docx'); + const paragraphs = []; + + for (const chapter of chapters) { + if (chapter.exclude) continue; + + // 添加章节标题 + const headingLevel = level <= 6 ? level : 6; // 最多支持6级标题 + paragraphs.push( + new Paragraph({ + text: chapter.title, + heading: HeadingLevel[`HEADING_${headingLevel}` as keyof typeof HeadingLevel], + spacing: { before: 300, after: 200 } + }) + ); + + if (chapter.type === 'file') { + // 读取文件内容 + const filePath = `${this.rootPath}/${book?.basic.title}/${chapter.path}`; + const file = app.vault.getAbstractFileByPath(filePath); + if (file instanceof TFile) { + const fileContent = await app.vault.read(file); + // 将Markdown转换为DOCX段落 + const contentParagraphs = this.convertMarkdownToDocx(fileContent); + // 等待Promise解析后再添加段落 + const resolvedParagraphs = await Promise.resolve(contentParagraphs); + paragraphs.push(...resolvedParagraphs); + } + } + + // 处理子章节 + if (chapter.children && chapter.children.length > 0) { + const childParagraphs = await this.processChapters(app, book, chapter.children, level + 1); + paragraphs.push(...childParagraphs); + } + } + + return paragraphs; + } + + /** + * 将Markdown格式转换为DOCX段落 + * @param markdown Markdown格式的文本 + * @returns DOCX段落数组 + */ + private async convertMarkdownToDocx(markdown: string): Promise { + const { Paragraph, TextRun, HeadingLevel, AlignmentType } = await import('docx'); + const paragraphs = []; + + // 按行分割Markdown + const lines = markdown.split('\n'); + let inCodeBlock = false; + let codeBlockContent = ''; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + + // 处理代码块 + if (line.startsWith('```')) { + if (inCodeBlock) { + // 结束代码块 + paragraphs.push( + new Paragraph({ + text: codeBlockContent, + spacing: { before: 200, after: 200 }, + indent: { left: 720 }, // 缩进 + }) + ); + codeBlockContent = ''; + inCodeBlock = false; + } else { + // 开始代码块 + inCodeBlock = true; + } + continue; + } + + if (inCodeBlock) { + codeBlockContent += line + '\n'; + continue; + } + + // 处理标题 + const headingMatch = line.match(/^(#{1,6})\s+(.+)$/); + if (headingMatch) { + const level = headingMatch[1].length; + const headingText = headingMatch[2]; + paragraphs.push( + new Paragraph({ + text: headingText, + heading: HeadingLevel[`HEADING_${level}` as keyof typeof HeadingLevel], + spacing: { before: 200, after: 120 } + }) + ); + continue; + } + + // 处理普通段落 + if (line.trim() !== '') { + // 处理加粗、斜体等格式 + let processedLine = line; + const textRuns = []; + + // 简单处理加粗和斜体 + // 注意:这是一个简化的处理,实际上需要更复杂的解析 + const boldItalicRegex = /\*\*\*(.+?)\*\*\*/g; + const boldRegex = /\*\*(.+?)\*\*/g; + const italicRegex = /\*(.+?)\*/g; + + // 替换加粗斜体 + processedLine = processedLine.replace(boldItalicRegex, (match, p1) => { + textRuns.push(new TextRun({ text: p1, bold: true, italics: true })); + return ''; + }); + + // 替换加粗 + processedLine = processedLine.replace(boldRegex, (match, p1) => { + textRuns.push(new TextRun({ text: p1, bold: true })); + return ''; + }); + + // 替换斜体 + processedLine = processedLine.replace(italicRegex, (match, p1) => { + textRuns.push(new TextRun({ text: p1, italics: true })); + return ''; + }); + + // 如果有剩余文本,添加为普通文本 + if (processedLine.trim() !== '') { + textRuns.push(new TextRun({ text: processedLine })); + } + + // 如果没有特殊格式,直接添加为普通段落 + if (textRuns.length === 0) { + paragraphs.push(new Paragraph({ text: line })); + } else { + paragraphs.push(new Paragraph({ children: textRuns })); + } + } else if (i > 0 && lines[i - 1].trim() !== '') { + // 空行,但前一行不是空行,添加段落间距 + paragraphs.push(new Paragraph({ text: '', spacing: { after: 120 } })); + } + } + + return paragraphs; + } + + /** + * 将Blob转换为base64字符串 + * @param blob Blob对象 + * @returns base64字符串 + */ + private async blobToBase64(blob: Blob): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onloadend = () => { + if (typeof reader.result === 'string') { + resolve(reader.result); + } else { + reject(new Error('转换失败')); + } + }; + reader.onerror = reject; + reader.readAsDataURL(blob); + }); + } +} + +export class PdfExportStrategy implements ExportStrategy { + private rootPath: string; + constructor(rootPath: string) { + this.rootPath = rootPath; + } + + async export(app: App, book: Book, selectedChapters?: ChapterNode[]): Promise { + return `PDF导出功能开发中 - ${book.basic.title}`; + } +} + +export class EpubExportStrategy implements ExportStrategy { + private rootPath: string; + constructor(rootPath: string) { + this.rootPath = rootPath; + } + + async export(app: App, book: Book, selectedChapters?: ChapterNode[]): Promise { + return `EPUB导出功能开发中 - ${book.basic.title}`; + } +} + +// 导出服务类 +export class ExportService { + + private rootPath: string; + + private strategies: Record; + + constructor(private app: App, settings: any) { + this.rootPath = settings.defaultBookPath; + this.strategies = { + 'txt': new TxtExportStrategy(this.rootPath), + 'docx': new DocxExportStrategy(this.rootPath), + 'pdf': new PdfExportStrategy(this.rootPath), + 'epub': new EpubExportStrategy(this.rootPath) + }; + } + + async exportBook(format: string, book: Book, selectedChapters?: ChapterNode[]): Promise<{ content: string, fileName: string }> { + const strategy = this.strategies[format]; + if (!strategy) { + throw new Error(`不支持的导出格式: ${format}`); + } + + const content = await strategy.export(this.app, book, selectedChapters); + return { + content: content, + fileName: `${book.basic.title}.${format}` + }; + } + + getSupportedFormats(): string[] { + return Object.keys(this.strategies); + } +} \ No newline at end of file diff --git a/src/views/ToolsView.ts b/src/views/ToolsView.ts index 5d2acb8..c67cbd2 100644 --- a/src/views/ToolsView.ts +++ b/src/views/ToolsView.ts @@ -125,7 +125,7 @@ export class ToolView extends ItemView { { icon: 'book', text: i18n.t('GENERATE_EBOOK'), - onClick: () => new EbookModal(this.containerEl).open() + onClick: () => new EbookModal(this.containerEl, this.app, this.plugin.settings).open() }, { icon: 'clock',