diff --git a/bin/fimfic2epub.js b/bin/fimfic2epub.js new file mode 100755 index 0000000..82d6be4 --- /dev/null +++ b/bin/fimfic2epub.js @@ -0,0 +1,19 @@ +#!/usr/bin/env node + +require("babel-register") + +// have to load these from the outside so webpack doesn't try to include them +process.fs = require('fs') +process.path = require('path') +process.request = require('request') +process.stylus = require('stylus') +process.tidy = require('tidy-html5').tidy_html5 +process.sizeOf = require('image-size') + +const FimFic2Epub = require('../src/FimFic2Epub').default + +const STORY_ID = process.argv[2] + +const ffc = new FimFic2Epub(STORY_ID) + +ffc.download() diff --git a/package.json b/package.json index f41556f..7dd1dfc 100644 --- a/package.json +++ b/package.json @@ -5,9 +5,13 @@ "description": "", "author": "djazz", "scripts": { - "build": "gulp -p" + "build": "gulp -p", + }, + "bin": { + "fimfic2epub": "./bin/fimfic2epub.js" }, "dependencies": { + "detect-node": "^2.0.3", "escape-string-regexp": "^1.0.5", "file-saver": "^1.3.2", "html-entities": "^1.2.0", @@ -30,8 +34,10 @@ "gulp-util": "^3.0.7", "gulp-watch": "^4.3.8", "gulp-zip": "^3.2.0", + "image-size": "^0.5.0", "lazypipe": "^1.0.1", "raw-loader": "^0.5.1", + "request": "^2.72.0", "run-sequence": "^1.2.1", "standard": "^7.1.2", "stylus": "^0.54.5", diff --git a/src/FimFic2Epub.js b/src/FimFic2Epub.js new file mode 100644 index 0000000..c1083d4 --- /dev/null +++ b/src/FimFic2Epub.js @@ -0,0 +1,389 @@ + +import JSZip from 'jszip' +import escapeStringRegexp from 'escape-string-regexp' +import { saveAs } from 'file-saver' +import zeroFill from 'zero-fill' +import { XmlEntities } from 'html-entities' + +import isNode from 'detect-node' + +import { styleCss, coverstyleCss, titlestyleCss } from './styles' + +import { cleanMarkup } from './cleanMarkup' +import fetchRemote from './fetchRemote' +import * as template from './templates' + +import { mimeMap, containerXml } from './constants' + +const entities = new XmlEntities() + +function blobToDataURL (blob, callback) { + let a = new FileReader() + a.onloadend = function (e) { callback(a.result) } + a.readAsDataURL(blob) +} + +export default class FimFic2Epub { + + constructor (storyId) { + this.storyId = storyId + this.isDownloading = false + this.zip = null + this.chapterContent = [] + this.remoteResources = new Map() + this.storyInfo = null + this.isDownloading = false + this.cachedBlob = null + this.hasCoverImage = false + this.includeTitlePage = true + this.categories = [] + this.tags = [] + } + + download () { + if (this.isDownloading) { + alert("Calm down, I'm working on it (it's processing)") + return + } + if (this.cachedBlob) { + this.saveStory() + return + } + this.build() + } + + build () { + this.isDownloading = true + + this.zip = new JSZip() + this.zip.file('mimetype', 'application/epub+zip') + this.zip.file('META-INF/container.xml', containerXml) + + console.log('Fetching story metadata...') + + fetchRemote('https://www.fimfiction.net/api/story.php?story=' + this.storyId, (raw, type) => { + let data + try { + data = JSON.parse(raw) + } catch (e) { + console.log('Unable to fetch story json') + return + } + if (data.error) { + console.error(data.error) + return + } + this.storyInfo = data.story + this.storyInfo.chapters = this.storyInfo.chapters || [] + this.storyInfo.uuid = 'urn:fimfiction:' + this.storyInfo.id + + this.zip.file('Styles/style.css', styleCss) + this.zip.file('Styles/coverstyle.css', coverstyleCss) + if (this.includeTitlePage) { + this.zip.file('Styles/titlestyle.css', titlestyleCss) + } + + this.zip.file('toc.ncx', template.createNcx(this)) + this.zip.file('Text/nav.xhtml', template.createNav(this)) + + this.fetchTitlePage() + }) + } + + fetchTitlePage () { + fetchRemote(this.storyInfo.url, (raw, type) => { + this.extractTitlePageInfo(raw, () => this.checkCoverImage()) + }) + } + + extractTitlePageInfo (html, cb) { + let descPos = html.indexOf('
') + 2 + html = html.substring(descPos) + let ma = html.match(/Source<\/a>/) + this.storyInfo.source_image = null + if (ma) { + this.storyInfo.source_image = ma[1] + } + let endCatsPos = html.indexOf('
') + let startCatsPos = html.substring(0, endCatsPos).lastIndexOf('
') + let catsHtml = html.substring(startCatsPos, endCatsPos) + html = html.substring(endCatsPos + 6) + + let categories = [] + let matchCategory = /(.*?)<\/a>/g + for (let c; (c = matchCategory.exec(catsHtml));) { + categories.push({ + url: 'http://www.fimfiction.net' + c[1], + className: c[2], + name: entities.decode(c[3]) + }) + } + this.categories = categories + + ma = html.match(/This story is a sequel to (.*?)<\/a>/) + if (ma) { + this.storyInfo.prequel = { + url: 'http://www.fimfiction.net' + ma[1], + title: entities.decode(ma[2]) + } + html = html.substring(html.indexOf('
') + 6) + } + let endDescPos = html.indexOf('\n') + let description = html.substring(0, endDescPos).trim() + + html = html.substring(endDescPos + 7) + let extraPos = html.indexOf('
') + html = html.substring(extraPos + 30) + + ma = html.match(/First Published<\/span>
(.*?)<\/span>/) + if (ma) { + let date = ma[1] + date = date.replace(/^(\d+)[a-z]+? ([a-zA-Z]+? \d+)$/, '$1 $2') + this.storyInfo.publishDate = (new Date(date).getTime() / 1000) | 0 + } + + html = html.substring(0, html.indexOf('
<\/a>/g + for (let tag; (tag = matchTag.exec(html));) { + let t = { + url: 'http://www.fimfiction.net/tag/' + tag[1], + name: entities.decode(tag[2]), + image: entities.decode(tag[3]) + } + tags.push(t) + tags.byImage[t.image] = t + if (this.includeTitlePage) { + this.remoteResources.set(t.image, {filename: 'tag_' + tag[1], originalUrl: t.image, where: ['tags']}) + } + } + this.tags = tags + + cleanMarkup(description, (html) => { + this.storyInfo.description = html + this.findRemoteResources('description', 'description', html) + cb() + }) + } + + checkCoverImage () { + this.hasCoverImage = !!this.storyInfo.full_image + + if (this.hasCoverImage) { + this.remoteResources.set(this.storyInfo.full_image, {filename: 'cover', where: ['cover']}) + + if (!isNode) { + let coverImage = new Image() + coverImage.src = this.storyInfo.full_image + + coverImage.addEventListener('load', () => { + this.processStory(coverImage) + }, false) + } else { + fetchRemote(this.storyInfo.full_image, (data, type) => { + this.processStory(process.sizeOf(data)) + }, 'buffer') + } + } else { + this.processStory() + } + } + + processStory (coverImage) { + console.log('Fetching chapters...') + + this.fetchChapters(() => { + this.fetchRemoteFiles(() => { + let coverFilename = '' + this.remoteResources.forEach((r, url) => { + let dest = '../' + r.dest + if (r.dest && r.originalUrl && r.where) { + 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.chapterContent[w] = this.chapterContent[w].replace(ourl, dest) + } else if (w === 'description') { + this.storyInfo.description = this.storyInfo.description.replace(ourl, dest) + } else if (w === 'tags') { + this.tags.byImage[r.originalUrl].image = dest + } + } + } + if (r.filename === 'cover' && r.dest) { + coverFilename = dest + } + }) + + for (let num = 0; num < this.chapterContent.length; num++) { + let html = this.chapterContent[num] + let filename = 'Text/chapter_' + zeroFill(3, num + 1) + '.xhtml' + this.zip.file(filename, html) + } + + this.chapterContent.length = 0 + + if (this.includeTitlePage) { + this.zip.file('Text/title.xhtml', template.createTitlePage(this)) + } + + if (this.hasCoverImage) { + this.zip.file('Text/cover.xhtml', template.createCoverPage(coverFilename, coverImage.width, coverImage.height)) + } else { + this.zip.file('Text/cover.xhtml', template.createCoverPage(this)) + } + + this.zip.file('content.opf', template.createOpf(this)) + + console.log('Packaging epub...') + + if (!isNode) { + this.zip + .generateAsync({ + type: 'blob', + mimeType: 'application/epub+zip', + compression: 'DEFLATE', + compressionOptions: {level: 9} + }) + .then((blob) => { + this.cachedBlob = blob + this.isDownloading = false + this.saveStory() + }) + } else { + this.zip + .generateNodeStream({ + type: 'nodebuffer', + streamFiles: true, + mimeType: 'application/epub+zip', + compression: 'DEFLATE', + compressionOptions: {level: 9} + }) + .pipe(process.fs.createWriteStream(this.storyInfo.title + ' by ' + this.storyInfo.author.name + '.epub')) + .on('finish', () => { + console.log('Saved epub') + }) + } + }) + }) + } + + fetchRemoteFiles (cb) { + let iter = this.remoteResources.entries() + let count = 0 + let completeCount = 0 + + let recursive = () => { + let r = iter.next().value + if (!r) { + if (completeCount === this.remoteResources.size) { + cb() + } + return + } + let url = r[0] + r = r[1] + + console.log('Fetching remote file ' + (count + 1) + ' of ' + this.remoteResources.size + ': ' + r.filename, url) + count++ + + fetchRemote(url, (data, type) => { + r.dest = null + r.type = type + let dest = mimeMap[type] + + if (dest) { + r.dest = dest.replace('*', r.filename) + this.zip.file(r.dest, data) + } + completeCount++ + recursive() + }, 'arraybuffer') + } + + // concurrent downloads! + recursive() + recursive() + recursive() + recursive() + } + + fetchChapters (cb) { + let chapters = this.storyInfo.chapters + let chapterCount = this.storyInfo.chapters.length + let currentChapter = 0 + let completeCount = 0 + + if (chapterCount === 0) { + cb() + return + } + + let recursive = () => { + let index = currentChapter++ + let ch = chapters[index] + if (!ch) { + return + } + console.log('Fetching chapter ' + (index + 1) + ' of ' + chapters.length + ': ' + ch.title) + fetchRemote(ch.link.replace('http', 'https'), (html) => { + template.createChapter(ch, html, (html) => { + this.findRemoteResources('ch_' + zeroFill(3, index + 1), index, html) + this.chapterContent[index] = html + completeCount++ + if (completeCount < chapterCount) { + recursive() + } else { + cb() + } + }) + }) + } + + // concurrent downloads! + recursive() + recursive() + recursive() + recursive() + } + + findRemoteResources (prefix, where, html) { + let remoteCounter = 1 + let matchUrl = /]*\/([^">]*?))".*?>/g + let emoticonUrl = /static\.fimfiction\.net\/images\/emoticons\/([a-z_]*)\.[a-z]*$/ + + for (let ma; (ma = matchUrl.exec(html));) { + let url = ma[1] + let cleanurl = decodeURI(entities.decode(url)) + if (this.remoteResources.has(cleanurl)) { + let r = this.remoteResources.get(cleanurl) + if (r.where.indexOf(where) === -1) { + r.where.push(where) + } + continue + } + let filename = prefix + '_' + remoteCounter + let emoticon = url.match(emoticonUrl) + if (emoticon) { + filename = 'emoticon_' + emoticon[1] + } + remoteCounter++ + this.remoteResources.set(cleanurl, {filename: filename, where: [where], originalUrl: url}) + } + } + + saveStory () { + console.log('Saving epub...') + if (typeof safari !== 'undefined') { + blobToDataURL(this.cachedBlob, (dataurl) => { + document.location.href = dataurl + alert('Rename downloaded file to .epub') + }) + } else { + saveAs(this.cachedBlob, this.storyInfo.title + ' by ' + this.storyInfo.author.name + '.epub') + } + } +} diff --git a/src/cleanMarkup.js b/src/cleanMarkup.js new file mode 100644 index 0000000..006636b --- /dev/null +++ b/src/cleanMarkup.js @@ -0,0 +1,47 @@ + +import m from 'mithril' +import render from './lib/mithril-node-render' +import isNode from 'detect-node' + +let tidy +if (!isNode) { + tidy = require('exports?tidy_html5!tidy-html5') +} else { + tidy = process.tidy +} + +import { tidyOptions } from './constants' + +export function cleanMarkup (html, callback) { + // fix center tags + html = html.replace(/
/g, '

') + html = html.replace(/<\/center>/g, '

') + + html = html.replace(/
(.+?)<\/div>/g, (match, contents) => { + // console.log(match, contents) + let youtubeId = contents.match(/src="https:\/\/www.youtube.com\/embed\/(.+?)"/)[1] + let thumbnail = 'http://img.youtube.com/vi/' + youtubeId + '/hqdefault.jpg' + let youtubeUrl = 'https://youtube.com/watch?v=' + youtubeId + return render(m('a', {href: youtubeUrl, target: '_blank'}, + m('img', {src: thumbnail, alt: 'Youtube Video'}) + )) + }) + + html = html.replace('
', '
') + html = html.replace('
', '
') + + html = fixDoubleSpacing(html) + + html = tidy(`\n` + html, tidyOptions) + + callback(html) +} + +export function fixDoubleSpacing (html) { + // from FimFictionConverter by Nyerguds + html = html.replace(/\s\s+/g, ' ') + // push spaces to the closed side of tags + html = html.replace(/\s+(<[a-z][^>]*>)\s+/g, ' $1') + html = html.replace(/\s+(<\/[a-z][^>]*>)\s+/g, '$1 ') + return html +} diff --git a/src/constants.js b/src/constants.js index b6c335c..edf6e57 100644 --- a/src/constants.js +++ b/src/constants.js @@ -18,7 +18,8 @@ export let tidyOptions = { 'quiet': 'yes', 'show-warnings': 0, 'newline': 'LF', - 'tidy-mark': 'no' + 'tidy-mark': 'no', + 'show-body-only': 'auto' } export let mimeMap = { diff --git a/src/fetchRemote.js b/src/fetchRemote.js new file mode 100644 index 0000000..28e3e41 --- /dev/null +++ b/src/fetchRemote.js @@ -0,0 +1,88 @@ +/* global chrome, safari */ + +import fetch from './fetch' +import isNode from 'detect-node' + +const safariQueue = {} + +// messaging with the safari extension global page +function safariHandler (ev) { + let type = ev.message.type + let url = ev.message.input + let data = ev.message.output // arraybuffer + if (!safariQueue[url]) { + // console.error("Unable to get callback for " + url, JSON.stringify(safariQueue)) + return + } + let cb = safariQueue[url].cb + let responseType = safariQueue[url].responseType + console.log(url, cb, responseType, data) + delete safariQueue[url] + + if (responseType === 'blob') { + let blob = new Blob([data], {type: type}) + cb(blob, type) + } else { + if (!responseType) { + let blob = new Blob([data], {type: type}) + let fr = new FileReader() + fr.onloadend = function () { + cb(fr.result, type) + } + fr.readAsText(blob) + } else { + cb(data, type) + } + } +} +if (typeof safari !== 'undefined') { + safari.self.addEventListener('message', safariHandler, false) +} + +function fetchBackground (url, cb, responseType) { + if (typeof chrome !== 'undefined' && chrome.runtime.sendMessage) { + chrome.runtime.sendMessage(url, function (objurl) { + fetch(objurl, cb, responseType) + URL.revokeObjectURL(objurl) + }) + } else { + safariQueue[url] = {cb: cb, responseType: responseType} + safari.self.tab.dispatchMessage('remote', url) + } +} + +export default function fetchRemote (url, cb, responseType) { + if (url.indexOf('//') === 0) { + url = 'http:' + url + } + if (isNode) { + fetchNode(url, cb, responseType) + return + } + if (document.location.protocol === 'https:' && url.indexOf('http:') === 0) { + fetchBackground(url, cb, responseType) + return + } + fetch(url, (data, type) => { + if (!data) { + fetchBackground(url, cb, responseType) + } else { + cb(data, type) + } + }, responseType) +} + +function fetchNode (url, cb, responseType) { + process.request({ + url: url, + encoding: responseType ? null : 'utf8' + }, (error, response, body) => { + if (error) { + console.error(error) + cb() + return + } + let type = response.headers['content-type'] + cb(body, type) + }) +} diff --git a/src/main.js b/src/main.js index 2ef6111..da29e81 100644 --- a/src/main.js +++ b/src/main.js @@ -1,250 +1,15 @@ -/* global chrome, safari */ -import JSZip from 'jszip' -import escapeStringRegexp from 'escape-string-regexp' -import { saveAs } from 'file-saver' -import zeroFill from 'zero-fill' - -import styleCss from './style' -import coverstyleCss from './coverstyle' - -import fetch from './fetch' -import parseChapter from './parseChapter' -import * as template from './templates' -import { mimeMap, containerXml } from './constants' +import FimFic2Epub from './FimFic2Epub' const STORY_ID = document.location.pathname.match(/^\/story\/(\d*)/)[1] -let storyInfo -let remoteResources = new Map() -let chapterContent = [] -let safariQueue = {} +const ffc = new FimFic2Epub(STORY_ID) -let epubButton = document.querySelector('.story_container ul.chapters li.bottom a[title="Download Story (.epub)"]') -let isDownloading = false -let cachedBlob = null +const epubButton = document.querySelector('.story_container ul.chapters li.bottom a[title="Download Story (.epub)"]') if (epubButton) { epubButton.addEventListener('click', function (e) { e.preventDefault() - if (isDownloading) { - alert("Calm down, I'm working on it (it's processing)") - return - } - if (cachedBlob) { - saveStory() - return - } - downloadStory() + ffc.download() }, false) } - -function blobToDataURL (blob, callback) { - let a = new FileReader() - a.onloadend = function (e) { callback(a.result) } - a.readAsDataURL(blob) -} - -function saveStory () { - console.log('Saving epub...') - if (typeof safari !== 'undefined') { - blobToDataURL(cachedBlob, (dataurl) => { - document.location.href = dataurl - alert('Rename downloaded file to .epub') - }) - } else { - saveAs(cachedBlob, storyInfo.title + ' by ' + storyInfo.author.name + '.epub') - } -} - -// messaging with the safari extension global page -function safariHandler (ev) { - let type = ev.message.type - let url = ev.message.input - let data = ev.message.output // arraybuffer - if (!safariQueue[url]) { - // console.error("Unable to get callback for " + url, JSON.stringify(safariQueue)) - return - } - let cb = safariQueue[url].cb - let responseType = safariQueue[url].responseType - console.log(url, cb, responseType, data) - delete safariQueue[url] - - if (responseType === 'blob') { - let blob = new Blob([data], {type: type}) - cb(blob, type) - } else { - if (!responseType) { - let blob = new Blob([data], {type: type}) - let fr = new FileReader() - fr.onloadend = function () { - cb(fr.result, type) - } - fr.readAsText(blob) - } else { - cb(data, type) - } - } -} -if (typeof safari !== 'undefined') { - safari.self.addEventListener('message', safariHandler, false) -} - -function fetchBackground (url, cb, responseType) { - if (typeof chrome !== 'undefined' && chrome.runtime.sendMessage) { - chrome.runtime.sendMessage(url, function (objurl) { - fetch(objurl, cb, responseType) - URL.revokeObjectURL(objurl) - }) - } else { - safariQueue[url] = {cb: cb, responseType: responseType} - safari.self.tab.dispatchMessage('remote', url) - } -} - -function fetchRemote (url, cb, responseType) { - if (url.indexOf('//') === 0) { - url = 'http:' + url - } - if (document.location.protocol === 'https:' && url.indexOf('http:') === 0) { - fetchBackground(url, cb, responseType) - return - } - fetch(url, (data, type) => { - if (!data) { - fetchBackground(url, cb, responseType) - } else { - cb(data, type) - } - }, responseType) -} - -function fetchRemoteFiles (zip, cb) { - let iter = remoteResources.entries() - let counter = 0 - - function recursive () { - let r = iter.next().value - if (!r) { - cb() - return - } - let url = r[0] - r = r[1] - console.log('Fetching remote file ' + (counter + 1) + ' of ' + remoteResources.size + ': ' + r.filename, url) - fetchRemote(url, (data, type) => { - r.dest = null - r.type = type - let dest = mimeMap[type] - - if (dest) { - r.dest = dest.replace('*', r.filename) - zip.file(r.dest, data) - } - counter++ - recursive() - }, 'arraybuffer') - } - recursive() -} - -function fetchChapters (cb) { - let chapters = storyInfo.chapters - let chapterCount = storyInfo.chapters.length - let currentChapter = 0 - function recursive () { - let ch = chapters[currentChapter] - console.log('Fetching chapter ' + (currentChapter + 1) + ' of ' + chapters.length + ': ' + ch.title) - fetchRemote(ch.link.replace('http', 'https'), (html) => { - parseChapter(currentChapter, ch, html, remoteResources, (html) => { - chapterContent[currentChapter] = html - currentChapter++ - if (currentChapter < chapterCount) { - recursive() - } else { - cb() - } - }) - }) - } - recursive() -} - -function downloadStory () { - isDownloading = true - - const zip = new JSZip() - zip.file('mimetype', 'application/epub+zip') - zip.folder('META-INF').file('container.xml', containerXml) - - console.log('Fetching story metadata...') - - fetchRemote('https://www.fimfiction.net/api/story.php?story=' + STORY_ID, (raw, type) => { - let data - try { - data = JSON.parse(raw) - } catch (e) { - console.log('Unable to fetch story json') - return - } - storyInfo = data.story - storyInfo.uuid = 'urn:fimfiction:' + storyInfo.id - storyInfo.publishDate = '1970-01-01' // TODO! - - remoteResources.set(storyInfo.full_image, {filename: 'cover'}) - let coverImage = new Image() - coverImage.src = storyInfo.full_image - - zip.file('style.css', styleCss) - zip.file('coverstyle.css', coverstyleCss) - - coverImage.addEventListener('load', () => { - zip.file('toc.ncx', template.createNcx(storyInfo)) - zip.file('nav.xhtml', template.createNav(storyInfo)) - - fetchChapters(() => { - fetchRemoteFiles(zip, () => { - let coverFilename = '' - remoteResources.forEach((r, url) => { - if (typeof r.chapter !== 'undefined' && r.originalUrl && r.dest) { - chapterContent[r.chapter] = chapterContent[r.chapter].replace( - new RegExp(escapeStringRegexp(r.originalUrl), 'g'), - r.dest - ) - } - if (r.filename === 'cover') { - coverFilename = r.dest - } - }) - - for (let num = 0; num < chapterContent.length; num++) { - let html = chapterContent[num] - let filename = 'chapter_' + zeroFill(3, num + 1) + '.xhtml' - zip.file(filename, html) - } - - chapterContent.length = 0 - - zip.file('cover.xhtml', template.createCoverPage(coverFilename, coverImage.width, coverImage.height)) - zip.file('content.opf', template.createOpf(storyInfo, remoteResources)) - - console.log('Packaging epub...') - - zip - .generateAsync({ - type: 'blob', - mimeType: 'application/epub+zip', - compression: 'DEFLATE', - compressionOptions: {level: 9} - }) - .then((blob) => { - cachedBlob = blob - saveStory() - isDownloading = false - }) - }) - }) - }, false) - }) -} diff --git a/src/parseChapter.js b/src/parseChapter.js deleted file mode 100644 index 0087eec..0000000 --- a/src/parseChapter.js +++ /dev/null @@ -1,74 +0,0 @@ - -import m from 'mithril' -import render from './lib/mithril-node-render' -import { XmlEntities } from 'html-entities' -import tidy from 'exports?tidy_html5!tidy-html5' -import zeroFill from 'zero-fill' - -import { NS, tidyOptions } from './constants' - -const entities = new XmlEntities() - -export default function parseChapter (num, ch, html, remoteResources, callback) { - let chapterPos = html.indexOf('
') - let chapter = html.substring(chapterPos + 29) - - let pos = chapter.indexOf('\t
\t\t\n\t') - - let authorNotesPos = chapter.substring(pos).indexOf('Author\'s Note:') - let authorNotes = '' - if (authorNotesPos !== -1) { - authorNotes = chapter.substring(pos + authorNotesPos + 22) - authorNotes = authorNotes.substring(0, authorNotes.indexOf('\t\t\n\t
')) - } - - chapter = chapter.substring(0, pos) - - let chapterPage = '' + render( - m('html', {xmlns: NS.XHTML}, [ - m('head', [ - m('meta', {charset: 'utf-8'}), - m('link', {rel: 'stylesheet', type: 'text/css', href: 'style.css'}), - m('title', ch.title) - ]), - m('body', [ - m('div#chapter_container', m.trust(chapter)), - authorNotes ? m('div#author_notes', m.trust(authorNotes)) : null - ]) - ]) - ) - - chapterPage = chapterPage.replace(/
/g, '
') - chapterPage = chapterPage.replace(/<\/center>/g, '
') - - chapterPage = chapterPage.replace(/
(.+?)<\/div>/g, (match, contents) => { - // console.log(match, contents) - let youtubeId = contents.match(/src="https:\/\/www.youtube.com\/embed\/(.+?)"/)[1] - let thumbnail = 'http://img.youtube.com/vi/' + youtubeId + '/hqdefault.jpg' - let youtubeUrl = 'https://youtube.com/watch?v=' + youtubeId - return render(m('a', {href: youtubeUrl, target: '_blank'}, - m('img', {src: thumbnail, alt: 'Youtube Video'}) - )) - }) - - chapterPage = chapterPage.replace('
', '
') - chapterPage = chapterPage.replace('
', '
') - - chapterPage = tidy(`\n` + chapterPage, tidyOptions) - - let remoteCounter = 1 - let matchUrl = /]*>/g - - for (let ma; (ma = matchUrl.exec(chapterPage));) { - let url = ma[1] - let cleanurl = decodeURI(entities.decode(url)) - if (remoteResources.has(cleanurl)) { - continue - } - let filename = 'ch_' + zeroFill(3, num + 1) + '_' + remoteCounter - remoteCounter++ - remoteResources.set(cleanurl, {filename: filename, chapter: num, originalUrl: url}) - } - - callback(chapterPage) -} diff --git a/src/style.styl b/src/style.styl index 99153f1..c67b428 100644 --- a/src/style.styl +++ b/src/style.styl @@ -2,6 +2,7 @@ body { background-color: white; color: black; + text-align: justify; } p { @@ -30,10 +31,10 @@ img { } hr { - background-color: #ddd; - margin-top: 12px; - margin-bottom: 12px; - color: #ddd; + background-color: #999; + margin-top: 0.8em; + margin-bottom: 0.8em; + color: #999; height: 1px; border: 0px; } diff --git a/src/styles.js b/src/styles.js new file mode 100644 index 0000000..c53adf5 --- /dev/null +++ b/src/styles.js @@ -0,0 +1,25 @@ + +import isNode from 'detect-node' + +let styleCss, coverstyleCss, titlestyleCss + +if (!isNode) { + styleCss = require('./style') + coverstyleCss = require('./coverstyle') + titlestyleCss = require('./titlestyle') +} else { + process.stylus.render(process.fs.readFileSync(process.path.join(__dirname, './style.styl'), 'utf8'), (err, css) => { + if (err) throw err + styleCss = css + }) + process.stylus.render(process.fs.readFileSync(process.path.join(__dirname, './coverstyle.styl'), 'utf8'), (err, css) => { + if (err) throw err + coverstyleCss = css + }) + process.stylus.render(process.fs.readFileSync(process.path.join(__dirname, './titlestyle.styl'), 'utf8'), (err, css) => { + if (err) throw err + titlestyleCss = css + }) +} + +export { styleCss, coverstyleCss, titlestyleCss } diff --git a/src/templates.js b/src/templates.js index 6c576ef..7371d92 100644 --- a/src/templates.js +++ b/src/templates.js @@ -4,19 +4,70 @@ import render from './lib/mithril-node-render' import { pd as pretty } from 'pretty-data' import zeroFill from 'zero-fill' +import { cleanMarkup } from './cleanMarkup' import { NS } from './constants' -function subjects (s) { - let list = [] - for (let i = 0; i < s.length; i++) { - list.push(m('dc:subject', s[i])) +function nth (d) { + if (d > 3 && d < 21) return 'th' + switch (d % 10) { + case 1: return 'st' + case 2: return 'nd' + case 3: return 'rd' + default: return 'th' } - return list } -export function createOpf (storyInfo, remoteResources) { +function prettyDate (d) { + // format: 27th Oct 2011 + let months = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'] + return d.getDate() + nth(d) + ' ' + months[d.getMonth()].substring(0, 3) + ' ' + d.getFullYear() +} + +export function createChapter (ch, html, callback) { + let authorNotesPos = html.indexOf('
Author\'s Note:') + authorNotes = html.substring(authorNotesPos + 22) + authorNotes = authorNotes.substring(0, authorNotes.indexOf('\t\n\t
')) + authorNotes = authorNotes.trim() + } + + let chapterPos = html.indexOf('
') + let chapter = html.substring(chapterPos + 29) + + let pos = chapter.indexOf('\t
\t\t\n\t') + + chapter = chapter.substring(0, pos) + + let sections = [ + m('div#chapter_container', m.trust(chapter)), + authorNotes ? m('div#author_notes', {className: authorNotesPos < chapterPos ? 'top' : 'bottom'}, m.trust(authorNotes)) : null + ] + + if (authorNotes && authorNotesPos < chapterPos) { + sections.reverse() + } + + let chapterPage = '' + render( + m('html', {xmlns: NS.XHTML}, [ + m('head', [ + m('meta', {charset: 'utf-8'}), + m('link', {rel: 'stylesheet', type: 'text/css', href: '../Styles/style.css'}), + m('title', ch.title) + ]), + m('body', sections) + ]) + ) + + cleanMarkup(chapterPage, (html) => { + callback(html) + }) +} + +export function createOpf (ffc) { let remotes = [] - remoteResources.forEach((r, url) => { + ffc.remoteResources.forEach((r, url) => { if (!r.dest) { return } @@ -30,39 +81,47 @@ export function createOpf (storyInfo, remoteResources) { let contentOpf = '\n' + pretty.xml(render( m('package', {xmlns: NS.OPF, version: '3.0', 'unique-identifier': 'BookId'}, [ m('metadata', {'xmlns:dc': NS.DC, 'xmlns:opf': NS.OPF}, [ - m('dc:identifier#BookId', storyInfo.uuid), - m('dc:title', storyInfo.title), - m('dc:creator#cre', storyInfo.author.name), + m('dc:identifier#BookId', ffc.storyInfo.uuid), + m('dc:title', ffc.storyInfo.title), + m('dc:creator#cre', ffc.storyInfo.author.name), m('meta', {refines: '#cre', property: 'role', scheme: 'marc:relators'}, 'aut'), - m('dc:date', storyInfo.publishDate), + m('dc:date', new Date((ffc.storyInfo.publishDate || ffc.storyInfo.date_modified) * 1000).toISOString().substring(0, 10)), m('dc:publisher', 'Fimfiction'), - m('dc:description', storyInfo.description), - m('dc:source', storyInfo.url), + m('dc:description', ffc.storyInfo.description), + m('dc:source', ffc.storyInfo.url), m('dc:language', 'en'), m('meta', {name: 'cover', content: 'cover'}), - m('meta', {property: 'dcterms:modified'}, new Date(storyInfo.date_modified * 1000).toISOString().replace('.000', '')) - ].concat(subjects(['Pony']))), + m('meta', {property: 'dcterms:modified'}, new Date(ffc.storyInfo.date_modified * 1000).toISOString().replace('.000', '')) + ].concat(ffc.categories.map((tag) => + m('dc:subject', tag.name) + ))), m('manifest', [ m('item', {id: 'ncx', href: 'toc.ncx', 'media-type': 'application/x-dtbncx+xml'}), - m('item', {id: 'nav', 'href': 'nav.xhtml', 'media-type': 'application/xhtml+xml', properties: 'nav'}), - m('item', {id: 'style', href: 'style.css', 'media-type': 'text/css'}), - m('item', {id: 'coverstyle', href: 'coverstyle.css', 'media-type': 'text/css'}), - m('item', {id: 'coverpage', href: 'cover.xhtml', 'media-type': 'application/xhtml+xml', properties: 'svg'}) - ].concat(storyInfo.chapters.map((ch, num) => - m('item', {id: 'chapter_' + zeroFill(3, num + 1), href: 'chapter_' + zeroFill(3, num + 1) + '.xhtml', 'media-type': 'application/xhtml+xml'}) + m('item', {id: 'nav', 'href': 'Text/nav.xhtml', 'media-type': 'application/xhtml+xml', properties: 'nav'}), + m('item', {id: 'style', href: 'Styles/style.css', 'media-type': 'text/css'}), + m('item', {id: 'coverstyle', href: 'Styles/coverstyle.css', 'media-type': 'text/css'}), + ffc.includeTitlePage ? m('item', {id: 'titlestyle', href: 'Styles/titlestyle.css', 'media-type': 'text/css'}) : null, + + m('item', {id: 'coverpage', href: 'Text/cover.xhtml', 'media-type': 'application/xhtml+xml', properties: ffc.hasCoverImage ? 'svg' : undefined}), + ffc.includeTitlePage ? m('item', {id: 'titlepage', href: 'Text/title.xhtml', 'media-type': 'application/xhtml+xml'}) : 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'}) ), remotes)), m('spine', {toc: 'ncx'}, [ m('itemref', {idref: 'coverpage'}), - m('itemref', {idref: 'nav'}) - ].concat(storyInfo.chapters.map((ch, num) => + ffc.includeTitlePage ? m('itemref', {idref: 'titlepage'}) : null, + 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)}) ))), - false ? m('guide', [ - - ]) : null + m('guide', [ + m('reference', {type: 'cover', title: 'Cover', href: 'Text/cover.xhtml'}), + m('reference', {type: 'toc', title: 'Contents', href: 'Text/nav.xhtml'}) + ]) ]) )) // console.log(contentOpf) @@ -81,21 +140,20 @@ function navPoints (list) { return arr } -export function createNcx (storyInfo) { +export function createNcx (ffc) { let tocNcx = '\n' + pretty.xml(render( m('ncx', {version: '2005-1', xmlns: NS.DAISY}, [ m('head', [ - m('meta', {content: storyInfo.uuid, name: 'dtb:uid'}), + m('meta', {content: ffc.storyInfo.uuid, name: 'dtb:uid'}), m('meta', {content: 0, name: 'dtb:depth'}), m('meta', {content: 0, name: 'dtb:totalPageCount'}), m('meta', {content: 0, name: 'dtb:maxPageNumber'}) ]), - m('docTitle', m('text', storyInfo.title)), + m('docTitle', m('text', ffc.storyInfo.title)), m('navMap', navPoints([ - ['Cover', 'cover.xhtml'], - ['Contents', 'nav.xhtml'] - ].concat(storyInfo.chapters.map((ch, num) => - [ch.title, 'chapter_' + zeroFill(3, num + 1) + '.xhtml'] + ['Cover', 'Text/cover.xhtml'] + ].concat(ffc.storyInfo.chapters.map((ch, num) => + [ch.title, 'Text/chapter_' + zeroFill(3, num + 1) + '.xhtml'] )))) ]) )) @@ -103,21 +161,20 @@ export function createNcx (storyInfo) { return tocNcx } -export function createNav (storyInfo) { +export function createNav (ffc) { let navDocument = '\n\n' + pretty.xml(render( m('html', {xmlns: NS.XHTML, 'xmlns:epub': NS.OPS, lang: 'en', 'xml:lang': 'en'}, [ m('head', [ m('meta', {charset: 'utf-8'}), - m('link', {rel: 'stylesheet', type: 'text/css', href: 'style.css'}), + m('link', {rel: 'stylesheet', type: 'text/css', href: '../Styles/style.css'}), m('title', 'Contents') ]), - m('body', [ + m('body#navpage', [ m('nav#toc', {'epub:type': 'toc'}, [ m('h1', 'Contents'), m('ol', [ - m('li', {hidden: ''}, m('a', {href: 'cover.xhtml'}, 'Cover')), - m('li', {hidden: ''}, m('a', {href: 'nav.xhtml'}, 'Contents')) - ].concat(storyInfo.chapters.map((ch, num) => + m('li', {hidden: ''}, m('a', {href: 'cover.xhtml'}, 'Cover')) + ].concat(ffc.storyInfo.chapters.map((ch, num) => m('li', m('a', {href: 'chapter_' + zeroFill(3, num + 1) + '.xhtml'}, ch.title)) ))) ]) @@ -129,20 +186,78 @@ export function createNav (storyInfo) { } export function createCoverPage (coverFilename, w, h) { + let body + + if (typeof coverFilename === 'string') { + body = m('svg#cover', {xmlns: NS.SVG, 'xmlns:xlink': NS.XLINK, version: '1.1', viewBox: '0 0 ' + w + ' ' + h}, + m('image', {width: w, height: h, 'xlink:href': coverFilename}) + ) + } else { + let ffc = coverFilename + body = [ + m('h1', ffc.storyInfo.title), + m('h2', ffc.storyInfo.author.name) + ] + } + let coverPage = '\n\n' + pretty.xml(render( m('html', {xmlns: NS.XHTML, 'xmlns:epub': NS.OPS}, [ m('head', [ m('meta', {name: 'viewport', content: 'width=' + w + ', height=' + h}), m('title', 'Cover'), - m('link', {rel: 'stylesheet', type: 'text/css', href: 'coverstyle.css'}) + m('link', {rel: 'stylesheet', type: 'text/css', href: '../Styles/coverstyle.css'}) ]), - m('body', {'epub:type': 'cover'}, [ - m('svg#cover', {xmlns: NS.SVG, 'xmlns:xlink': NS.XLINK, version: '1.1', viewBox: '0 0 ' + w + ' ' + h}, - m('image', {width: w, height: h, 'xlink:href': coverFilename}) - ) - ]) + m('body', {'epub:type': 'cover'}, body) ]) )) // console.log(coverPage) return coverPage } + +function dateBox (heading, date) { + return m('.datebox', m('.wrap', [ + m('span.heading', heading), + m('br'), + m('span.date', prettyDate(date)) + ])) +} + +export function createTitlePage (ffc) { + let titlePage = '\n\n' + pretty.xml(render( + m('html', {xmlns: NS.XHTML, 'xmlns:epub': NS.OPS, lang: 'en', 'xml:lang': 'en'}, [ + m('head', [ + m('meta', {charset: 'utf-8'}), + m('link', {rel: 'stylesheet', type: 'text/css', href: '../Styles/style.css'}), + m('link', {rel: 'stylesheet', type: 'text/css', href: '../Styles/titlestyle.css'}), + m('title', ffc.storyInfo.title) + ]), + m('body#titlepage', [ + m('h1', ffc.storyInfo.title), + m('h2', ffc.storyInfo.author.name), + m('a', {href: ffc.storyInfo.url}, 'Read on Fimfiction'), + m('hr'), + ffc.categories.length > 0 ? [ + m('div#categories', ffc.categories.map((tag) => + m('div', {className: tag.className}, tag.name) + )), + m('hr') + ] : null, + ffc.storyInfo.prequel ? [m('div', [ + 'This story is a sequel to ', + m('a', {href: ffc.storyInfo.prequel.url}, ffc.storyInfo.prequel.title) + ]), m('hr')] : null, + m('div#description', m.trust(ffc.storyInfo.description)), + m('hr'), + m('.extra_story_data', [ + ffc.storyInfo.publishDate && dateBox('First Published', new Date(ffc.storyInfo.publishDate * 1000)), + dateBox('Last Modified', new Date(ffc.storyInfo.date_modified * 1000)), + ffc.tags.map((t) => + m('span', {className: 'character_icon', title: t.name}, m('img', {src: t.image, className: 'character_icon'})) + ) + ]) + ]) + ]) + )) + // console.log(titlePage) + return titlePage +} diff --git a/src/titlestyle.styl b/src/titlestyle.styl new file mode 100644 index 0000000..701be49 --- /dev/null +++ b/src/titlestyle.styl @@ -0,0 +1,215 @@ + +.datebox { + line-height: 38px; + color: #333; + padding-left: 10px; + font-size: 0.75em; + font-weight: bold; + display: inline-block; + padding: 0px 10px; + border-radius: 3px; + background-color: #fff; + vertical-align: middle; + border: 1px solid #999; + margin: 0px 2px; + + .heading, .date { + font-family: sans-serif; + } + + .wrap { + line-height: 1.2em; + vertical-align: middle; + display: inline-block; + text-align: center; + } + + .heading { + font-weight: normal; + color: #555; + } +} + +span.character_icon { + vertical-align: middle; + margin: 0 2px; + padding: 3px; + background: #fff; + border-radius: 3px; + display: inline-block; + vertical-align: middle; + border: 1px solid #999; + + img.character_icon { + border-radius: 0; + display: block; + } +} + +// Ratings +.content-rating-everyone, .content-rating-teen, .content-rating-mature { + padding: 4px 6px; + vertical-align: 6px; + color: #fff; + border: 1px solid rgba(0,0,0,0.2); + text-shadow: -1px -1px rgba(0,0,0,0.2); + border-radius: 4px; + font-size: 12px; + margin-right: 5px; + display: inline-block; + line-height: 16px; + font-family: sans-serif; +} +.content-rating-everyone { + background: #78ac40; + box-shadow: 0px 1px 0px #90ce4d inset; +} +.content-rating-teen { + background: #ffb400; + box-shadow: 0px 1px 0px #ffd800 inset; +} +.content-rating-mature { + background: #c03d2f; + box-shadow: 0px 1px 0px #e64938 inset; +} + +// Categories +.story_category { + display: inline-block; + padding: 8px 12px; + line-height: 1.0em; + color: #555; + text-decoration: none; + background-color: #eee; + border-radius: 4px; + margin-bottom: 4px; + color: #fff; + font-family: sans-serif; +} + +.story_category_sex { + background-color: #992584; + box-shadow: 0px 1px 0px #b82c9e inset; + text-shadow: -1px -1px #7a1e6a; + border: 1px solid #821f70; +} +.story_category_gore { + background-color: #742828; + box-shadow: 0px 1px 0px #8b3030 inset; + text-shadow: -1px -1px #5d2020; + border: 1px solid #632222; +} +.story_category_romance { + background-color: #974bff; + box-shadow: 0px 1px 0px #b55aff inset; + text-shadow: -1px -1px #793ccc; + border: 1px solid #8040d9; +} +.story_category_dark { + background-color: #b93737; + box-shadow: 0px 1px 0px #de4242 inset; + text-shadow: -1px -1px #942c2c; + border: 1px solid #9d2f2f; +} +.story_category_sad { + background-color: #bd42a7; + box-shadow: 0px 1px 0px #e34fc8 inset; + text-shadow: -1px -1px #973586; + border: 1px solid #a1388e; +} +.story_category_tragedy { + background-color: #ffb54b; + box-shadow: 0px 1px 0px #ffd95a inset; + text-shadow: -1px -1px #cc913c; + border: 1px solid #d99a40; +} +.story_category_comedy { + background-color: #f59c00; + box-shadow: 0px 1px 0px #fb0 inset; + text-shadow: -1px -1px #c47d00; + border: 1px solid #d08500; +} +.story_category_random { + background-color: #3f74ce; + box-shadow: 0px 1px 0px #4c8bf7 inset; + text-shadow: -1px -1px #325da5; + border: 1px solid #3663af; +} +.story_category_slice_of_life { + background-color: #4b86ff; + box-shadow: 0px 1px 0px #5aa1ff inset; + text-shadow: -1px -1px #3c6bcc; + border: 1px solid #4072d9; +} +.story_category_adventure { + background-color: #45c950; + box-shadow: 0px 1px 0px #53f160 inset; + text-shadow: -1px -1px #37a140; + border: 1px solid #3bab44; +} +.story_category_alternate_universe { + background-color: #888; + box-shadow: 0px 1px 0px #a3a3a3 inset; + text-shadow: -1px -1px #6d6d6d; + border: 1px solid #747474; +} +.story_category_crossover { + background-color: #47b8a0; + box-shadow: 0px 1px 0px #55ddc0 inset; + text-shadow: -1px -1px #399380; + border: 1px solid #3c9c88; +} +.story_category_human { + background-color: #b5835a; + box-shadow: 0px 1px 0px #d99d6c inset; + text-shadow: -1px -1px #916948; + border: 1px solid #9a6f4d; +} +.story_category_anthro { + background-color: #b5695a; + box-shadow: 0px 1px 0px #d97e6c inset; + text-shadow: -1px -1px #915448; + border: 1px solid #9a594d; +} +.story_category_scifi { + background-color: #5d63a5; + box-shadow: 0px 1px 0px #7077c6 inset; + text-shadow: -1px -1px #4a4f84; + border: 1px solid #4f548c; +} +.story_category_equestria_girls { + background-color: #4d3281; + box-shadow: 0px 1px 0px #5c3c9b inset; + text-shadow: -1px -1px #3e2867; + border: 1px solid #412b6e; +} +.story_category_horror { + background-color: #6d232f; + box-shadow: 0px 1px 0px #832a38 inset; + text-shadow: -1px -1px #571c26; + border: 1px solid #5d1e28; +} +.story_category_mystery { + background-color: #444; + box-shadow: 0px 1px 0px #525252 inset; + text-shadow: -1px -1px #363636; + border: 1px solid #3a3a3a; +} +.story_category_drama { + background-color: #ec50ca; + box-shadow: 0px 1px 0px #ff60f2 inset; + text-shadow: -1px -1px #bd40a2; + border: 1px solid #c944ac; +} +.story_category_thriller { + background-color: #d62b2b; + box-shadow: 0px 1px 0px #ff3434 inset; + text-shadow: -1px -1px #ab2222; + border: 1px solid #b62525; +} +.story_category_second_person { + background-color: #02a1db; + box-shadow: 0px 1px 0px #02c1ff inset; + text-shadow: -1px -1px #0281af; + border: 1px solid #0289ba; +}