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