mirror of
https://github.com/daniel-j/fimfic2epub.git
synced 2024-05-24 14:21:01 +12:00
moved code, fixed bugs, added title page. can run with node
This commit is contained in:
parent
99c62e8b9f
commit
f833379e68
19
bin/fimfic2epub.js
Executable file
19
bin/fimfic2epub.js
Executable 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()
|
|
@ -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
389
src/FimFic2Epub.js
Normal 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
47
src/cleanMarkup.js
Normal 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
|
||||
}
|
|
@ -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
88
src/fetchRemote.js
Normal 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)
|
||||
})
|
||||
}
|
243
src/main.js
243
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)
|
||||
})
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
|
@ -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
25
src/styles.js
Normal 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 }
|
205
src/templates.js
205
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('<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
215
src/titlestyle.styl
Normal 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;
|
||||
}
|
Loading…
Reference in a new issue