diff --git a/bin/fimfic2epub b/bin/fimfic2epub index a7113a8..b909925 100755 --- a/bin/fimfic2epub +++ b/bin/fimfic2epub @@ -40,6 +40,10 @@ ffc.fetchAll() }) }) .catch((err) => { - console.log('Error: ' + (err || 'Unknown error')) + if (err && err.stack) { + console.error(err.stack) + } else { + console.error('Error: ' + (err || 'Unknown error')) + } process.exit(1) }) diff --git a/extension/inject.css b/extension/inject.css index 4414d9f..393d482 100644 --- a/extension/inject.css +++ b/extension/inject.css @@ -1,4 +1,22 @@ +/* checkbox fixes */ +.toggleable-switch { + white-space: nowrap; +} +.toggleable-switch > a, .toggleable-switch > span { + transition: background 0.2s, opacity 0.2s !important; +} +.toggleable-switch input:disabled+a, .toggleable-switch input:disabled~span { + opacity: 0.6; +} +.toggleable-switch input:disabled+a, .toggleable-switch input:checked:disabled+a { + background: #BBB; +} +.toggleable-switch input:disabled+a:before, .toggleable-switch input:checked:disabled+a:before { + color: #666; +} + +/* the logo button */ .fimfic2epub-logo { float: right; margin-top: 5px; @@ -24,6 +42,7 @@ width: 24px; } +/* dialog popup */ #epubDialogContainer .drop-down-pop-up-container { position: absolute; top: 0; diff --git a/extension/manifest.json b/extension/manifest.json index 04224b1..b7b2fa3 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -4,7 +4,7 @@ "name": "fimfic2epub", "short_name": "fimfic2epub", "description": "Improved EPUB exporter for Fimfiction", - "version": "1.4.4", + "version": "1.4.5", "icons": { "128": "icon-128.png" diff --git a/package.json b/package.json index 68fd139..5939e53 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "fimfic2epub", - "version": "1.4.4", + "version": "1.4.5", "description": "Tool to generate improved EPUB ebooks from Fimfiction stories", "author": "djazz", "repository": { diff --git a/src/FimFic2Epub.js b/src/FimFic2Epub.js index 0e22e38..b28e276 100644 --- a/src/FimFic2Epub.js +++ b/src/FimFic2Epub.js @@ -88,6 +88,7 @@ class FimFic2Epub extends Emitter { this.options = { addCommentsLink: true, includeAuthorNotes: true, + useAuthorNotesIndex: false, addChapterHeadings: true, includeExternal: true, @@ -108,6 +109,9 @@ class FimFic2Epub extends Emitter { this.subjects = [] this.chapters = [] this.chaptersHtml = [] + this.notesHtml = [] + this.hasAuthorNotes = false + this.chaptersWithNotes = [] this.remoteResourcesCached = false this.remoteResources = new Map() this.coverUrl = '' @@ -189,6 +193,8 @@ class FimFic2Epub extends Emitter { } this.chapters.length = 0 this.chaptersHtml.length = 0 + this.hasAuthorNotes = false + this.chaptersWithNotes.length = 0 this.progress(0, 0, 'Fetching chapters...') this.pcache.chapters = new Promise((resolve, reject) => { @@ -218,6 +224,10 @@ class FimFic2Epub extends Emitter { ]).then((values) => { chapter.content = values[0] chapter.notes = values[1] + if (chapter.notes) { + this.hasAuthorNotes = true + this.chaptersWithNotes.push(index) + } ch.realWordCount = htmlWordCount(chapter.content) this.chapters[index] = chapter @@ -226,6 +236,7 @@ class FimFic2Epub extends Emitter { if (completeCount < chapterCount) { recursive() } else { + this.chaptersWithNotes.sort((a, b) => a - b) resolve() } }) @@ -303,14 +314,31 @@ class FimFic2Epub extends Emitter { buildChapters () { let chain = Promise.resolve() this.chaptersHtml.length = 0 + this.notesHtml.length = 0 for (let i = 0; i < this.chapters.length; i++) { let ch = this.storyInfo.chapters[i] let chapter = this.chapters[i] - chain = chain.then(template.createChapter.bind(null, ch, chapter, this)).then((html) => { - this.findRemoteResources('ch_' + zeroFill(3, i + 1), i, html) + chain = chain.then(template.createChapter.bind(null, { + title: this.options.addChapterHeadings ? ch.title : null, + link: this.options.addCommentsLink ? ch.link : null, + linkNotes: this.options.includeAuthorNotes && this.options.useAuthorNotesIndex && chapter.notes ? 'note_' + zeroFill(3, i + 1) + '.xhtml' : null, + content: chapter.content, + notes: !this.options.useAuthorNotesIndex ? chapter.notes : '', + notesFirst: chapter.notesFirst + })).then((html) => { + this.findRemoteResources('ch_' + zeroFill(3, i + 1), {chapter: i}, html) this.chaptersHtml[i] = html }) + if (this.options.includeAuthorNotes && this.options.useAuthorNotesIndex && chapter.notes) { + chain = chain.then(template.createChapter.bind(null, { + title: 'Author\'s Note: ' + ch.title, + content: chapter.notes + })).then((html) => { + this.findRemoteResources('note_' + zeroFill(3, i + 1), {note: i}, html) + this.notesHtml[i] = html + }) + } } return chain @@ -338,7 +366,7 @@ class FimFic2Epub extends Emitter { this.zip.file('OEBPS/Text/title.xhtml', template.createTitlePage(this)) this.zip.file('OEBPS/Styles/titlestyle.css', titlestyleCss) - this.zip.file('OEBPS/Text/nav.xhtml', template.createNav(this)) + this.zip.file('OEBPS/Text/nav.xhtml', template.createNav(this, 0)) this.zip.file('OEBPS/toc.ncx', template.createNcx(this)) for (let i = 0; i < this.chapters.length; i++) { @@ -347,6 +375,17 @@ class FimFic2Epub extends Emitter { this.zip.file(filename, html) } + if (this.options.includeAuthorNotes && this.options.useAuthorNotesIndex && this.hasAuthorNotes) { + this.zip.file('OEBPS/Text/notesnav.xhtml', template.createNav(this, 1)) + + for (let i = 0; i < this.chapters.length; i++) { + if (!this.chapters[i].notes) continue + let filename = 'OEBPS/Text/note_' + zeroFill(3, i + 1) + '.xhtml' + let html = this.notesHtml[i] + this.zip.file(filename, html) + } + } + this.zip.file('OEBPS/Styles/style.css', styleCss) this.remoteResources.forEach((r) => { @@ -617,8 +656,10 @@ class FimFic2Epub extends Emitter { let ourl = new RegExp(escapeStringRegexp(r.originalUrl), 'g') for (var i = 0; i < r.where.length; i++) { let w = r.where[i] - if (typeof w === 'number') { - this.chaptersHtml[w] = this.chaptersHtml[w].replace(ourl, dest) + if (typeof w === 'object' && w.chapter !== undefined && this.chaptersHtml[w.chapter]) { + this.chaptersHtml[w.chapter] = this.chaptersHtml[w.chapter].replace(ourl, dest) + } else if (typeof w === 'object' && w.note !== undefined && this.notesHtml[w.note]) { + this.notesHtml[w.note] = this.notesHtml[w.note].replace(ourl, dest) } else if (w === 'description') { this.storyInfo.description = this.storyInfo.description.replace(ourl, dest) } else if (w === 'tags') { diff --git a/src/main.js b/src/main.js index 6e269e8..702d056 100644 --- a/src/main.js +++ b/src/main.js @@ -79,10 +79,12 @@ document.body.appendChild(dialogContainer) let checkbox = { view: function (ctrl, args, text) { - return m('label.toggleable-switch', {style: 'white-space: nowrap;'}, [ - m('input', {type: 'checkbox', name: args.name, checked: args.checked, onchange: args.onchange}), - m('a', {style: 'margin-right: 10px'}), - text + return m('label.toggleable-switch', [ + m('input', Object.assign({ + type: 'checkbox' + }, args)), + m('a'), + text ? m('span', text) : null ]) } } @@ -107,9 +109,11 @@ let dialog = { this.title = m.prop('') this.author = m.prop('') + this.description = m.prop('') this.subjects = m.prop([]) this.addCommentsLink = m.prop(ffc.options.addCommentsLink) this.includeAuthorNotes = m.prop(ffc.options.includeAuthorNotes) + this.useAuthorNotesIndex = m.prop(ffc.options.useAuthorNotesIndex) this.addChapterHeadings = m.prop(ffc.options.addChapterHeadings) this.includeExternal = m.prop(ffc.options.includeExternal) this.joinSubjects = m.prop(ffc.options.joinSubjects) @@ -124,6 +128,7 @@ let dialog = { ffcProgress(-1) this.title(ffc.storyInfo.title) this.author(ffc.storyInfo.author.name) + this.description(ffc.storyInfo.short_description) this.subjects(ffc.subjects.slice(0)) m.redraw(true) this.center() @@ -152,6 +157,12 @@ let dialog = { autosize.update(this) } + this.setDescription = function () { + ctrl.description(this.value.trim()) + this.value = ctrl.description() + autosize.update(this) + } + this.ondown = (e) => { let rect = this.el().firstChild.getBoundingClientRect() let offset = {x: e.pageX - rect.left - document.body.scrollLeft, y: e.pageY - rect.top - document.body.scrollTop} @@ -202,8 +213,10 @@ let dialog = { } ffc.setTitle(this.title()) ffc.setAuthorName(this.author()) + ffc.storyInfo.short_description = this.description() ffc.options.addCommentsLink = this.addCommentsLink() ffc.options.includeAuthorNotes = this.includeAuthorNotes() + ffc.options.useAuthorNotesIndex = this.useAuthorNotesIndex() ffc.options.addChapterHeadings = this.addChapterHeadings() ffc.options.includeExternal = this.includeExternal() ffc.subjects = this.subjects() @@ -214,7 +227,6 @@ let dialog = { .then(ffc.fetchAll.bind(ffc)) .then(ffc.build.bind(ffc)) .then(ffc.getFile.bind(ffc)).then((file) => { - console.log('Saving file...') if (typeof safari !== 'undefined') { blobToDataURL(file).then((dataurl) => { document.location.href = dataurl @@ -245,11 +257,13 @@ let dialog = { m(checkbox, {checked: ctrl.addChapterHeadings(), onchange: m.withAttr('checked', ctrl.addChapterHeadings)}, 'Add chapter headings'), m(checkbox, {checked: ctrl.addCommentsLink(), onchange: m.withAttr('checked', ctrl.addCommentsLink)}, 'Add link to online comments (at the end of chapters)'), m(checkbox, {checked: ctrl.includeAuthorNotes(), onchange: m.withAttr('checked', ctrl.includeAuthorNotes)}, 'Include author\'s notes'), + m(checkbox, {checked: ctrl.useAuthorNotesIndex(), onchange: m.withAttr('checked', ctrl.useAuthorNotesIndex), disabled: !ctrl.includeAuthorNotes()}, 'Put all notes at the end of the ebook'), m(checkbox, {checked: ctrl.includeExternal(), onchange: m.withAttr('checked', ctrl.includeExternal)}, 'Download & include remote content (embed images)'), m('div', {style: 'font-size: 0.9em; line-height: 1em; margin-top: 4px; margin-bottom: 6px; color: #777;'}, 'Note: Disabling this creates invalid EPUBs and requires internet access to see remote content. Only cover image will be embedded.') )), m('tr', m('td.section_header', {colspan: 3}, m('b', 'Metadata customization'))), + m('tr', m('td.label', {style: 'vertical-align: top;'}, 'Description'), m('td', {colspan: 2}, m('textarea', {config: autosize, onchange: ctrl.setDescription}, ctrl.description()))), m('tr', m('td.label', {style: 'vertical-align: top;'}, 'Categories'), m('td', {colspan: 2}, m('textarea', {rows: 2, config: autosize, onchange: ctrl.setSubjects}, ctrl.subjects().join('\n')), m(checkbox, {checked: ctrl.joinSubjects(), onchange: m.withAttr('checked', ctrl.joinSubjects)}, 'Join categories and separate with commas (for iBooks only)') diff --git a/src/templates.js b/src/templates.js index f7e90fd..1dc23b8 100644 --- a/src/templates.js +++ b/src/templates.js @@ -22,19 +22,19 @@ function prettyDate (d) { return d.getDate() + nth(d) + ' ' + months[d.getMonth()].substring(0, 3) + ' ' + d.getFullYear() } -export function createChapter (ch, chapter, ffc) { +export function createChapter (ch) { return new Promise((resolve, reject) => { - let {content, notes} = chapter + let {content, notes, notesFirst, title, link, linkNotes} = ch let sections = [ - m.trust(content), - ffc.options.includeAuthorNotes && notes ? m('div#author_notes', {className: chapter.notesFirst ? 'top' : 'bottom'}, [ + m.trust(content || ''), + notes ? m('div#author_notes', {className: notesFirst ? 'top' : 'bottom'}, [ m('p', m('b', 'Author\'s Note:')), m.trust(notes)]) : null ] // if author notes are a the beginning of the chapter - if (notes && chapter.notesFirst) { + if (notes && notesFirst) { sections.reverse() } @@ -43,17 +43,18 @@ export function createChapter (ch, chapter, ffc) { m('head', [ m('meta', {charset: 'utf-8'}), m('link', {rel: 'stylesheet', type: 'text/css', href: '../Styles/style.css'}), - m('title', ch.title) + m('title', title) ]), m('body', [ - ffc.options.addChapterHeadings ? m('.chapter-title', [ - m('h1', ch.title), + title ? m('.chapter-title', [ + m('h1', title), m('hr') ]) : null, sections, - ffc.options.addCommentsLink ? m('p.double', {style: 'text-align: center; clear: both;'}, - m('a.chaptercomments', {href: ch.link + '#comment_list'}, 'Read chapter comments online') - ) : null + m('p.double', {style: 'text-align: center; clear: both;'}, + link ? m('a.chaptercomments', {href: link + '#comment_list'}, 'Read chapter comments online') : null, + linkNotes ? m('a.chaptercomments', {href: linkNotes}, 'Read author\'s note') : null + ) ]) ]) )) @@ -114,6 +115,23 @@ export function createOpf (ffc) { remotes.push(m('item', {id: r.filename, href: r.dest, 'media-type': r.type})) }) + let manifestChapters = ffc.storyInfo.chapters.map((ch, num) => + m('item', {id: 'chapter_' + zeroFill(3, num + 1), href: 'Text/chapter_' + zeroFill(3, num + 1) + '.xhtml', 'media-type': 'application/xhtml+xml', properties: ch.remote ? 'remote-resources' : null}) + ) + let spineChapters = ffc.storyInfo.chapters.map((ch, num) => + m('itemref', {idref: 'chapter_' + zeroFill(3, num + 1)}) + ) + let manifestNotes = [] + let spineNotes = [] + if (ffc.options.includeAuthorNotes && ffc.options.useAuthorNotesIndex && ffc.hasAuthorNotes) { + spineNotes.push(m('itemref', {idref: 'notesnav'})) + ffc.chaptersWithNotes.forEach((num) => { + let id = 'note_' + zeroFill(3, num + 1) + manifestNotes.push(m('item', {id: id, href: 'Text/' + id + '.xhtml', 'media-type': 'application/xhtml+xml'})) + spineNotes.push(m('itemref', {idref: id})) + }) + } + let subjects = ffc.subjects if (ffc.options.joinSubjects) { subjects = [subjects.join(', ')] @@ -128,7 +146,7 @@ export function createOpf (ffc) { m('meta', {refines: '#cre', property: 'role', scheme: 'marc:relators'}, 'aut'), m('dc:date', new Date((ffc.storyInfo.publishDate || ffc.storyInfo.date_modified) * 1000).toISOString().substring(0, 10)), m('dc:publisher', 'Fimfiction'), - m('dc:description', ffc.storyInfo.short_description || ffc.storyInfo.description), + m('dc:description', ffc.storyInfo.short_description), m('dc:source', ffc.storyInfo.url), m('dc:language', 'en'), ffc.coverImage ? m('meta', {name: 'cover', content: 'cover'}) : null, @@ -141,6 +159,7 @@ export function createOpf (ffc) { ffc.coverImage ? m('item', {id: 'cover', href: ffc.coverFilename, 'media-type': ffc.coverType, properties: 'cover-image'}) : null, m('item', {id: 'ncx', href: 'toc.ncx', 'media-type': 'application/x-dtbncx+xml'}), m('item', {id: 'nav', 'href': 'Text/nav.xhtml', 'media-type': 'application/xhtml+xml', properties: 'nav'}), + ffc.options.includeAuthorNotes && ffc.options.useAuthorNotesIndex ? m('item', {id: 'notesnav', 'href': 'Text/notesnav.xhtml', 'media-type': 'application/xhtml+xml'}) : null, m('item', {id: 'style', href: 'Styles/style.css', 'media-type': 'text/css'}), m('item', {id: 'coverstyle', href: 'Styles/coverstyle.css', 'media-type': 'text/css'}), @@ -149,18 +168,16 @@ export function createOpf (ffc) { m('item', {id: 'coverpage', href: 'Text/cover.xhtml', 'media-type': 'application/xhtml+xml', properties: ffc.coverImage ? 'svg' : undefined}), m('item', {id: 'titlepage', href: 'Text/title.xhtml', 'media-type': 'application/xhtml+xml', properties: ffc.hasRemoteResources.titlePage ? 'remote-resources' : null}) - ].concat(ffc.storyInfo.chapters.map((ch, num) => - m('item', {id: 'chapter_' + zeroFill(3, num + 1), href: 'Text/chapter_' + zeroFill(3, num + 1) + '.xhtml', 'media-type': 'application/xhtml+xml', properties: ch.remote ? 'remote-resources' : null}) - ), remotes)), + ].concat(manifestChapters, manifestNotes, remotes)), m('spine', {toc: 'ncx'}, sortSpineItems([ m('itemref', {idref: 'coverpage'}), m('itemref', {idref: 'titlepage'}), - m('itemref', {idref: 'nav', linear: ffc.storyInfo.chapters.length <= 1 ? 'no' : undefined}) - ].concat(ffc.storyInfo.chapters.map((ch, num) => - m('itemref', {idref: 'chapter_' + zeroFill(3, num + 1)}) - )))), - + m('itemref', {idref: 'nav', linear: ffc.storyInfo.chapters.length <= 1 && !(ffc.options.includeAuthorNotes && ffc.options.useAuthorNotesIndex && ffc.hasAuthorNotes) ? 'no' : undefined}) + ].concat( + spineChapters, + spineNotes + ))), m('guide', [ m('reference', {type: 'cover', title: 'Cover', href: 'Text/cover.xhtml'}), m('reference', {type: 'toc', title: 'Contents', href: 'Text/nav.xhtml'}) @@ -174,7 +191,7 @@ export function createOpf (ffc) { function navPoints (list) { let arr = [] for (let i = 0; i < list.length; i++) { - list[i] + if (!list[i]) continue arr.push(m('navPoint', {id: 'navPoint-' + (i + 1), playOrder: i + 1}, [ m('navLabel', m('text', list[i][0])), m('content', {src: list[i][1]}) @@ -182,7 +199,6 @@ function navPoints (list) { } return arr } - export function createNcx (ffc) { let tocNcx = '\n' + pretty.xml(render( m('ncx', {version: '2005-1', xmlns: NS.DAISY}, [ @@ -197,33 +213,50 @@ export function createNcx (ffc) { ['Cover', 'Text/cover.xhtml'] ].concat(ffc.storyInfo.chapters.map((ch, num) => [ch.title, 'Text/chapter_' + zeroFill(3, num + 1) + '.xhtml'] - )))) + ), ffc.options.includeAuthorNotes && ffc.options.useAuthorNotesIndex && ffc.hasAuthorNotes ? [['Author\'s Notes', 'Text/notesnav.xhtml']] : null))) ]) )) // console.log(tocNcx) return tocNcx } -export function createNav (ffc) { +export function createNav (ffc, mode = 0) { + let title, list + switch (mode) { + case 0: + title = 'Contents' + list = [m('li', {hidden: ''}, m('a', {href: 'cover.xhtml'}, 'Cover'))] + .concat(ffc.storyInfo.chapters.map((ch, num) => + m('li', [ + m('a.leftalign', {href: 'chapter_' + zeroFill(3, num + 1) + '.xhtml'}, ch.title) + // m('span.date', [m('b', ' · '), prettyDate(new Date(ch.date_modified * 1000)), m('span', {style: 'display: none'}, ' · ')]), + // m('.floatbox', m('span.wordcount', ch.realWordCount.toLocaleString('en-GB'))) + ]) + )) + if (ffc.options.includeAuthorNotes && ffc.options.useAuthorNotesIndex && ffc.hasAuthorNotes) { + list.push(m('li', m('a.leftalign', {href: 'notesnav.xhtml'}, 'Author\'s Notes'))) + } + break + case 1: + title = 'Author\'s Notes' + list = ffc.chaptersWithNotes.map((num) => { + let ch = ffc.storyInfo.chapters[num] + return m('li', m('a.leftalign', {href: 'note_' + zeroFill(3, num + 1) + '.xhtml'}, ch.title)) + }) + break + } + let navDocument = '\n\n' + pretty.xml(render( m('html', {xmlns: NS.XHTML, 'xmlns:epub': NS.OPS}, [ m('head', [ m('meta', {charset: 'utf-8'}), m('link', {rel: 'stylesheet', type: 'text/css', href: '../Styles/style.css'}), - m('title', 'Contents') + m('title', title) ]), m('body#navpage', [ - m('nav#toc', {'epub:type': 'toc'}, [ - m('h3', 'Contents'), - m('ol', [ - m('li', {hidden: ''}, m('a', {href: 'cover.xhtml'}, 'Cover')) - ].concat(ffc.storyInfo.chapters.map((ch, num) => - m('li', [ - m('a.leftalign', {href: 'chapter_' + zeroFill(3, num + 1) + '.xhtml'}, ch.title) - // m('span.date', [m('b', ' · '), prettyDate(new Date(ch.date_modified * 1000)), m('span', {style: 'display: none'}, ' · ')]), - // m('.floatbox', m('span.wordcount', ch.realWordCount.toLocaleString('en-GB'))) - ]) - ))) + m('nav#toc', mode === 0 ? {'epub:type': 'toc'} : null, [ + m('h3', title), + m('ol', list) ]) ]) ])