Просмотр исходного кода

[bugfix] serve http only when ready
[add] download book as pdf or epub
[add] get book informations in json format
[add] get book cover (thumbmail size or not)
[add] get book page as png (pdf only)
[add] get 10 last new books on logged homepage
[wip] Book edit form
[add] popin for book pages and forms
[add] parse epub for cover
[add] parse pdf for page count
[add] render pdf page in png

[add] synchronize db and fs
[add] query bnf with isbn to get book meta

isundil 6 лет назад
Родитель
Сommit
235cbc2448

+ 3 - 0
Makefile

@@ -1,5 +1,8 @@
 
 SRC=	src/cli/resources.js	\
+		src/cli/query.js		\
+		src/cli/popin.js		\
+		src/cli/bookEditForm.js	\
 		src/cli/ui.js
 
 OUTPUT=	public/javascripts/scripts.js

+ 29 - 18
app.js

@@ -6,6 +6,8 @@ var logger = require('morgan');
 
 var indexRouter = require('./routes/index');
 var loginRouter = require('./routes/login');
+var bookRouter = require('./routes/book');
+var settingsRouter = require('./routes/settings');
 
 var app = express();
 
@@ -26,25 +28,34 @@ app.use(require('express-session')({
 }));
 app.use(express.static(path.join(__dirname, 'public')));
 
-app.use('/', indexRouter);
-app.use('/login', loginRouter);
 
-// catch 404 and forward to error handler
-app.use(function(req, res, next) {
-  next(createError(404));
-});
-
-// error handler
-app.use(function(err, req, res, next) {
-  // set locals, only providing error in development
-  res.locals.message = err.message;
-  res.locals.error = req.app.get('env') === 'development' ? err : {};
-
-  // render the error page
-  require('./src/templateArgs.js').generate(req).then(args => {
-    res.status(err.status || 500);
-    res.render('error', args);
-  });
+// Prepare db
+require("./src/sequelize.js").ready.then(() => {
+    // Start file updater
+    require("./src/files.js");
+
+    app.use('/', indexRouter);
+    app.use('/login', loginRouter);
+    app.use('/book', bookRouter);
+    app.use('/settings', settingsRouter);
+
+    // catch 404 and forward to error handler
+    app.use(function(req, res, next) {
+      next(createError(404));
+    });
+
+    // error handler
+    app.use(function(err, req, res, next) {
+      // set locals, only providing error in development
+      res.locals.message = err.message;
+      res.locals.error = req.app.get('env') === 'development' ? err : {};
+
+      // render the error page
+      require('./src/templateArgs.js').generate(req).then(args => {
+        res.status(err.status || 500);
+        res.render('error', args);
+      });
+    });
 });
 
 module.exports = app;

+ 39 - 0
models/document.js

@@ -0,0 +1,39 @@
+
+module.exports = (sequelize, DataTypes) => {
+    var Document = sequelize.define("document", {
+        id: { type: DataTypes.STRING, primaryKey: true },
+        path: { type: DataTypes.STRING },
+        title: { type: DataTypes.STRING },
+        author: { type: DataTypes.STRING },
+        pageCount: { type: DataTypes.INTEGER },
+        identifier: { type: DataTypes.STRING },
+        fileSize: { type: DataTypes.INTEGER }
+    }, {
+        timestamps: true,
+        sequelize });
+
+    Document.getLastInserted = function(count) {
+        return Document.findAll({
+            order: [[ 'createdAt', 'DESC' ]],
+            limit: count
+        });
+    }
+
+    var DocumentCover = sequelize.define("document_covers", {
+        id: {
+            type: DataTypes.STRING,
+            primaryKey: true,
+        },
+        cover: { type: DataTypes.BLOB('long') }
+    }, {
+        timestamps: false,
+        sequelize });
+
+    DocumentCover.belongsTo(Document, { foreignKey: 'id' });
+
+    return {
+        Document: Document,
+        DocumentCover: DocumentCover
+    };
+};
+

+ 2 - 2
models/users.js

@@ -1,12 +1,12 @@
 
 module.exports = (sequelize, DataTypes) => {
     var User = sequelize.define("user", {
-        username: { type: DataTypes.STRING, unique: true },
+        username: { type: DataTypes.STRING },
         password: { type: DataTypes.STRING }
     }, {
+        indexes: [ { unique: true, fields: [ 'username' ]} ],
         timestamps: true,
         sequelize });
-    User.sync();
     return User;
 };
 

+ 9 - 1
package.json

@@ -13,16 +13,24 @@
   "author": "isundil",
   "license": "GPL-3.0-or-later",
   "dependencies": {
+    "adm-zip": "^0.4.13",
+    "assert": "^1.4.1",
+    "canvas": "^2.4.1",
     "cookie-parser": "~1.4.3",
     "debug": "~2.6.9",
+    "ebook-convert": "^2.0.1",
     "express": "~4.16.0",
     "express-session": "^1.16.1",
+    "http": "0.0.0",
     "http-errors": "~1.6.2",
     "mariadb": "^2.0.3",
     "morgan": "~1.9.0",
     "mysql2": "^1.6.5",
+    "pdfjs-dist": "^2.0.943",
     "pug": "2.0.0-beta11",
     "sequelize": "^5.7.3",
-    "sha256": "^0.2.0"
+    "sha256": "^0.2.0",
+    "tempfile": "^3.0.0",
+    "xml2js": "^0.4.19"
   }
 }

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
public/images/book_close.svg


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
public/images/book_delete.svg


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
public/images/book_download.svg


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
public/images/book_edit.svg


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
public/images/book_favorite.svg


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
public/images/book_help.svg


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
public/images/book_remove.svg


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
public/images/book_search.svg


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
public/images/book_share.svg


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
public/images/book_upload.svg


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
public/images/book_valid.svg


+ 33 - 0
public/images/loading.svg

@@ -0,0 +1,33 @@
+<svg class="lds-spinner" width="200px"  height="200px"  xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 100 100" preserveAspectRatio="xMidYMid" style="background: none;"><g transform="rotate(0 50 50)">
+  <rect x="47" y="24" rx="9.4" ry="4.8" width="6" height="12" fill="#0b1d27">
+    <animate attributeName="opacity" values="1;0" keyTimes="0;1" dur="1s" begin="-0.875s" repeatCount="indefinite"></animate>
+  </rect>
+</g><g transform="rotate(45 50 50)">
+  <rect x="47" y="24" rx="9.4" ry="4.8" width="6" height="12" fill="#0b1d27">
+    <animate attributeName="opacity" values="1;0" keyTimes="0;1" dur="1s" begin="-0.75s" repeatCount="indefinite"></animate>
+  </rect>
+</g><g transform="rotate(90 50 50)">
+  <rect x="47" y="24" rx="9.4" ry="4.8" width="6" height="12" fill="#0b1d27">
+    <animate attributeName="opacity" values="1;0" keyTimes="0;1" dur="1s" begin="-0.625s" repeatCount="indefinite"></animate>
+  </rect>
+</g><g transform="rotate(135 50 50)">
+  <rect x="47" y="24" rx="9.4" ry="4.8" width="6" height="12" fill="#0b1d27">
+    <animate attributeName="opacity" values="1;0" keyTimes="0;1" dur="1s" begin="-0.5s" repeatCount="indefinite"></animate>
+  </rect>
+</g><g transform="rotate(180 50 50)">
+  <rect x="47" y="24" rx="9.4" ry="4.8" width="6" height="12" fill="#0b1d27">
+    <animate attributeName="opacity" values="1;0" keyTimes="0;1" dur="1s" begin="-0.375s" repeatCount="indefinite"></animate>
+  </rect>
+</g><g transform="rotate(225 50 50)">
+  <rect x="47" y="24" rx="9.4" ry="4.8" width="6" height="12" fill="#0b1d27">
+    <animate attributeName="opacity" values="1;0" keyTimes="0;1" dur="1s" begin="-0.25s" repeatCount="indefinite"></animate>
+  </rect>
+</g><g transform="rotate(270 50 50)">
+  <rect x="47" y="24" rx="9.4" ry="4.8" width="6" height="12" fill="#0b1d27">
+    <animate attributeName="opacity" values="1;0" keyTimes="0;1" dur="1s" begin="-0.125s" repeatCount="indefinite"></animate>
+  </rect>
+</g><g transform="rotate(315 50 50)">
+  <rect x="47" y="24" rx="9.4" ry="4.8" width="6" height="12" fill="#0b1d27">
+    <animate attributeName="opacity" values="1;0" keyTimes="0;1" dur="1s" begin="0s" repeatCount="indefinite"></animate>
+  </rect>
+</g></svg>

+ 1 - 0
public/images/settings.svg

@@ -0,0 +1 @@
+<svg xmlns:x="http://ns.adobe.com/Extensibility/1.0/" xmlns:i="http://ns.adobe.com/AdobeIllustrator/10.0/" xmlns:graph="http://ns.adobe.com/Graphs/1.0/" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" x="0px" y="0px" viewBox="0 0 100 125" style="enable-background:new 0 0 100 100;" xml:space="preserve"><switch><foreignObject requiredExtensions="http://ns.adobe.com/AdobeIllustrator/10.0/" x="0" y="0" width="1" height="1"/><g i:extraneous="self"><path d="M95.7,41.9l-8.8-1.1c-0.9-3.7-2.4-7.2-4.3-10.4l5.4-7c0.6-0.8,0.6-2-0.2-2.7l-8.6-8.6c-0.7-0.7-1.9-0.8-2.7-0.2l-7,5.4    c-3.2-1.9-6.7-3.4-10.4-4.3l-1.1-8.8c-0.1-1-1-1.8-2-1.8H43.9c-1,0-1.9,0.8-2,1.8l-1.1,8.8c-3.7,0.9-7.2,2.4-10.4,4.3l-7-5.4    c-0.8-0.6-2-0.6-2.7,0.2l-8.6,8.6c-0.7,0.7-0.8,1.9-0.2,2.7l5.4,7c-1.9,3.2-3.4,6.7-4.3,10.4l-8.8,1.1c-1,0.1-1.8,1-1.8,2v12.1    c0,1,0.8,1.9,1.8,2l8.8,1.1c0.9,3.7,2.4,7.2,4.3,10.4l-5.4,7c-0.6,0.8-0.6,2,0.2,2.7l8.6,8.6c0.7,0.7,1.9,0.8,2.7,0.2l7-5.4    c3.2,1.9,6.7,3.4,10.4,4.3l1.1,8.8c0.1,1,1,1.8,2,1.8h12.1c1,0,1.9-0.8,2-1.8l1.1-8.8c3.7-0.9,7.2-2.4,10.4-4.3l7,5.4    c0.8,0.6,2,0.6,2.7-0.2l8.6-8.6c0.7-0.7,0.8-1.9,0.2-2.7l-5.4-7c1.9-3.2,3.4-6.7,4.3-10.4l8.8-1.1c1-0.1,1.8-1,1.8-2V43.9    C97.5,42.9,96.7,42.1,95.7,41.9z M50,74.3c-13.4,0-24.3-10.9-24.3-24.3c0-13.4,10.9-24.3,24.3-24.3c13.4,0,24.3,10.9,24.3,24.3    C74.3,63.4,63.4,74.3,50,74.3z"/></g></switch><text x="0" y="115" fill="#000000" font-size="5px" font-weight="bold" font-family="'Helvetica Neue', Helvetica, Arial-Unicode, Arial, Sans-serif">Created by Rose Alice Design</text><text x="0" y="120" fill="#000000" font-size="5px" font-weight="bold" font-family="'Helvetica Neue', Helvetica, Arial-Unicode, Arial, Sans-serif">from the Noun Project</text></svg>

+ 5 - 0
public/javascripts/scripts.js

@@ -0,0 +1,5 @@
+'use strict';function f(a){return new Promise(b=>{b(JSON.parse(a))})}function h(a){return new Promise((b,e)=>{var c=new XMLHttpRequest;c.onreadystatechange=function(){4===c.readyState&&(2===Math.floor(c.status/100)?b(c.responseText):e(c.statusText))};c.open("GET",a,!0);c.send()})};function l(){this.c=document.createElement("div");var a=document.createElement("div");a.classList.add("popin-overlay");this.c.appendChild(a);a=document.createElement("div");a.classList.add("popin-container");this.c.appendChild(a);this.b=document.createElement("div");this.b.className="popin-img";this.f=document.createElement("img");this.f.src="/images/loading.svg";this.b.appendChild(this.f);a.appendChild(this.b);a.addEventListener("click",(()=>{this.c.remove()}).bind(this));this.b.addEventListener("click",
+a=>a.stopPropagation());this.a=new XMLHttpRequest;this.a.responseType="blob";this.a.onreadystatechange=(()=>{4===this.a.readyState&&(2===Math.floor(this.a.status/100)&&this.a.response?this.f.src=window.URL.createObjectURL(this.a.response):console.error("Cannot get resource",this.a.status))}).bind(this)};function m(a){this.data=void 0;this.error=null;var b=this;this.a=new Promise(e=>{h("/book/"+a+"/info.json").then(f).then(a=>{b.data={title:a.title,g:a.author,identifier:a.identifier,h:a.pageCount};e(b)}).catch(c=>{console.error("Cannot get data from book "+a,c);b.data=null;this.error=c;e(b)})});this.b=()=>{}}
+function n(a,b,e,c){var d=document.createElement("label"),k=document.createElement("span");a:{switch(c){case "text":case "number":var g=document.createElement("input");g.type=c;g.value=e||("text"==c?"":0);g.name=a;a=g;break a}a=null}k.textContent=b;d.appendChild(k);d.appendChild(a);return d};window.displayCover=function(a){var b=new l;document.body.appendChild(b.c);a="/book/"+a+"/read/1.png";0!==b.a.readyState&&4!==b.a.readyState&&b.a.abort();b.a.open("GET",a,!0);b.a.send()};
+window.editDocument=function(a){var b=new l;a=new m(a);document.body.appendChild(b.c);a.a.then(a=>{var c=document.createDocumentFragment();if(a.error){var d=document.createElement("div");d.className="error";d.textContent=a.error;c.appendChild(d)}else c.appendChild(n("title","Titre",a.data.title,"text")),c.appendChild(n("author","Auteur",a.data.g,"text")),c.appendChild(n("pageCount","Nombre de pages",a.data.h,"number")),c.appendChild(n("identifier","Identifiant",a.data.identifier,"text"));b.f.remove();
+b.b.className="popin-dom";b.b.appendChild(c)});a.b=()=>{b.c.remove()}};

+ 83 - 0
public/stylesheets/style.css

@@ -37,6 +37,10 @@ nav.menubar {
   list-style-type: none;
 }
 
+.menubar input {
+  display: inline;
+}
+
 .menubar img {
   height: 1em;
   vertical-align: middle;
@@ -46,3 +50,82 @@ nav.menubar {
   margin: 0 20px;
 }
 
+.booklist.horizontal {
+    display: flex;
+    padding: 0 40px;
+    overflow: auto;
+}
+
+.booklist li {
+    list-style-type: none;
+}
+
+.booklist.horizontal li {
+    margin: 0 20px;
+    display: inline-block;
+}
+
+.popin-overlay {
+    position: fixed;
+    top: 0;
+    bottom: 0;
+    right: 0;
+    left: 0;
+    background-color: rgba(0, 0, 0, 0.5);
+    z-index: 100;
+}
+
+.popin-container {
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    position: fixed;
+    top: 0;
+    bottom: 0;
+    right: 0;
+    left: 0;
+    z-index: 101;
+    user-select: none;
+}
+
+.popin-container > div {
+    display: inline-block;
+    background: white;
+    border: 1em solid rgba(0, 0, 0, 0.25);
+}
+
+.popin-dom {
+    padding: 1em;
+    width: 800px;
+    height: 600px;
+    overflow: auto;
+}
+
+.book .dl-links {
+    text-align: right;
+}
+
+h7 {
+    font-size: 1.25em;
+    display: block;
+    margin-top: 0.75em;
+}
+
+h7 img {
+    height: 1.25em;
+}
+
+a.btn {
+    display: inline-block;
+    padding: 5px;
+    margin: 0 0 0 5px;
+    text-decoration: none;
+    background-color: aliceblue;
+    border: 1px solid #d1e2ef;
+}
+
+a.btn img {
+    max-height: 1em;
+    max-width: 1em;
+}
+

+ 120 - 0
routes/book.js

@@ -0,0 +1,120 @@
+
+const express = require('express'),
+    router = express.Router(),
+    Document = require("../src/sequelize.js").Document,
+    DocumentCover = require("../src/sequelize.js").DocumentCover,
+    DocumentFile = require("../src/document.js").Document,
+    templateArgs = require('../src/templateArgs.js');
+
+function getDocumentInFormat(docId, format) {
+    return new Promise((res, rej) => {
+        Document.findOne({ attributes: [ "path" ], where: { id: docId }}).then(path => {
+            if (!path) {
+                rej(404);
+                return;
+            }
+            const document = new DocumentFile(path.path);
+            var doc = document.convert(format);
+            doc.ready.then(content => {
+                if (!content) {
+                    doc.clean();
+                    rej(500);
+                    return;
+                }
+                res({ error: null, content: content });
+                doc.clean();
+            });
+        });
+    });
+}
+
+router.get('/:bookId/info.json', (req, res) => {
+    Document.findOne({where: { id: req.params.bookId }}).then(doc => {
+        if (!doc) {
+            res.status(404);
+            res.send("Book bot found");
+            return;
+        }
+        res.set({ "Content-Type": "application/json" });
+        res.send(JSON.stringify({
+            title: doc.title,
+            author: doc.author,
+            identifier: doc.identifier,
+            pageCount: doc.pageCount
+        }));
+    });
+});
+
+router.get('/:bookId/download.pdf', (req, res) => {
+  templateArgs.needLogin(req, res).then(async () => {
+      getDocumentInFormat(req.params.bookId, "pdf").then(doc => {
+          res.set({
+            "Content-Type": "application/pdf",
+          });
+          doc.content.pipe(res);
+      }).catch(e => {
+          res.status(e)
+          res.send("Error");
+      });
+  });
+});
+
+router.get('/:bookId/download.epub', (req, res) => {
+  templateArgs.needLogin(req, res).then(async () => {
+      getDocumentInFormat(req.params.bookId, "epub").then(doc => {
+          res.set({
+            "Content-Type": "application/epub",
+          });
+          doc.content.pipe(res);
+      }).catch(e => {
+          res.status(e)
+          res.send("Error");
+      });
+  });
+});
+
+router.get('/:bookId/cover.png', (req, res) => {
+  templateArgs.needLogin(req, res).then(async () => {
+    var cover = await DocumentCover.findOne({ where: {id: req.params.bookId }});
+    if (!cover) {
+      res.status(404);
+      res.send("Not Found");
+    } else {
+      res.set({
+        "Content-Type": "image/png",
+      });
+      res.send(cover.cover);
+    }
+  });
+});
+
+router.get('/:bookId/read/:pageNo.png', (req, res) => {
+  templateArgs.needLogin(req, res).then(async () => {
+    var path = await Document.findOne({ attributes: [ "path" ], where: { id: req.params.bookId }}),
+        pageReq = parseInt(req.params.pageNo, 10);
+    if (!path || pageReq <= 0) {
+        res.status(404);
+        res.send("Not Found");
+        return;
+    }
+    const document = new DocumentFile(path.path),
+          pageCount = document.getPageCount();
+    if (pageReq > pageCount) {
+        res.status(404);
+        res.send("Not Found");
+        return;
+    }
+    const content = await document.getPage(pageReq);
+    if (content) {
+      res.set({
+        "Content-Type": "image/png",
+      });
+      res.send(content);
+    } else {
+      res.status(500);
+      res.end("Error");
+    }
+  });
+});
+
+module.exports = router;

+ 11 - 5
routes/index.js

@@ -1,11 +1,17 @@
-var express = require('express');
-var router = express.Router();
-var templateArgs = require('../src/templateArgs.js');
+const express = require('express'),
+    router = express.Router(),
+    Document = require("../src/sequelize.js").Document;
+    templateArgs = require('../src/templateArgs.js');
 
 /* GET home page. */
 router.get('/', function(req, res, next) {
-  templateArgs.generate(req).then(args => {
-    res.render("index", args);
+  templateArgs.generate(req).then(async args => {
+    if (args.user) {
+      args.books = await Document.getLastInserted(10);
+      res.render("indexUser", args);
+    } else {
+      res.render("index", args);
+    }
   });
 });
 

+ 7 - 7
routes/login.js

@@ -1,7 +1,7 @@
-var express = require('express');
-var router = express.Router();
-var templateArgs = require('../src/templateArgs.js');
-var User = require("../src/sequelize.js").User();
+const express = require('express'),
+    router = express.Router(),
+    templateArgs = require('../src/templateArgs.js'),
+    User = require("../src/sequelize.js").User;
 
 function renderLogin(req, res, loginFailure) {
   templateArgs.generate(req).then(args => {
@@ -19,14 +19,14 @@ router.get('/', (req, res, next) => {
 });
 
 router.post('/', (req, res) => {
-  User.findAll({
+  User.findOne({
     where: {
       username: req.body.username,
       password: require('sha256')(req.body.password)
     }
   }).then(val => {
-    if (val && val[0]) {
-      req.session.userId = val[0].id;
+    if (val) {
+      req.session.userId = val.id;
       res.redirect("/");
     } else {
       renderLogin(req, res, true);

+ 15 - 0
routes/settings.js

@@ -0,0 +1,15 @@
+var express = require('express');
+var router = express.Router();
+var templateArgs = require('../src/templateArgs.js');
+
+/* GET home page. */
+router.get('/', function(req, res) {
+  templateArgs.needLogin(req, res).then(() => {
+    templateArgs.generate(req).then(args => {
+      args.title = "Totoro Book - Settings";
+      res.render('settings', args);
+    });
+  });
+});
+
+module.exports = router;

+ 80 - 0
src/cli/bookEditForm.js

@@ -0,0 +1,80 @@
+
+/**
+ * @constructor
+ * @param {string} hash
+**/
+function BookEditForm(hash) {
+    /** @type {Object|null|undefined} */
+    this.data = undefined;
+
+    /** @type {String|null} */
+    this.error = null;
+
+    var _this = this;
+
+    /** @type {Promise} */
+    this.ready = new Promise((res, rej) => {
+        var req = queryGet("/book/" +hash +"/info.json").then(toJSON).then((data) => {
+            _this.data = {
+                title: data["title"],
+                author: data["author"],
+                identifier: data["identifier"],
+                pageCount: data["pageCount"]
+            };
+            res(_this);
+        }).catch((e) => {
+            console.error("Cannot get data from book " +hash, e);
+            _this.data = null;
+            this.error = e;
+            res(_this);
+        });
+    });
+
+    /** @type {function()} */
+    this.onClose = () => {};
+};
+
+var inputType = {
+    text: "text",
+    number: "number"
+};
+
+BookEditForm.prototype.createInputDom = function(type, name, val) {
+    switch (type) {
+        case inputType.text:
+        case inputType.number:
+            var input = document.createElement("input");
+            input.type = type;
+            input.value = val || (type == inputType.text ? "" : 0);
+            input.name = name;
+            return input;
+    }
+    return null;
+};
+
+BookEditForm.prototype.createFieldDom = function(fieldName, legend, defaultValue, type) {
+    var res = document.createElement("label"),
+        legendDom = document.createElement("span"),
+        input = this.createInputDom(type, fieldName, defaultValue);
+    legendDom.textContent = legend;
+    res.appendChild(legendDom);
+    res.appendChild(input);
+    return res;
+};
+
+BookEditForm.prototype.createDom = function() {
+    var frag = document.createDocumentFragment();
+    if (this.error) {
+        var err = document.createElement("div");
+        err.className = htmlClasses.error;
+        err.textContent = this.error;
+        frag.appendChild(err);
+        return frag;
+    }
+    frag.appendChild(this.createFieldDom("title", "Titre", this.data.title, inputType.text));
+    frag.appendChild(this.createFieldDom("author", "Auteur", this.data.author, inputType.text));
+    frag.appendChild(this.createFieldDom("pageCount", "Nombre de pages", this.data.pageCount, inputType.number));
+    frag.appendChild(this.createFieldDom("identifier", "Identifiant", this.data.identifier, inputType.text));
+    return frag;
+};
+

+ 55 - 0
src/cli/popin.js

@@ -0,0 +1,55 @@
+
+/**
+ * @constructor
+**/
+function Popin() {
+    this.wrapper = document.createElement("div");
+    var overlay = document.createElement("div");
+    overlay.classList.add(htmlClasses.popin.overlay);
+    this.wrapper.appendChild(overlay);
+    var container = document.createElement("div");
+    container.classList.add(htmlClasses.popin.container);
+    this.wrapper.appendChild(container);
+    this.contentWrapper = document.createElement("div");
+    this.contentWrapper.className = htmlClasses.popin.imgContent;
+    this.img = document.createElement("img");
+    this.img.src = "/images/loading.svg";
+    this.contentWrapper.appendChild(this.img);
+    container.appendChild(this.contentWrapper);
+    container.addEventListener("click", (() => this.hide()).bind(this));
+    this.contentWrapper.addEventListener("click", e => e.stopPropagation());
+    this.query = new XMLHttpRequest();
+    this.query.responseType = 'blob';
+    this.query.onreadystatechange = (() => {
+        if (this.query.readyState === 4) {
+            if (Math.floor(this.query.status / 100) === 2 && this.query.response) {
+                this.img.src = window.URL.createObjectURL(/** @type {Blob!} */ (this.query.response));
+            } else {
+                // Http error
+                console.error("Cannot get resource", this.query.status);
+            }
+        }
+    }).bind(this);
+}
+
+Popin.prototype.show = function() {
+    document.body.appendChild(this.wrapper);
+};
+
+Popin.prototype.hide = function() {
+    this.wrapper.remove();
+};
+
+Popin.prototype.setImage = function(src) {
+    if (this.query.readyState !== 0 && this.query.readyState !== 4)
+        this.query.abort();
+    this.query.open("GET", src, true);
+    this.query.send();
+};
+
+Popin.prototype.setContent = function(fragment) {
+    this.img.remove();
+    this.contentWrapper.className = htmlClasses.popin.domContent;
+    this.contentWrapper.appendChild(fragment);
+};
+

+ 24 - 0
src/cli/query.js

@@ -0,0 +1,24 @@
+
+function toJSON(dataStr) {
+    return new Promise((res, rej) => {
+        res(JSON.parse(dataStr));
+    });
+}
+
+function queryGet(url) {
+    return new Promise((res, rej) => {
+        var req = new XMLHttpRequest();
+        req.onreadystatechange = function() {
+            if (req.readyState === 4) {
+                if (Math.floor(req.status / 100) === 2) {
+                    res(req.responseText);
+                } else {
+                    rej(req.statusText);
+                }
+            }
+        };
+        req.open("GET", url, true);
+        req.send();
+    });
+}
+

+ 13 - 0
src/cli/resources.js

@@ -0,0 +1,13 @@
+
+const htmlIds = {
+},
+htmlClasses = {
+    popin: {
+        overlay: "popin-overlay",
+        container: "popin-container",
+        domContent: "popin-dom",
+        imgContent: "popin-img"
+    },
+    error: "error"
+};
+

+ 16 - 0
src/cli/ui.js

@@ -0,0 +1,16 @@
+
+window["displayCover"] = function(hash) {
+    var popin = new Popin();
+    popin.show();
+    popin.setImage("/book/" +hash +"/read/1.png");
+};
+
+window["editDocument"] = function(hash) {
+    var popin = new Popin(),
+        bookEditForm = new BookEditForm(hash);
+
+    popin.show();
+    bookEditForm.ready.then(form => popin.setContent(form.createDom()));
+    bookEditForm.onClose = () => popin.hide();
+};
+

+ 99 - 0
src/document.js

@@ -0,0 +1,99 @@
+
+const fs = require("fs"),
+    path = require("path"),
+    crypto = require("crypto"),
+    ebookConvert = require("ebook-convert"),
+    tempfile = require("tempfile"),
+    handlers = [
+        [ require("./pdfDocument.js").PdfHandler, ".pdf" ],
+        [ require("./epubDocument.js").EpubHandler, ".epub" ]
+    ];
+
+class Document {
+    constructor(filepath, stat) {
+        if (!stat)
+            stat = fs.statSync(filepath);
+
+        this.path = filepath;
+        this.size = stat.size; // in bytes
+        this.hash = null;
+        this.handler = null;
+
+        var extname = this.extName;
+
+        handlers.some(hndl => {
+            for (var i =1, len = hndl.length; i< len; ++i)
+                if (extname == hndl[i]) {
+                    this.handler = new hndl[0](this);
+                    return true;
+                }
+        }, this);
+    }
+
+    get extName() {
+        return path.extname(this.path).toLowerCase();
+    }
+
+    get name() {
+        return path.basename(this.path);
+    }
+
+    computeMd5Async() {
+        return new Promise(((res, rej) => {
+            var hash = crypto.createHash("md5"),
+                stream = fs.createReadStream(this.path);
+            stream.on("data", data => hash.update(data, "utf8"));
+            stream.once("end", () => {
+                res(this.hash = hash.digest("hex"));
+            });
+        }).bind(this));
+    }
+
+    getPageCount() {
+        return this.handler ? this.handler.getPageCount() : null;
+    }
+
+    getCover() {
+        return this.handler ? this.handler.getCover() : null;
+    }
+
+    getPage(page) {
+        return this.handler ? this.handler.getPngPage(page, false) : null;
+    }
+
+    convert(extension) {
+        if (this.extName === '.' +extension.toLowerCase())
+            return {
+                ready: new Promise((res, rej) => res(fs.createReadStream(this.path))),
+                clean: () => {}
+            };
+        const tmpFile = tempfile("." +extension);
+        return {
+            ready: new Promise((res, rej) => {
+                var params = {
+                    input: '"' +this.path +'"',
+                    output: tmpFile
+                };
+                switch (extension) {
+                    case "epub":
+                        params["flow-size"] = 2600000 // 2.6Mo
+                        break;
+                }
+                console.log("Converting file with params", params);
+                ebookConvert(params, (err) => {
+                    if (err)
+                        console.error(err);
+                    res(err ? null : fs.createReadStream(tmpFile));
+                });
+            }),
+            clean: () => {
+                fs.unlink(tmpFile, err => err && console.error(err));
+            }
+        };
+    }
+};
+
+module.exports = {
+    Document: Document
+};
+

+ 15 - 0
src/documentHandler.js

@@ -0,0 +1,15 @@
+
+function DocumentHandler(doc) {
+    this.doc = doc;
+}
+
+DocumentHandler.prototype.getPageCount = function() { return null; };
+DocumentHandler.prototype.getPngPage = function(page, isThumbmail) { return null; };
+
+DocumentHandler.prototype.getCover = function() {
+    return this.getPngPage(1, true);
+};
+
+
+module.exports.DocumentHandler = DocumentHandler;
+

+ 108 - 0
src/epubDocument.js

@@ -0,0 +1,108 @@
+
+const Handler = require("./documentHandler.js").DocumentHandler,
+    assert = require("assert"),
+    AdmZip = require("adm-zip"),
+    Xml2js = require("xml2js"),
+    Canvas = require("canvas"),
+    fs = require("fs");
+
+function EpubHandler(doc) {
+    Handler.call(this, doc);
+    this.data = null;
+    this.loading = null;
+}
+EpubHandler.prototype = Object.create(Handler.prototype);
+
+EpubHandler.prototype.parseFile = function() {
+    return new Promise(async (res, rej) => {
+        const zip = new AdmZip(this.doc.path),
+            entries = zip.getEntries(),
+            entriesByPath = {};
+        entries.forEach(i => {
+            entriesByPath[i.entryName] = i;
+        });
+        if (!entriesByPath["content.opf"])
+            rej();
+        Xml2js.parseString(entriesByPath["content.opf"].getData().toString("utf8"), ((err, result) => {
+            if (err) {
+                console.error("Malformed XML data in epub file", err);
+                rej();
+            }
+            if (!result ||
+                !result.package ||
+                !result.package.manifest ||
+                !result.package.manifest[0] ||
+                !result.package.manifest[0].item ||
+                !Array.isArray(result.package.manifest[0].item)) {
+                console.error("Cannot read metadata epub file");
+                rej();
+            }
+            this.data = {};
+            for (var i =0, len = result.package.manifest[0].item.length;
+                    i < len;
+                    ++i) {
+                var item = result.package.manifest[0].item[i]["$"];
+                if (item && item.id === "cover") {
+                    if (!entriesByPath[item.href]) {
+                        console.error("Referenced file " +item.href +" not found");
+                        continue;
+                    }
+                    this.data.cover = entriesByPath[item.href].getData();
+                    break;
+                }
+            }
+            if (!this.data.cover) {
+                console.error("Cannot find cover");
+                this.data = null;
+                rej();
+            }
+            res(this.data);
+        }).bind(this));
+    });
+}
+
+EpubHandler.prototype.loadDocument = function() {
+    return new Promise(((res, rej) => {
+        if (this.data) {
+            res(this.data);
+        } else {
+            if (this.loading)
+                this.loading = this.loading.then((() => res(this.data)).bind(this));
+            else
+                this.loading = this.parseFile().then(doc => res(this.data));
+        }
+    }).bind(this));
+}
+
+EpubHandler.prototype.getPageCount = function() {
+    return new Promise((res, rej) => res(null));
+};
+
+EpubHandler.prototype.getThumbmailViewport = function(fullWidth, fullHeight) {
+    const maxSize = require("../config").thumbmailMaxSize;
+    var ratio = Math.min(maxSize /fullHeight, maxSize /fullWidth);
+    return {
+        height: ratio *fullHeight,
+        width: ratio *fullWidth
+    };
+};
+
+EpubHandler.prototype.getCover = function() {
+    return new Promise(async (res, rej) => {
+        const data = await this.loadDocument(),
+            image = new Canvas.Image();
+        image.src = data.cover;
+        const size = this.getThumbmailViewport(image.width, image.height),
+            canvas = Canvas.createCanvas(size.width, size.height),
+            ctx = canvas.getContext('2d');
+        ctx.drawImage(image, 0, 0, size.width, size.height);
+        canvas.toBuffer((err, buf) => res(buf), "image/png");
+    });
+};
+
+EpubHandler.prototype.getPngPage = function(page, isThumbmail) {
+    return new Promise((res, rej) => res(null));
+};
+
+module.exports.EpubHandler = EpubHandler;
+

+ 112 - 0
src/files.js

@@ -0,0 +1,112 @@
+
+const fs = require("fs"),
+    path = require("path"),
+    Document = require("./document.js").Document;
+    DbDoc = require("./sequelize.js").Document;
+    DbDocCover = require("./sequelize.js").DocumentCover;
+    CONFIG = require("../config.js");
+
+function CreateDocument(filename, stat) {
+    var doc = new Document(filename, stat);
+    if (!doc.handler) {
+        console.error("Unknown file type: " +doc.name);
+        return null;
+    }
+    return doc;
+}
+
+function consumeDir(dirs, files) {
+    (fs.readdirSync(dirs[0]) || []).forEach(shortname => {
+        var fileName = path.join(dirs[0], shortname),
+            stat = fs.statSync(fileName);
+
+        if (stat.isDirectory()) {
+            dirs.push(fileName);
+        } else {
+            var doc = CreateDocument(fileName, stat);
+            if (doc)
+                files.push(doc);
+        }
+    });
+    dirs.splice(0, 1);
+}
+
+function FilePool(files) {
+    this.files = files;
+}
+
+FilePool.prototype.createDbDocument = async function(hash) {
+    var file = this.files[hash],
+        meta = [];
+
+    meta = await Promise.all([
+        file.getPageCount(),
+        file.getCover(),
+    ]);
+    return DbDoc.create({
+        id: hash,
+        path: file.path,
+        title: null,
+        author: null,
+        pageCount: meta[0],
+        isbn: null,
+        fileSize: file.size
+    }).then(() => DbDocCover.create({
+        id: hash,
+        cover: meta[1]
+    }));
+}
+
+FilePool.prototype.sync = function(dbIds) {
+    var toAdd = [],
+        toRm = [];
+
+    for (var i in this.files) {
+        if (!dbIds[i]) {
+            // new file !
+            toAdd.push(i);
+        } else if (dbIds[i] !== this.files[i].path) {
+            // File Moved !
+            toRm.push(i);
+            toAdd.push(i);
+        }
+    }
+    for (var i in dbIds) {
+        if (!this.files[i]) {
+            // file removed !
+            toRm.push(i);
+        }
+    }
+
+    var promises = [];
+    toRm.length && promises.push(DbDocCover.destroy({ where: { id: toRm} }).then(DbDoc.destroy({ where: { id: toRm }})));
+    toAdd.forEach(i => promises.push(this.createDbDocument(i)));
+    return Promise.all(promises);
+};
+
+async function CreateFilePool() {
+    var files = [],
+        dirs = CONFIG.directories.slice(0);
+
+    while (dirs.length)
+        consumeDir(dirs, files);
+    const results = await Promise.all(files.map(i => i.computeMd5Async()))
+    filesByHash = {};
+    files.forEach(i => filesByHash[i.hash] = i);
+    return new FilePool(filesByHash);
+}
+
+function refreshFiles() {
+    CreateFilePool().then(pool => {
+        var dbIds = DbDoc.findAll({ attributes: [ "id", "path" ] }).then(async dbIds => {
+            var ids = {};
+            dbIds.forEach(i => ids[i.id] = i.path);
+            await pool.sync(ids);
+            console.log("Done");
+        });
+    });
+};
+
+refreshFiles();
+setInterval(refreshFiles, CONFIG.scanInterval * 60 * 1000);
+

+ 95 - 0
src/pdfDocument.js

@@ -0,0 +1,95 @@
+
+const Handler = require("./documentHandler.js").DocumentHandler,
+    pdfJs = require("pdfjs-dist"),
+    assert = require("assert"),
+    Canvas = require("canvas");
+
+function NodeCanvasFactory() {}
+NodeCanvasFactory.prototype = {
+  create: function NodeCanvasFactory_create(width, height) {
+    assert(width > 0 && height > 0, 'Invalid canvas size from Create ' +width +'x' +height);
+    var canvas = Canvas.createCanvas(width, height);
+    return {
+      canvas: canvas,
+      context: canvas.getContext('2d')
+    };
+  },
+
+  reset: function NodeCanvasFactory_reset(canvasAndContext, width, height) {
+    assert(canvasAndContext.canvas, 'Canvas is not specified');
+    assert(width > 0 && height > 0, 'Invalid canvas size for reset');
+    canvasAndContext.canvas.width = width;
+    canvasAndContext.canvas.height = height;
+  },
+
+  destroy: function NodeCanvasFactory_destroy(canvasAndContext) {
+    assert(canvasAndContext.canvas, 'Canvas is not specified');
+
+    // Zeroing the width and height cause Firefox to release graphics
+    // resources immediately, which can greatly reduce memory consumption.
+    canvasAndContext.canvas.width = 0;
+    canvasAndContext.canvas.height = 0;
+    canvasAndContext.canvas = null;
+    canvasAndContext.context = null;
+  },
+};
+
+function PdfHandler(doc) {
+    Handler.call(this, doc);
+    this.pdfData = null;
+    this.loading = null;
+}
+PdfHandler.prototype = Object.create(Handler.prototype);
+
+PdfHandler.prototype.loadDocument = function() {
+    return new Promise(((res, rej) => {
+        if (this.pdfData) {
+            res(this.pdfData);
+        } else {
+            if (this.loading)
+                this.loading = this.loading.then((() => res(this.pdfData)).bind(this));
+            else
+                this.loading = pdfJs.getDocument(this.doc.path).then((doc => {
+                    res(this.pdfData = doc);
+                }).bind(this));
+        }
+    }).bind(this));
+}
+
+PdfHandler.prototype.getPageCount = function() {
+    return new Promise((async (res, rej) => {
+        var doc = await this.loadDocument();
+        res(doc ? doc.numPages : 0);
+    }).bind(this));
+};
+
+PdfHandler.prototype.getThumbmailViewport = function(page) {
+    const maxSize = require("../config").thumbmailMaxSize;
+    var fullSize = page.getViewport(1),
+        ratio = Math.min(maxSize /fullSize.height, maxSize /fullSize.width);
+    return page.getViewport(ratio);
+};
+
+PdfHandler.prototype.getPngPage = function(page, isThumbmail) {
+    return new Promise((async (res, rej) => {
+        var doc = await this.loadDocument();
+        if (!doc) {
+            res(null);
+            return;
+        }
+        var pageContent = await doc.getPage(page),
+            viewport = isThumbmail ? this.getThumbmailViewport(pageContent) : pageContent.getViewport(1),
+            canvasFactory = new NodeCanvasFactory(),
+            canvas = canvasFactory.create(viewport.width, viewport.height);
+        pageContent.render({
+            canvasContext: canvas.context,
+            viewport: viewport,
+            canvasFactory: canvasFactory
+        }).promise.then(() => {
+            canvas.canvas.toBuffer((err, buf) => res(buf), "image/png");
+        });
+    }).bind(this));
+};
+
+module.exports.PdfHandler = PdfHandler;
+

+ 72 - 0
src/query/queryBnf.js

@@ -0,0 +1,72 @@
+
+const QueryDocument = require("./queryDocument.js"),
+    newBook = QueryDocument.newBook,
+    Xml2js = require("xml2js"),
+    http = require("http");
+
+function parseTitleAndAuthor(str) {
+    var arr = (str || "").split("/", 2);
+    return {
+        title: (arr[0] || str || "").trim(),
+        author: (arr[1] || "").trim()
+    };
+}
+
+class BnfQueryEngine {
+}
+
+BnfQueryEngine.prototype.parseBooks = function(isbn, data, res) {
+    if (!data["srw:searchRetrieveResponse"] ||
+        !data["srw:searchRetrieveResponse"]["srw:records"] ||
+        !data["srw:searchRetrieveResponse"]["srw:records"][0] ||
+        !data["srw:searchRetrieveResponse"]["srw:records"][0]["srw:record"]) {
+        console.error("Incomplete XML data while querying BNF for ISBN " +isbn);
+        res([]);
+        return;
+    }
+    data = data["srw:searchRetrieveResponse"]["srw:records"][0]["srw:record"];
+    var result = [];
+    for (var i =0, len = data.length; i < len; ++i) {
+        if (!data[i]["srw:recordData"] ||
+            !data[i]["srw:recordData"][0] ||
+            !data[i]["srw:recordData"][0]["oai_dc:dc"] ||
+            !data[i]["srw:recordData"][0]["oai_dc:dc"][0])
+            continue;
+        var currentData = data[i]["srw:recordData"][0]["oai_dc:dc"][0],
+            titleAndAuthor = parseTitleAndAuthor((currentData["dc:title"] || [""])[0]);
+        var book = newBook(
+            titleAndAuthor.title,
+            titleAndAuthor.author,
+            (currentData["dc:identifier"] || [""]).join(" "), // identifier (isbn/issn)
+        );
+        result.push(book);
+    }
+    res(result);
+}
+
+BnfQueryEngine.prototype.byIsbn = function(isbn) {
+    return new Promise((res, rej) => {
+        http.get("http://catalogue.bnf.fr/api/SRU?version=1.2&operation=searchRetrieve&query=bib.isbn%20any%20%22" +isbn +"%22&recordSchema=dublincore&maximumRecords=20&startRecord=1", resp => {
+            var chunks = [];
+            resp.on("data", chunk => chunks.push(chunk));
+            resp.once("end", () => {
+                var xmlResult = Buffer.concat(chunks).toString("utf-8");
+                Xml2js.parseString(xmlResult, ((err, xmlData) => {
+                    if (err) {
+                        console.error("Invalid XML data from querying BNF for ISBN " +isbn, e);
+                        res([]);
+                        return;
+                    }
+                    this.parseBooks(isbn, xmlData, res);
+                }).bind(this));
+            });
+            resp.on("error", (e) => {
+                console.error("Error querying BNF for ISBN " +isbn, e);
+                res([]);
+            });
+        });
+    });
+};
+
+QueryDocument.register(new BnfQueryEngine());
+

+ 33 - 0
src/query/queryDocument.js

@@ -0,0 +1,33 @@
+
+function QueryEngine() {
+}
+QueryEngine.prototype.byIsbn = function() {};
+
+var engines = [];
+
+module.exports = (function() {
+    var engines = [];
+
+    var searchByISBN = async function(isbn) {
+        var all = await Promise.all(engines.map(i => i.byIsbn(isbn)));
+        var result = [];
+        all.forEach(i => i.forEach(j => result.push(j)));
+        return result;
+    };
+
+    return {
+        ByISBN: searchByISBN,
+        QueryEngine: QueryEngine,
+        newBook: function(title, author, identifier) {
+            return {
+                title: title,
+                author: author,
+                identifier: identifier
+            };
+        },
+        register: function(engine) {
+            engines.push(engine);
+        }
+    };
+})();
+

+ 6 - 0
src/query/queryWrapper.js

@@ -0,0 +1,6 @@
+
+const QueryDocument = require("./queryDocument.js");
+require("./queryBnf.js");
+
+module.exports.searchISBN = QueryDocument.ByISBN;
+

+ 7 - 1
src/sequelize.js

@@ -3,8 +3,14 @@ const config = require("../config.js").db,
     Sequelize = require("sequelize"),
     sequelize = new Sequelize(config.database, config.username, config.password, { dialect: "mysql", host: config.server });
 
+const User = sequelize.import(__dirname + "/../models/users.js"),
+    DocumentType = sequelize.import(__dirname + "/../models/document.js");
+
 module.exports = {
+    ready: sequelize.sync({ alter: true }),
     sequelize: sequelize,
-    User: function() { return sequelize.import(__dirname + "/../models/users.js"); }
+    User: User,
+    Document: DocumentType.Document,
+    DocumentCover: DocumentType.DocumentCover
 };
 

+ 44 - 13
src/templateArgs.js

@@ -1,22 +1,53 @@
 
-const User = require("./sequelize.js").User();
+const User = require("./sequelize.js").User;
 
-module.exports.generate = function(req) {
-  var args = {
-    title: "Totoro Book",
-  };
+var loadUsers = module.exports.loadUser = function(req) {
   return new Promise((res, rej) => {
-    if (req && req.session && req.session.userId) {
-        User.findAll({ where: { id: req.session.userId }}).then(users => {
-          if (users && users[0]) {
-            args.user = users[0];
-            args.username = args.user.username;
-          }
-          res(args);
+    if (req.user !== undefined) {
+      res(req.user);
+      return;
+    }
+    if (req.session && req.session.userId) {
+        User.findOne({ where: { id: req.session.userId }}).then(user => {
+          res(req.user = user);
         });
     } else {
-      res(args);
+      res(req.user = null);
     }
   });
 }
 
+var generate = module.exports.generate = function(req) {
+  return new Promise((res, rej) => {
+    loadUsers(req).then((user) => {
+      var args = {
+        title: "Totoro Book",
+      };
+      if (user) {
+        args.user = user;
+        args.username = user.username;
+      }
+      res(args);
+    });
+  });
+}
+
+module.exports.needLogin = function(req, res) {
+  return new Promise((success, reject) => {
+    loadUsers(req).then((user) => {
+      if (!user) {
+        res.locals.message = "Access Denied";
+        res.locals.error = {};
+
+        // render the error page
+        generate(req).then(args => {
+          res.status(403);
+          res.render('error', args);
+        });
+      } else {
+        success();
+      }
+    });
+  });
+}
+

+ 18 - 0
views/include/book.pug

@@ -0,0 +1,18 @@
+mixin book(book)
+  article.book
+    h5 #{book.title}
+    h6 #{book.author}
+    .cover
+      a(href=`javascript:displayCover("${book.id}")`)
+        img(src=`/book/${book.id}/cover.png`)
+    if (user)
+      .edit
+        h7
+          img(src="images/book_edit.svg")
+        a(href=`javascript:editDocument("${book.id}")`) Editer
+    h7
+      img(src="images/book_download.svg")
+    .dl-links
+      a(class="btn" href=`/book/${book.id}/download.pdf`) PDF
+      a(class="btn" href=`/book/${book.id}/download.epub`) ePUB
+

+ 2 - 1
views/include/headbar.pug

@@ -9,7 +9,8 @@ block header
       ul
         if username
           li
-            input(type='text')
+            input(type='text', class='search')
+          li
             a(href='/settings')
               img(src='images/settings.svg')
         else

+ 2 - 0
views/include/layout.pug

@@ -7,3 +7,5 @@ html
     include headbar
     div(class='page')
       block content
+    script(src="/javascripts/scripts.js")
+

+ 0 - 1
views/index.pug

@@ -2,4 +2,3 @@ extends include/layout
 
 block content
   h1= title
-  p Welcome to #{username}

+ 13 - 0
views/indexUser.pug

@@ -0,0 +1,13 @@
+
+extends include/layout
+include include/book
+
+block content
+  h1 Home
+  p Welcome to #{username}
+  h2 Recently added Books
+  ul(class='booklist horizontal')
+    each i in books
+      li
+        +book(i)
+

+ 5 - 0
views/settings.pug

@@ -0,0 +1,5 @@
+extends include/layout
+
+block content
+  h1 Settings
+  p Welcome to #{username}

Некоторые файлы не были показаны из-за большого количества измененных файлов