moved code, fixed bugs, added title page. can run with node

This commit is contained in:
daniel-j 2016-06-27 23:19:01 +02:00
parent 99c62e8b9f
commit f833379e68
12 changed files with 961 additions and 364 deletions

19
bin/fimfic2epub.js Executable file
View file

@ -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()

View file

@ -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",

389
src/FimFic2Epub.js Normal file
View file

@ -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('<div class="description" id="description')
descPos = descPos + html.substring(descPos).indexOf('">') + 2
html = html.substring(descPos)
let ma = html.match(/<a href="(.*?)" class="source">Source<\/a>/)
this.storyInfo.source_image = null
if (ma) {
this.storyInfo.source_image = ma[1]
}
let endCatsPos = html.indexOf('<hr />')
let startCatsPos = html.substring(0, endCatsPos).lastIndexOf('</div>')
let catsHtml = html.substring(startCatsPos, endCatsPos)
html = html.substring(endCatsPos + 6)
let categories = []
let matchCategory = /<a href="(.*?)" class="(.*?)">(.*?)<\/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 href="([^"]*)">(.*?)<\/a>/)
if (ma) {
this.storyInfo.prequel = {
url: 'http://www.fimfiction.net' + ma[1],
title: entities.decode(ma[2])
}
html = html.substring(html.indexOf('<hr />') + 6)
}
let endDescPos = html.indexOf('</div>\n')
let description = html.substring(0, endDescPos).trim()
html = html.substring(endDescPos + 7)
let extraPos = html.indexOf('<div class="extra_story_data">')
html = html.substring(extraPos + 30)
ma = html.match(/<span class="published">First Published<\/span><br \/><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('<div class="button-group"'))
let tags = []
tags.byImage = {}
let matchTag = /<a href="\/tag\/(.*?)" class="character_icon" title="(.*?)" style=".*?"><img src="(.*?)" class="character_icon" \/><\/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 = /<img.*?src="([^">]*\/([^">]*?))".*?>/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')
}
}
}

47
src/cleanMarkup.js Normal file
View file

@ -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(/<center>/g, '<p style="text-align: center;">')
html = html.replace(/<\/center>/g, '</p>')
html = html.replace(/<div class="youtube_container">(.+?)<\/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('<blockquote style="margin: 10px 0px; box-sizing:border-box; -moz-box-sizing:border-box;margin-right:25px; padding: 15px;background-color: #F7F7F7;border: 1px solid #AAA;width: 50%;float:left;box-shadow: 5px 5px 0px #EEE;">', '<blockquote class="left_insert">')
html = html.replace('<blockquote style="margin: 10px 0px; box-sizing:border-box; -moz-box-sizing:border-box;margin-left:25px; padding: 15px;background-color: #F7F7F7;border: 1px solid #AAA;width: 50%;float:right;box-shadow: 5px 5px 0px #EEE;">', '<blockquote class="right_insert">')
html = fixDoubleSpacing(html)
html = tidy(`<?xml version="1.0" encoding="utf-8"?>\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
}

View file

@ -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 = {

88
src/fetchRemote.js Normal file
View file

@ -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)
})
}

View file

@ -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)
})
}

View file

@ -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('<div id="chapter_container">')
let chapter = html.substring(chapterPos + 29)
let pos = chapter.indexOf('\t</div>\t\t\n\t')
let authorNotesPos = chapter.substring(pos).indexOf('<b>Author\'s Note:</b>')
let authorNotes = ''
if (authorNotesPos !== -1) {
authorNotes = chapter.substring(pos + authorNotesPos + 22)
authorNotes = authorNotes.substring(0, authorNotes.indexOf('\t\t\n\t</div>'))
}
chapter = chapter.substring(0, pos)
let chapterPage = '<!doctype html>' + 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(/<center>/g, '<div style="text-align: center;">')
chapterPage = chapterPage.replace(/<\/center>/g, '</div>')
chapterPage = chapterPage.replace(/<div class="youtube_container">(.+?)<\/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('<blockquote style="margin: 10px 0px; box-sizing:border-box; -moz-box-sizing:border-box;margin-right:25px; padding: 15px;background-color: #F7F7F7;border: 1px solid #AAA;width: 50%;float:left;box-shadow: 5px 5px 0px #EEE;">', '<blockquote class="left_insert">')
chapterPage = chapterPage.replace('<blockquote style="margin: 10px 0px; box-sizing:border-box; -moz-box-sizing:border-box;margin-left:25px; padding: 15px;background-color: #F7F7F7;border: 1px solid #AAA;width: 50%;float:right;box-shadow: 5px 5px 0px #EEE;">', '<blockquote class="right_insert">')
chapterPage = tidy(`<?xml version="1.0" encoding="utf-8"?>\n` + chapterPage, tidyOptions)
let remoteCounter = 1
let matchUrl = /<img src="(.+?)"[^>]*>/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)
}

View file

@ -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;
}

25
src/styles.js Normal file
View file

@ -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 }

View file

@ -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('<div class="authors-note"')
let authorNotes = ''
if (authorNotesPos !== -1) {
authorNotesPos = authorNotesPos + html.substring(authorNotesPos).indexOf('<b>Author\'s Note:</b>')
authorNotes = html.substring(authorNotesPos + 22)
authorNotes = authorNotes.substring(0, authorNotes.indexOf('\t\n\t</div>'))
authorNotes = authorNotes.trim()
}
let chapterPos = html.indexOf('<div id="chapter_container">')
let chapter = html.substring(chapterPos + 29)
let pos = chapter.indexOf('\t</div>\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 = '<!doctype html>' + 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 = '<?xml version="1.0" encoding="utf-8"?>\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 = '<?xml version="1.0" encoding="utf-8" ?>\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 = '<?xml version="1.0" encoding="utf-8"?>\n<!DOCTYPE html>\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 = '<?xml version="1.0" encoding="utf-8"?>\n<!DOCTYPE html>\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 = '<?xml version="1.0" encoding="utf-8"?>\n<!DOCTYPE html>\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
}

215
src/titlestyle.styl Normal file
View file

@ -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;
}