diff --git a/qml/pages/BookDetailPage.qml b/qml/pages/BookDetailPage.qml index eba658d..5748655 100644 --- a/qml/pages/BookDetailPage.qml +++ b/qml/pages/BookDetailPage.qml @@ -8,6 +8,8 @@ Page { property string coverSource: "" property bool downloading: false property string downloadError: "" + property bool readingNow: false + property string readingUrl: "" SilicaFlickable { anchors.fill: parent @@ -29,6 +31,14 @@ Page { visible: source.length > 0 } + Button { + anchors.horizontalCenter: parent.horizontalCenter + text: "Leggi (EPUB)" + visible: page.hasEpubFormat() + enabled: !page.downloading + onClicked: startReading() + } + Label { x: Theme.horizontalPageMargin width: parent.width - 2 * Theme.horizontalPageMargin @@ -157,6 +167,50 @@ Page { return out.trim() } + function hasEpubFormat() { + var fmts = bookData.formats + if (!fmts) + return false + for (var i = 0; i < fmts.length; i++) + if (String(fmts[i].format).toLowerCase() === "epub") + return true + return false + } + + function firstEpubFormat() { + var fmts = bookData.formats + if (!fmts) + return null + for (var i = 0; i < fmts.length; i++) + if (String(fmts[i].format).toLowerCase() === "epub") + return fmts[i] + return null + } + + // estrae l'id numerico calibre da un url di download: /opds/download/// + function extractBookId(u) { + var parts = u.split("/") + for (var i = 0; i < parts.length - 1; i++) + if (parts[i] === "download" && parts[i + 1].length > 0 + && !isNaN(parseInt(parts[i + 1]))) + return parseInt(parts[i + 1]) + return 0 + } + + function startReading() { + var f = page.firstEpubFormat() + if (!f) + return + var id = page.extractBookId(f.url) + if (id <= 0) + return + page.readingNow = true + page.readingUrl = f.url + page.downloading = true + page.downloadError = "" + downloader.download(resolveUrl(f.url), appSettings.bookFilePath(id, "epub")) + } + function startDownload(format) { if (page.downloading) return @@ -196,11 +250,19 @@ Page { onFinished: { page.downloading = false if (ok) { + if (page.readingNow) { + page.readingNow = false + var id = page.extractBookId(page.readingUrl) + pageStack.push(Qt.resolvedUrl("ReaderPage.qml"), + { epubPath: path, bookId: id, bookFormat: "epub" }) + return + } page.downloadError = "" page.downloadNotice = "Salvato in " + path hideNoticeTimer.restart() } else { page.downloadNotice = "" + page.readingNow = false page.downloadError = "Errore: " + error } } diff --git a/qml/pages/ChapterListDialog.qml b/qml/pages/ChapterListDialog.qml new file mode 100644 index 0000000..8c2f77f --- /dev/null +++ b/qml/pages/ChapterListDialog.qml @@ -0,0 +1,41 @@ +import QtQuick 2.0 +import Sailfish.Silica 1.0 + +Dialog { + id: dialog + + property var model: [] + property int selectedIndex: 0 + + SilicaListView { + anchors.fill: parent + header: DialogHeader { title: "Indice" } + model: dialog.model + + delegate: ListItem { + id: item + contentHeight: Theme.itemSizeMedium + + Label { + anchors.left: parent.left + anchors.leftMargin: Theme.horizontalPageMargin + anchors.right: parent.right + anchors.rightMargin: Theme.horizontalPageMargin + anchors.verticalCenter: parent.verticalCenter + text: (modelData.index + 1) + ". " + modelData.title + color: item.highlighted ? Theme.highlightColor : Theme.primaryColor + truncationMode: TruncationMode.Elide + } + + onClicked: { + dialog.selectedIndex = modelData.index + dialog.accept() + } + } + + ViewPlaceholder { + enabled: dialog.model.length === 0 + text: "Nessun capitolo" + } + } +} diff --git a/qml/pages/ReaderPage.qml b/qml/pages/ReaderPage.qml new file mode 100644 index 0000000..85808fc --- /dev/null +++ b/qml/pages/ReaderPage.qml @@ -0,0 +1,227 @@ +import QtQuick 2.0 +import Sailfish.Silica 1.0 +import Sailfish.WebView 1.0 +import harbour.calibreweb 1.0 + +Page { + id: page + + property string epubPath: "" + property int bookId: 0 + property string bookFormat: "epub" + + property int currentChapter: 0 + property double chapterFraction: 0.0 + property bool restoring: false + property int saveCounter: 0 + property int syncCounter: 0 + property string errorMessage: "" + + allowedOrientations: Orientation.All + + EpubBook { + id: book + } + + SilicaFlickable { + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: toolbar.top + contentHeight: parent.height + WebView { + id: web + anchors.fill: parent + active: true + onLoadedChanged: { + if (!loaded) + return + if (page.restoring) + page.restoring = false + else + restorePosition() + page.ready = true + } + } + } + + BusyIndicator { + anchors.centerIn: parent + running: !page.ready + size: BusyIndicatorSize.Large + } + + Label { + anchors.centerIn: parent + text: page.errorMessage + color: Theme.errorColor + visible: page.errorMessage.length > 0 + } + + // ---- barra di controllo ---- + Rectangle { + id: toolbar + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + height: Theme.itemSizeMedium + color: Theme.rgba(Theme.primaryColor, 0.08) + + IconButton { + id: prevBtn + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + icon.source: "image://theme/icon-m-left" + enabled: page.currentChapter > 0 + onClicked: gotoChapter(page.currentChapter - 1) + } + + Label { + id: chapterLabel + anchors.left: prevBtn.right + anchors.right: nextBtn.left + anchors.verticalCenter: parent.verticalCenter + text: (book.chapterCount > 0) + ? (page.currentChapter + 1) + "/" + book.chapterCount + + " · " + book.chapterTitle(page.currentChapter) + + " · " + Math.round(page.overallProgress() * 100) + "%" + : "…" + truncationMode: TruncationMode.Elide + color: Theme.primaryColor + horizontalAlignment: Text.AlignHCenter + font.pixelSize: Theme.fontSizeSmall + } + + IconButton { + id: nextBtn + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + icon.source: "image://theme/icon-m-right" + enabled: page.currentChapter < book.chapterCount - 1 + onClicked: gotoChapter(page.currentChapter + 1) + } + + ProgressBar { + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + height: 6 + value: page.overallProgress() + minimumValue: 0 + maximumValue: 1 + } + } + + PullDownMenu { + MenuItem { + text: "Indice capitoli" + onClicked: showChapterList() + } + MenuItem { + text: "Aggiorna posizione sul server" + onClicked: syncNow() + } + } + + // ---- timer di tracciamento ---- + Timer { + id: trackTimer + interval: 2000 + running: page.ready + repeat: true + onTriggered: readScrollPosition() + } + + function overallProgress() { + if (book.chapterCount <= 0) + return 0 + return (page.currentChapter + page.chapterFraction) / book.chapterCount + } + + function readScrollPosition() { + if (!web.loaded) + return + web.runJavaScript( + "window.scrollY / Math.max(1, document.documentElement.scrollHeight - window.innerHeight)", + function(f) { + var v = Math.max(0, Math.min(1, f)) + if (Math.abs(v - page.chapterFraction) > 0.005) { + page.chapterFraction = v + maybeSave(true) + } + }) + } + + function maybeSave(force) { + if (bookId <= 0) + return + page.saveCounter++ + // salva locale ogni ~10 s, sync server ogni ~30 s + if (force || page.saveCounter % 5 === 0) { + appSettings.setBookmarkKey(page.bookId, page.bookFormat, + book.makeBookmarkKey(page.currentChapter, page.chapterFraction)) + } + if (force || page.syncCounter++ % 15 === 0) + syncNow() + } + + function syncNow() { + if (bookId <= 0) + return + apiClient.postBookmark(page.bookId, page.bookFormat, + book.makeBookmarkKey(page.currentChapter, page.chapterFraction)) + } + + function gotoChapter(index) { + if (index < 0 || index >= book.chapterCount) + return + if (index !== page.currentChapter) { + maybeSave(true) + page.chapterFraction = 0 + } + page.currentChapter = index + page.restoring = true + page.ready = false + web.url = "file://" + book.chapterFilePath(index) + } + + function restorePosition() { + if (page.chapterFraction > 0.01) { + web.runJavaScript( + "window.scrollTo(0, " + page.chapterFraction + + " * Math.max(1, document.documentElement.scrollHeight - window.innerHeight)); true", + function() { }) + } + } + + function showChapterList() { + var dialog = pageStack.push(Qt.resolvedUrl("ChapterListDialog.qml"), + { model: book.chapterList() }) + dialog.accepted.connect(function() { + gotoChapter(dialog.selectedIndex) + }) + } + + Component.onCompleted: { + page.ready = false + if (!book.open(page.epubPath)) { + page.errorMessage = "Impossibile aprire il libro EPUB" + page.ready = true + return + } + // ripristina il segno locale + var key = appSettings.bookmarkKey(page.bookId, page.bookFormat) + var ch = 0 + var fr = 0.0 + if (key.length > 0 && book.parseBookmarkKey(key, ch, fr)) { + page.currentChapter = Math.max(0, Math.min(ch, book.chapterCount - 1)) + page.chapterFraction = fr + } + web.url = "file://" + book.chapterFilePath(page.currentChapter) + } + + onStatusChanged: { + if (status === PageStatus.Inactive) + maybeSave(true) + } +} diff --git a/src/apiclient.cpp b/src/apiclient.cpp index 2aebdc3..d8a920e 100644 --- a/src/apiclient.cpp +++ b/src/apiclient.cpp @@ -10,6 +10,7 @@ #include #include #include +#include #include #include #include @@ -134,6 +135,90 @@ static bool looksLikeImage(const QByteArray &data) || data.startsWith("GIF8") || data.startsWith("RIFF"); } +void ApiClient::postBookmark(int bookId, const QString &format, const QString &key) +{ + const QString url = m_settings->baseUrl() + QStringLiteral("/ajax/bookmark/") + + QString::number(bookId) + QLatin1Char('/') + format; + qDebug() << "postBookmark:" << url << "key=" << key; + + const QUrl requestUrl(url); + QNetworkRequest request(requestUrl); + request.setRawHeader("User-Agent", "harbour-calibreweb/0.1"); + request.setHeader(QNetworkRequest::ContentTypeHeader, + QLatin1String("application/x-www-form-urlencoded")); + applyAuth(request); + const QByteArray body = "bookmark=" + QUrl::toPercentEncoding(key); + + QNetworkReply *reply = m_nam.post(request, body); + if (m_settings->ignoreSslErrors()) { + connect(reply, &QNetworkReply::sslErrors, reply, + static_cast &)>( + &QNetworkReply::ignoreSslErrors)); + } + connect(reply, &QNetworkReply::finished, this, [this, reply, url, key]() { + reply->deleteLater(); + const int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + qDebug() << "postBookmark status:" << status; + // 400/403 = probabile CSRF attivo: recupera il token dalla pagina di login e riprova + if ((status == 400 || status == 403) && !m_csrfRetried) { + m_csrfRetried = true; + fetchCsrfTokenAndRetry(url, key); + } + }); +} + +void ApiClient::fetchCsrfTokenAndRetry(const QString &url, const QString &key) +{ + const QUrl loginUrl(m_settings->baseUrl() + QStringLiteral("/login")); + QNetworkRequest request(loginUrl); + request.setRawHeader("User-Agent", "harbour-calibreweb/0.1"); + applyAuth(request); + QNetworkReply *reply = m_nam.get(request); + if (m_settings->ignoreSslErrors()) { + connect(reply, &QNetworkReply::sslErrors, reply, + static_cast &)>( + &QNetworkReply::ignoreSslErrors)); + } + connect(reply, &QNetworkReply::finished, this, [this, reply, url, key]() { + reply->deleteLater(); + const QByteArray body = reply->readAll(); + // + QRegularExpression re(QStringLiteral( + "name=[\"']csrf_token[\"'][^>]*value=[\"']([^\"']+)[\"']")); + const QRegularExpressionMatch match = re.match(QString::fromUtf8(body)); + if (match.hasMatch()) { + postBookmarkWithCsrf(url, key, match.captured(1)); + } else { + qDebug() << "postBookmark: token CSRF non trovato nella pagina di login"; + } + m_csrfRetried = false; + }); +} + +void ApiClient::postBookmarkWithCsrf(const QString &url, const QString &key, + const QString &csrfToken) +{ + const QUrl requestUrl(url); + QNetworkRequest request(requestUrl); + request.setRawHeader("User-Agent", "harbour-calibreweb/0.1"); + request.setHeader(QNetworkRequest::ContentTypeHeader, + QLatin1String("application/x-www-form-urlencoded")); + request.setRawHeader("X-CSRFToken", csrfToken.toUtf8()); + applyAuth(request); + const QByteArray body = "bookmark=" + QUrl::toPercentEncoding(key); + QNetworkReply *reply = m_nam.post(request, body); + if (m_settings->ignoreSslErrors()) { + connect(reply, &QNetworkReply::sslErrors, reply, + static_cast &)>( + &QNetworkReply::ignoreSslErrors)); + } + connect(reply, &QNetworkReply::finished, this, [reply]() { + reply->deleteLater(); + const int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + qDebug() << "postBookmark (con CSRF) status:" << status; + }); +} + void ApiClient::fetchImage(const QString &url, const QString &id) { if (url.isEmpty()) { diff --git a/src/apiclient.h b/src/apiclient.h index 37ea93c..52482c0 100644 --- a/src/apiclient.h +++ b/src/apiclient.h @@ -21,6 +21,8 @@ public: Q_INVOKABLE void getFeed(const QString &url); Q_INVOKABLE void fetchImage(const QString &url, const QString &id); + // sincronizza la posizione di lettura su calibre-web (POST /ajax/bookmark) + Q_INVOKABLE void postBookmark(int bookId, const QString &format, const QString &key); signals: void feedReady(const QVariantList &entries, const QString &nextUrl, const QString &feedTitle); @@ -31,10 +33,14 @@ private: QNetworkReply *startGet(const QUrl &url); void applyAuth(QNetworkRequest &request); QString resolveUrl(const QString &relative) const; + void postBookmarkWithCsrf(const QString &url, const QString &key, + const QString &csrfToken); + void fetchCsrfTokenAndRetry(const QString &url, const QString &key); Settings *m_settings; QNetworkAccessManager m_nam; QString m_cacheDir; + bool m_csrfRetried = false; }; #endif // APICLIENT_H diff --git a/src/epub.cpp b/src/epub.cpp index 528242a..6998a28 100644 --- a/src/epub.cpp +++ b/src/epub.cpp @@ -129,7 +129,8 @@ QString readTextFile(const QString &path) // EpubBook // --------------------------------------------------------------------------- -EpubBook::EpubBook() +EpubBook::EpubBook(QObject *parent) + : QObject(parent) { } @@ -189,7 +190,9 @@ bool EpubBook::open(const QString &epubPath) return false; if (!parseOpf()) return false; - return !m_chapters.isEmpty(); + const bool ok = !m_chapters.isEmpty(); + emit changed(); + return ok; } bool EpubBook::parseContainer() @@ -397,6 +400,18 @@ QString EpubBook::chapterTitle(int index) const return m_chapters.at(index).title; } +QVariantList EpubBook::chapterList() const +{ + QVariantList list; + for (int i = 0; i < m_chapters.size(); ++i) { + QVariantMap m; + m.insert(QStringLiteral("index"), i); + m.insert(QStringLiteral("title"), m_chapters.at(i).title); + list.append(m); + } + return list; +} + QString EpubBook::makeBookmarkKey(int chapterIndex, double fraction) const { return QStringLiteral("cw:v1:%1:%2").arg(chapterIndex).arg(fraction, 0, 'f', 4); diff --git a/src/epub.h b/src/epub.h index baa1b4c..2b9498a 100644 --- a/src/epub.h +++ b/src/epub.h @@ -2,7 +2,9 @@ #define EPUB_H #include +#include #include +#include struct EpubChapter { @@ -11,27 +13,36 @@ struct EpubChapter QString title; }; -class EpubBook +class EpubBook : public QObject { + Q_OBJECT + + Q_PROPERTY(QString title READ title NOTIFY changed) + Q_PROPERTY(QString author READ author NOTIFY changed) + Q_PROPERTY(int chapterCount READ chapterCount NOTIFY changed) + public: - EpubBook(); + explicit EpubBook(QObject *parent = 0); ~EpubBook(); - bool open(const QString &epubPath); - void close(); + Q_INVOKABLE bool open(const QString &epubPath); + Q_INVOKABLE void close(); QString title() const { return m_title; } QString author() const { return m_author; } - QList chapters() const { return m_chapters; } - QString baseDir() const { return m_baseDir; } int chapterCount() const { return m_chapters.size(); } - QString chapterFilePath(int index) const; - QString chapterTitle(int index) const; + Q_INVOKABLE QString chapterFilePath(int index) const; + Q_INVOKABLE QString chapterTitle(int index) const; + // elenco [{index, title}] per la lista capitoli + Q_INVOKABLE QVariantList chapterList() const; // chiave segno locale, es. "cw:v1:3:0.42" (capitolo 3, 42% del capitolo) - QString makeBookmarkKey(int chapterIndex, double fraction) const; - bool parseBookmarkKey(const QString &key, int *chapterIndex, double *fraction) const; + Q_INVOKABLE QString makeBookmarkKey(int chapterIndex, double fraction) const; + Q_INVOKABLE bool parseBookmarkKey(const QString &key, int *chapterIndex, double *fraction) const; + +signals: + void changed(); private: bool extractZip(const QString &epubPath); diff --git a/src/main.cpp b/src/main.cpp index 25c9d3d..adb5e73 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -6,6 +6,7 @@ #include "apiclient.h" #include "downloader.h" +#include "epub.h" #include "settings.h" Q_DECL_EXPORT int main(int argc, char *argv[]) @@ -13,7 +14,9 @@ Q_DECL_EXPORT int main(int argc, char *argv[]) QGuiApplication *app = SailfishApp::application(argc, argv); QQuickView *view = SailfishApp::createView(); - qDebug() << "harbour-calibreweb v0.1.0 build" << __DATE__ << __TIME__; + qDebug() << "harbour-calibreweb v0.2.0 build" << __DATE__ << __TIME__; + + qmlRegisterType("harbour.calibreweb", 1, 0, "EpubBook"); Settings settings; ApiClient apiClient(&settings); diff --git a/src/settings.cpp b/src/settings.cpp index 37c1c45..6095531 100644 --- a/src/settings.cpp +++ b/src/settings.cpp @@ -87,3 +87,22 @@ QString Settings::baseUrl() const url.chop(1); return url; } + +QString Settings::bookmarkKey(int bookId, const QString &format) const +{ + return m_settings.value(QStringLiteral("bookmarks/%1/%2").arg(bookId).arg(format)) + .toString(); +} + +void Settings::setBookmarkKey(int bookId, const QString &format, const QString &key) +{ + m_settings.setValue(QStringLiteral("bookmarks/%1/%2").arg(bookId).arg(format), key); +} + +QString Settings::bookFilePath(int bookId, const QString &format) const +{ + const QString dir = QStandardPaths::writableLocation(QStandardPaths::CacheLocation) + + QStringLiteral("/books"); + QDir().mkpath(dir); + return dir + QStringLiteral("/%1.%2").arg(bookId).arg(format); +} diff --git a/src/settings.h b/src/settings.h index a8fbc2a..5162a28 100644 --- a/src/settings.h +++ b/src/settings.h @@ -39,6 +39,13 @@ public: // URL del server senza slash finale QString baseUrl() const; + // --- segnalibri (posizione di lettura, per libro+formato) --- + Q_INVOKABLE QString bookmarkKey(int bookId, const QString &format) const; + Q_INVOKABLE void setBookmarkKey(int bookId, const QString &format, const QString &key); + + // path del file libro scaricato in cache (per il lettore) + Q_INVOKABLE QString bookFilePath(int bookId, const QString &format) const; + signals: void serverUrlChanged(); void usernameChanged();