Merge pull request #46 from EbookFoundation/only-opds

Only opds
pull/47/head
Theodore Kluge 2019-03-14 12:58:59 -04:00 committed by GitHub
commit f3f46ae0ad
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 122 additions and 86 deletions

View File

@ -6,7 +6,7 @@
*/
const HttpError = require('../errors/HttpError')
const { asyncRead, hmacSign } = require('../util')
const { hmacSign } = require('../util')
const request = require('request')
const uriRegex = /^(.+:\/\/)?(.+\.)*(.+\.).{1,}(:\d+)?(.+)?/i
@ -19,37 +19,37 @@ module.exports = {
if (!host) throw new HttpError(400, 'Missing hostname')
if (!body) throw new HttpError(400, 'Missing body')
if (!body.metadata) throw new HttpError(400, 'Missing OPDS metadata')
if (!body.metadata['@type'] || body.metadata['@type'] !== 'http://schema.org/Book') throw new HttpError(400, 'Invalid \'@type\': expected \'http://schema.org/Book\'')
const bookExists = await Book.findOne(body)
const query = {
hostname: host,
title: body.metadata.title,
author: body.metadata.author,
publisher: body.metadata.publisher,
identifier: body.metadata.identifier,
version: body.metadata.modified.replace(/\D/g, '')
}
const bookExists = await Book.findOne(query)
if (bookExists) {
throw new HttpError(400, 'Version already exists')
throw new HttpError(400, 'Ebook already exists')
} else {
const { title, isbn, author, publisher } = body
// require at least 2 fields to be filled out
if ([title, isbn, author, publisher].reduce((a, x) => a + (x ? 1 : 0), 0) >= 2) {
result = await Book.create(body).fetch()
const { publisher, title, author, identifier } = body.metadata
// require at least 3 fields to be filled out
if ([title, identifier, author, publisher].reduce((a, x) => a + (x ? 1 : 0), 0) >= 3) {
result = await Book.create({
...query,
opds: body
}).fetch()
} else {
throw new HttpError(400, 'Please fill out at least 2 fields (title, author, publisher, isbn)')
throw new HttpError(400, 'Please fill out at least 3 opds metadata fields (title, author, publisher, identifier)')
}
}
if (req.file('opds')) {
req.file('opds').upload(sails.config.skipperConfig, async function (err, uploaded) {
if (err) {
await Book.destroy({ id: result.id })
throw new HttpError(500, err.message)
}
const fd = (uploaded[0] || {}).fd
await Book.update({ id: result.id }, { storage: fd })
sendUpdatesAsync(result.id)
return res.json({
...result
})
})
} else {
throw new HttpError(400, 'Missing OPDS file upload')
}
sendUpdatesAsync(result)
return res.json(result)
} catch (e) {
if (e instanceof HttpError) return e.send(res)
return res.status(500).json({
@ -59,16 +59,16 @@ module.exports = {
}
}
async function sendUpdatesAsync (id) {
const book = await Book.findOne({ id })
async function sendUpdatesAsync (book) {
const id = book.id
const targets = await TargetUrl.find()
if (!book) return
for (const i in targets) {
try {
const item = targets[i]
const user = await User.findOne({ id: item.user })
const { author: fAuthor, publisher: fPublisher, title: fTitle, isbn: fIsbn, url } = item
const { author: bAuthor, publisher: bPublisher, title: bTitle, isbn: bIsbn } = book
const { author: fAuthor, publisher: fPublisher, title: fTitle, identifier: fIsbn, url } = item
const { author: bAuthor, publisher: bPublisher, title: bTitle, identifier: bIsbn, opds } = book
sails.log('sending ' + book.id + ' info to ' + url)
if (uriRegex.test(url)) {
@ -77,17 +77,7 @@ async function sendUpdatesAsync (id) {
if (fTitle && !((bTitle || '').includes(fTitle))) continue
if (fIsbn && !((bIsbn || '').includes(fIsbn))) continue
let content
const skipperConfig = sails.config.skipperConfig
const adapterConfig = { ...skipperConfig, adapter: undefined }
const skipperAdapter = skipperConfig.adapter(adapterConfig)
const opdsHelper = await sails.helpers.opds()
try {
if (!book.storage.length) throw new Error('missing book opds file')
content = await asyncRead(skipperAdapter, opdsHelper, book.storage)
} catch (e) {
content = await opdsHelper.book2opds(book)
}
let content = opds
const timestamp = Date.now()
request.post({
url: url,

View File

@ -1,5 +1,4 @@
const HttpError = require('../errors/HttpError')
const { asyncRead } = require('../util')
module.exports = {
navigation: async function (req, res) {
@ -49,19 +48,7 @@ module.exports = {
throw new HttpError(404, 'No books matching those parameters were found.')
}
const skipperConfig = sails.config.skipperConfig
const adapterConfig = { ...skipperConfig, adapter: undefined }
const skipperAdapter = skipperConfig.adapter(adapterConfig)
const opdsHelper = await sails.helpers.opds()
books = await Promise.all(books.map(book => {
try {
if (!book.storage.length) throw new Error('missing book opds file')
return asyncRead(skipperAdapter, opdsHelper, book.storage)
} catch (e) {
return opdsHelper.book2opds(book)
}
}))
books = books.map(b => b.opds)
return res.json({
metadata: {
@ -70,10 +57,10 @@ module.exports = {
currentPage: page
},
links: [
{ rel: 'self', href: `new?page=${page}`, type: 'application/opds+json' },
{ rel: 'prev', href: `new?page=${page > 1 ? page - 1 : page}`, type: 'application/opds+json' },
{ rel: 'next', href: `new?page=${page + 1}`, type: 'application/opds+json' },
{ 'rel': 'search', 'href': 'all{?title,author,version,isbn}', 'type': 'application/opds+json', 'templated': true }
{ rel: 'self', href: `all?page=${page}`, type: 'application/opds+json' },
{ rel: 'prev', href: `all?page=${page > 1 ? page - 1 : page}`, type: 'application/opds+json' },
{ rel: 'next', href: `all?page=${page + 1}`, type: 'application/opds+json' },
{ rel: 'search', href: 'all{?title,author,version,isbn}', type: 'application/opds+json', templated: true }
],
publications: books
})

View File

@ -20,10 +20,10 @@ module.exports = {
title: { type: 'string', required: true },
author: { type: 'string' },
publisher: { type: 'string' },
isbn: { type: 'string' },
identifier: { type: 'string' },
version: { type: 'string' },
hostname: { type: 'string' },
storage: { type: 'string' }
opds: { type: 'json' }
// ╔═╗╔╦╗╔╗ ╔═╗╔╦╗╔═╗
// ║╣ ║║║╠╩╗║╣ ║║╚═╗

View File

@ -1,14 +1,13 @@
'use strict'
import React from 'react'
import IconButton from '../components/IconButton'
import ConfirmIconButton from '../containers/ConfirmIconButton'
import UnderlineInput from '../components/UnderlineInput'
import './listitem.scss'
import { changeUrlField, setUrl, removeUrl, setEditingUri } from '../actions'
const uriRegex = /(.+:\/\/)?(.+\.)*(.+\.).{1,}(:\d+)?(.+)?/i
const isbnRegex = /^(97(8|9))?\d{9}(\d|X)$/
// const isbnRegex = /^(97(8|9))?\d{9}(\d|X)$/
class UriListItem extends React.Component {
constructor () {
@ -81,11 +80,10 @@ class UriListItem extends React.Component {
onBlur={(e) => this.props.dispatch(setUrl(this.props.item))} />
<UnderlineInput
className='uri flex'
type='number'
type='text'
name={'isbn-' + this.props.item.id}
placeholder='ISBN'
placeholder='Identifier'
value={'' + this.props.item.isbn}
pattern={isbnRegex}
onChange={(e) => this.props.dispatch(changeUrlField(this.props.item.id, 'isbn', e.target.value))}
onBlur={(e) => this.props.dispatch(setUrl(this.props.item))} />
</div>

View File

@ -85,12 +85,17 @@
header {
height: 50px;
line-height: 50px;
flex: none;
.logo {
color: $black-1;
text-decoration: none;
font-size: 1.2em;
padding: 0 20px;
@include break('small') {
display: none;
}
}
nav {
a {
@ -107,7 +112,7 @@
line-height: 40px;
height: 40px;
margin: 5px 20px 5px 0;
background: $accent-1;
background: $accent-2;
color: $text-light-1;
border-radius: 3px;
}
@ -115,12 +120,13 @@
}
}
footer {
height: 40px;
min-height: 40px;
line-height: 40px;
background: $accent-1;
color: white;
padding: 0 20px;
box-shadow: $shadow-3;
flex: none;
a {
color: $accent-3;
@ -131,7 +137,7 @@
}
}
}
main {
.paper {
background: white;
width: 85%;
max-width: 900px;
@ -158,6 +164,7 @@
background: $black-5;
padding: 10px;
border-radius: 3px;
overflow-x: scroll;
&:before {
display: block;
@ -181,5 +188,11 @@
p {
line-height: 1.4em;
}
@include break('small') {
width: 100%;
padding: 30px 10px;
margin: 0;
}
}
}

View File

@ -73,7 +73,7 @@ module.exports.http = {
publishLimit: publishLimiter,
passportInit: require('passport').initialize(),
passportSession: require('passport').session(),
allowCrossDomain: allowCrossDomain
allowCrossDomain: allowCrossDomain,
/***************************************************************************
* *
@ -83,10 +83,10 @@ module.exports.http = {
* *
***************************************************************************/
// bodyParser: (function _configureBodyParser(){
// var skipper = require('skipper');
// var middlewareFn = skipper({ strict: true });
// return middlewareFn;
// })(),
bodyParser: (function _configureBodyParser () {
const skipper = require('skipper')
const middlewareFn = skipper({ strict: true })
return middlewareFn
})()
}
}

View File

@ -10,20 +10,31 @@ POST to /api/publish containing headers:
roe-secret: <api secret>
}
and body:
and opds2 publication body with type `application/json`:
{
title: The ebook's title,
author: The author (optional),
version: A version number (optional),
isbn: The ISBN (optional),
opds: file
"metadata": {
"@type": "http://schema.org/Book",
"title": "Moby-Dick",
"author": "Herman Melville",
"identifier": "urn:isbn:978031600000X",
"publisher": "Ebook Publisher.com",
"language": "en",
"modified": "2015-09-29T17:00:00Z"
},
"links": [
{"rel": "self", "href": "http://example.org/manifest.json", "type": "application/webpub+json"}
],
"images": [
{"href": "http://example.org/cover.jpg", "type": "image/jpeg", "height": 1400, "width": 800},
{"href": "http://example.org/cover-small.jpg", "type": "image/jpeg", "height": 700, "width": 400},
{"href": "http://example.org/cover.svg", "type": "image/svg+xml"}
]
}
```
Each tuple of `(title, author, version, isbn)` must be unique.
The `opds` parameter is an opds2 file containing an opds Publication sent along with the post body.
@Type must be `http://schema.org/Book`.
Each tuple of `(title, author, publisher, identifier, modified)` must be unique.
The server will respond with either:
@ -35,9 +46,10 @@ The server will respond with either:
"id": number,
"title": string,
"author": string,
"isbn": string,
"publisher": string,
"identifier": string,
"version": string,
"storage": string
"opds": json
}
```
@ -95,6 +107,7 @@ The server will respond with either:
"@type": "http://schema.org/Book",
"title": "Moby-Dick",
"author": "Herman Melville",
"publisher": "Ebook Publisher.com",
"identifier": "urn:isbn:978031600000X",
"language": "en",
"modified": "2015-09-29T17:00:00Z"
@ -152,6 +165,7 @@ HTTP Body:
"@type": "http://schema.org/Book",
"title": "Moby-Dick",
"author": "Herman Melville",
"publisher": "Ebook Publisher.com",
"identifier": "urn:isbn:978031600000X",
"language": "en",
"modified": "2015-09-29T17:00:00Z"

View File

@ -0,0 +1,19 @@
exports.up = function (knex, Promise) {
return Promise.all([
knex.schema.table('book', t => {
t.json('opds')
t.renameColumn('isbn', 'identifier')
t.dropColumns('storage')
})
])
}
exports.down = function (knex, Promise) {
return Promise.all([
knex.schema.table('book', t => {
t.dropColumns('opds')
t.renameColumn('identifier', 'isbn')
t.string('storage')
})
])
}

View File

@ -0,0 +1,15 @@
exports.up = function (knex, Promise) {
return Promise.all([
knex.schema.table('book', t => {
t.dropColumns('source')
})
])
}
exports.down = function (knex, Promise) {
return Promise.all([
knex.schema.table('book', t => {
t.string('source')
})
])
}

View File

@ -10,9 +10,9 @@
<body class='home flex-container flex-vertical'>
<%- partial('../shared/header.html') %>
<main class="flex">
<% if (content) { %>
<section class="paper">
<%- content %>
<% } %>
</section>
</main>
<%- partial('../shared/footer.html') %>
</body>