New minor version. Database upgrade preparations.
[dorian] / model / book.cpp
1 #include <QDir>
2 #include <QString>
3 #include <QDebug>
4 #include <QtXml>
5 #include <qtextdocument.h>  // Qt::escape is currently defined here...
6 #include <QDirIterator>
7 #include <QFileInfo>
8 #include <QtAlgorithms>
9 #include <QCryptographicHash>
10
11 #include "book.h"
12 #include "opshandler.h"
13 #include "xmlerrorhandler.h"
14 #include "extractzip.h"
15 #include "library.h"
16 #include "containerhandler.h"
17 #include "ncxhandler.h"
18 #include "trace.h"
19
20 const int COVER_WIDTH = 53;
21 const int COVER_HEIGHT = 59;
22
23 static QImage makeCover(const QString &path)
24 {
25     return QImage(path).scaled(COVER_WIDTH, COVER_HEIGHT,
26         Qt::KeepAspectRatioByExpanding, Qt::SmoothTransformation).
27         scaled(COVER_WIDTH, COVER_HEIGHT, Qt::KeepAspectRatio);
28 }
29
30 Book::Book(const QString &p, QObject *parent): QObject(parent)
31 {
32     mPath = "";
33     if (p.size()) {
34         QFileInfo info(p);
35         mPath = info.absoluteFilePath();
36         title = info.baseName();
37         cover = makeCover(":/icons/book.png");
38         mTempFile.open();
39     }
40 }
41
42 QString Book::path() const
43 {
44     return mPath;
45 }
46
47 bool Book::open()
48 {
49     Trace t("Book::open");
50     qDebug() << path();
51     close();
52     clear();
53     if (path().isEmpty()) {
54         title = "No book";
55         return false;
56     }
57     if (!extract(QStringList())) {
58         return false;
59     }
60     if (!parse()) {
61         return false;
62     }
63     save();
64     emit opened(path());
65     return true;
66 }
67
68 void Book::peek()
69 {
70     Trace t("Book::peek");
71     qDebug() << path();
72     close();
73     clear();
74     if (path().isEmpty()) {
75         title = "No book";
76         return;
77     }
78     if (!extractMetaData()) {
79         return;
80     }
81     if (!parse()) {
82         return;
83     }
84     save();
85     close();
86 }
87
88 void Book::close()
89 {
90     Trace t("Book::close");
91     content.clear();
92     parts.clear();
93     QDir::setCurrent(QDir::rootPath());
94     clearDir(tmpDir());
95 }
96
97 QString Book::tmpDir() const
98 {
99     QString tmpName = QFileInfo(mTempFile.fileName()).fileName();
100     return QDir(QDir::temp().absoluteFilePath("dorian")).
101             absoluteFilePath(tmpName);
102 }
103
104 bool Book::extract(const QStringList &excludedExtensions)
105 {
106     Trace t("Book::extract");
107     bool ret = false;
108     QString tmp = tmpDir();
109     qDebug() << "Extracting" << mPath << "to" << tmp;
110
111     QDir::setCurrent(QDir::rootPath());
112     if (!clearDir(tmp)) {
113         qCritical() << "Book::extract: Failed to remove" << tmp;
114         return false;
115     }
116     QDir d(tmp);
117     if (!d.exists()) {
118         if (!d.mkpath(tmp)) {
119             qCritical() << "Book::extract: Could not create" << tmp;
120             return false;
121         }
122     }
123
124     // If book comes from resource, copy it to the temporary directory first
125     QString bookPath = path();
126     if (bookPath.startsWith(":/books/")) {
127         QFile src(bookPath);
128         QString dst(QDir(tmp).absoluteFilePath("book.epub"));
129         if (!src.copy(dst)) {
130             qCritical() << "Book::extract: Failed to copy built-in book"
131                     << bookPath << "to" << dst;
132             return false;
133         }
134         bookPath = dst;
135     }
136
137     QString oldDir = QDir::currentPath();
138     if (!QDir::setCurrent(tmp)) {
139         qCritical() << "Book::extract: Could not change to" << tmp;
140         return false;
141     }
142     ret = extractZip(bookPath, excludedExtensions);
143     if (!ret) {
144         qCritical() << "Book::extract: Extracting ZIP failed";
145     }
146     QDir::setCurrent(oldDir);
147     return ret;
148 }
149
150 bool Book::parse()
151 {
152     Trace t("Book::parse");
153
154     // Parse OPS file
155     bool ret = false;
156     QString opsFileName = opsPath();
157     qDebug() << "Parsing OPS file" << opsFileName;
158     QFile opsFile(opsFileName);
159     QXmlSimpleReader reader;
160     QXmlInputSource *source = new QXmlInputSource(&opsFile);
161     OpsHandler *opsHandler = new OpsHandler(*this);
162     XmlErrorHandler *errorHandler = new XmlErrorHandler();
163     reader.setContentHandler(opsHandler);
164     reader.setErrorHandler(errorHandler);
165     ret = reader.parse(source);
166     delete errorHandler;
167     delete opsHandler;
168     delete source;
169
170     // Initially, put all content items in the chapter list.
171     // This will be refined by parsing the NCX file later
172     chapters = parts;
173
174     // Load cover image
175     QString coverPath;
176     QStringList coverKeys;
177     coverKeys << "cover-image" << "img-cover-jpeg" << "cover";
178     foreach (QString key, coverKeys) {
179         if (content.contains(key)) {
180             coverPath = QDir(rootPath()).absoluteFilePath(content[key].href);
181             break;
182         }
183     }
184     if (coverPath.isEmpty()) {
185         // Last resort
186         QString coverJpeg = QDir(rootPath()).absoluteFilePath("cover.jpg");
187         if (QFileInfo(coverJpeg).exists()) {
188             coverPath = coverJpeg;
189         }
190     }
191     if (!coverPath.isEmpty()) {
192         qDebug() << "Loading cover image from" << coverPath;
193         cover = makeCover(coverPath);
194     }
195
196     // If there is an "ncx" item in content, parse it: That's the real table of
197     // contents
198     QString ncxFileName;
199     if (content.contains("ncx")) {
200         ncxFileName = content["ncx"].href;
201     } else if (content.contains("ncxtoc")) {
202         ncxFileName = content["ncxtoc"].href;
203     } else if (content.contains("toc")) {
204         ncxFileName = content["toc"].href;
205     } else {
206         qDebug() << "No NCX table of contents";
207     }
208     if (!ncxFileName.isEmpty()) {
209         qDebug() << "Parsing NCX file" << ncxFileName;
210         QFile ncxFile(QDir(rootPath()).absoluteFilePath(ncxFileName));
211         source = new QXmlInputSource(&ncxFile);
212         NcxHandler *ncxHandler = new NcxHandler(*this);
213         errorHandler = new XmlErrorHandler();
214         reader.setContentHandler(ncxHandler);
215         reader.setErrorHandler(errorHandler);
216         ret = reader.parse(source);
217         delete errorHandler;
218         delete ncxHandler;
219         delete source;
220     }
221
222     // Calculate book part sizes
223     size = 0;
224     foreach (QString part, parts) {
225         QFileInfo info(QDir(rootPath()).absoluteFilePath(content[part].href));
226         content[part].size = info.size();
227         size += content[part].size;
228     }
229
230     return ret;
231 }
232
233 bool Book::clearDir(const QString &dir)
234 {
235     QDir d(dir);
236     if (!d.exists()) {
237         return true;
238     }
239     QDirIterator i(dir, QDirIterator::Subdirectories);
240     while (i.hasNext()) {
241         QString entry = i.next();
242         if (entry.endsWith("/.") || entry.endsWith("/..")) {
243             continue;
244         }
245         QFileInfo info(entry);
246         if (info.isDir()) {
247             if (!clearDir(entry)) {
248                 return false;
249             }
250         }
251         else {
252             if (!QFile::remove(entry)) {
253                 qCritical() << "Book::clearDir: Could not remove" << entry;
254                 // FIXME: To be investigated: This is happening too often
255                 // return false;
256             }
257         }
258     }
259     (void)d.rmpath(dir);
260     return true;
261 }
262
263 void Book::clear()
264 {
265     close();
266     title = "";
267     creators.clear();
268     date = "";
269     publisher = "";
270     datePublished = "";
271     subject = "";
272     source = "";
273     rights = "";
274 }
275
276 void Book::load()
277 {
278     Trace t("Book::load");
279     qDebug() << "path" << path();
280     QSettings settings;
281     QString key = "book/" + path() + "/";
282     qDebug() << "key" << key;
283
284     // Load book info
285     title = settings.value(key + "title").toString();
286     qDebug() << title;
287     creators = settings.value(key + "creators").toStringList();
288     date = settings.value(key + "date").toString();
289     publisher = settings.value(key + "publisher").toString();
290     datePublished = settings.value(key + "datepublished").toString();
291     subject = settings.value(key + "subject").toString();
292     source = settings.value(key + "source").toString();
293     rights = settings.value(key + "rights").toString();
294     mLastBookmark.part = settings.value(key + "lastpart").toInt();
295     mLastBookmark.pos = settings.value(key + "lastpos").toReal();
296     cover = settings.value(key + "cover").value<QImage>().scaled(COVER_WIDTH,
297         COVER_HEIGHT, Qt::IgnoreAspectRatio, Qt::SmoothTransformation);
298     if (cover.isNull()) {
299         cover = makeCover(":/icons/book.png");
300     }
301
302     // Load bookmarks
303     int size = settings.value(key + "bookmarks").toInt();
304     for (int i = 0; i < size; i++) {
305         int part = settings.value(key + "bookmark" + QString::number(i) +
306                                      "/part").toInt();
307         qreal pos = settings.value(key + "bookmark" + QString::number(i) +
308                                    "/pos").toReal();
309         qDebug() << QString("Bookmark %1 at part %2, %3").
310                 arg(i).arg(part).arg(pos);
311         mBookmarks.append(Bookmark(part, pos));
312     }
313 }
314
315 void Book::save()
316 {
317     Trace t("Book::save");
318     QSettings settings;
319     QString key = "book/" + path() + "/";
320     qDebug() << "key" << key;
321
322     // Save book info
323     settings.setValue(key + "title", title);
324     qDebug() << "title" << title;
325     settings.setValue(key + "creators", creators);
326     settings.setValue(key + "date", date);
327     settings.setValue(key + "publisher", publisher);
328     settings.setValue(key + "datepublished", datePublished);
329     settings.setValue(key + "subject", subject);
330     settings.setValue(key + "source", source);
331     settings.setValue(key + "rights", rights);
332     settings.setValue(key + "lastpart", mLastBookmark.part);
333     settings.setValue(key + "lastpos", mLastBookmark.pos);
334     settings.setValue(key + "cover", cover);
335
336     // Save bookmarks
337     settings.setValue(key + "bookmarks", mBookmarks.size());
338     for (int i = 0; i < mBookmarks.size(); i++) {
339         qDebug() << QString("Bookmark %1 at %2, %3").
340                 arg(i).arg(mBookmarks[i].part).arg(mBookmarks[i].pos);
341         settings.setValue(key + "bookmark" + QString::number(i) + "/part",
342                           mBookmarks[i].part);
343         settings.setValue(key + "bookmark" + QString::number(i) + "/pos",
344                           mBookmarks[i].pos);
345     }
346 }
347
348 void Book::setLastBookmark(int part, qreal position)
349 {
350     mLastBookmark.part = part;
351     mLastBookmark.pos = position;
352     save();
353 }
354
355 Book::Bookmark Book::lastBookmark() const
356 {
357     return Book::Bookmark(mLastBookmark);
358 }
359
360 void Book::addBookmark(int part, qreal position)
361 {
362     mBookmarks.append(Bookmark(part, position));
363     qSort(mBookmarks.begin(), mBookmarks.end());
364     save();
365 }
366
367 void Book::deleteBookmark(int index)
368 {
369     mBookmarks.removeAt(index);
370     save();
371 }
372
373 QList<Book::Bookmark> Book::bookmarks() const
374 {
375     return mBookmarks;
376 }
377
378 QString Book::opsPath()
379 {
380     Trace t("Book::opsPath");
381     QString ret;
382
383     QFile container(tmpDir() + "/META-INF/container.xml");
384     qDebug() << container.fileName();
385     QXmlSimpleReader reader;
386     QXmlInputSource *source = new QXmlInputSource(&container);
387     ContainerHandler *containerHandler = new ContainerHandler();
388     XmlErrorHandler *errorHandler = new XmlErrorHandler();
389     reader.setContentHandler(containerHandler);
390     reader.setErrorHandler(errorHandler);
391     if (reader.parse(source)) {
392         ret = tmpDir() + "/" + containerHandler->rootFile;
393         mRootPath = QFileInfo(ret).absoluteDir().absolutePath();
394         qDebug() << "OSP path" << ret << "\nRoot dir" << mRootPath;
395     }
396     delete errorHandler;
397     delete containerHandler;
398     delete source;
399     return ret;
400 }
401
402 QString Book::rootPath() const
403 {
404     return mRootPath;
405 }
406
407 QString Book::name() const
408 {
409     if (title.size()) {
410         QString ret = title;
411         if (creators.length()) {
412             ret += "\nBy " + creators[0];
413             for (int i = 1; i < creators.length(); i++) {
414                 ret += ", " + creators[i];
415             }
416         }
417         return ret;
418     } else {
419         return path();
420     }
421 }
422
423 QString Book::shortName() const
424 {
425     return (title.isEmpty())? QFileInfo(path()).baseName(): title;
426 }
427
428 int Book::chapterFromPart(int index)
429 {
430     int ret = -1;
431
432     QString partId = parts[index];
433     QString partHref = content[partId].href;
434
435     for (int i = 0; i < chapters.size(); i++) {
436         QString id = chapters[i];
437         QString href = content[id].href;
438         QString baseRef(href);
439         QUrl url(QString("file://") + href);
440         if (url.hasFragment()) {
441             QString fragment = url.fragment();
442             baseRef.chop(fragment.length() + 1);
443         }
444         if (baseRef == partHref) {
445             ret = i;
446             // Don't break, keep looking
447         }
448     }
449
450     return ret;
451 }
452
453 int Book::partFromChapter(int index)
454 {
455     Trace t("Book::partFromChapter");
456     QString id = chapters[index];
457     QString href = content[id].href;
458     int hashPos = href.indexOf("#");
459     if (hashPos != -1) {
460         href = href.left(hashPos);
461     }
462
463     qDebug() << "Chapter" << index;
464     qDebug() << " id" << id;
465     qDebug() << " href" << href;
466
467     for (int i = 0; i < parts.size(); i++) {
468         QString partId = parts[i];
469         if (content[partId].href == href) {
470             qDebug() << "Part index for" << href << "is" << i;
471             return i;
472         }
473     }
474
475     qWarning() << "Book::partFromChapter: Could not find part index for"
476             << href;
477     return -1;
478 }
479
480 qreal Book::getProgress(int part, qreal position)
481 {
482     Q_ASSERT(part < parts.size());
483     QString key;
484     qreal partSize = 0;
485     for (int i = 0; i < part; i++) {
486         key = parts[i];
487         partSize += content[key].size;
488     }
489     key = parts[part];
490     partSize += content[key].size * position;
491     return partSize / (qreal)size;
492 }
493
494 bool Book::extractMetaData()
495 {
496     QStringList excludedExtensions;
497     excludedExtensions << ".html" << ".xhtml" << ".xht" << ".htm";
498     return extract(excludedExtensions);
499 }
500
501 void Book::upgrade()
502 {
503     Trace t("Book::upgrade");
504     qDebug() << path();
505 }