From 85b4f8e9b9dda5e73149baade92a058217bcbb9d Mon Sep 17 00:00:00 2001 From: Carlo Baratto Date: Wed, 12 Aug 2026 01:45:31 +0200 Subject: [PATCH] Client OPDS per Calibre Web (Sailfish OS): core C++ testato, UI Silica, packaging RPM --- .gitignore | 6 + README.md | 99 ++++++++++++++++ harbour-calibreweb.pro | 28 +++++ harbour-calibreweb.yaml | 15 +++ icons/harbour-calibreweb-108.png | Bin 0 -> 796 bytes icons/harbour-calibreweb.png | Bin 0 -> 629 bytes icons/make_icons.py | 55 +++++++++ qml/cover/CoverPage.qml | 23 ++++ qml/harbour-calibreweb.qml | 14 +++ qml/pages/BookDetailPage.qml | 176 +++++++++++++++++++++++++++++ qml/pages/FeedPage.qml | 187 +++++++++++++++++++++++++++++++ qml/pages/MainPage.qml | 107 ++++++++++++++++++ qml/pages/SearchPage.qml | 52 +++++++++ qml/pages/SettingsPage.qml | 103 +++++++++++++++++ rpm/harbour-calibreweb.desktop | 9 ++ rpm/harbour-calibreweb.spec | 47 ++++++++ src/apiclient.cpp | 135 ++++++++++++++++++++++ src/apiclient.h | 40 +++++++ src/downloader.cpp | 78 +++++++++++++ src/downloader.h | 34 ++++++ src/main.cpp | 27 +++++ src/opdsparser.cpp | 123 ++++++++++++++++++++ src/opdsparser.h | 22 ++++ src/settings.cpp | 88 +++++++++++++++ src/settings.h | 50 +++++++++ 25 files changed, 1518 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 harbour-calibreweb.pro create mode 100644 harbour-calibreweb.yaml create mode 100644 icons/harbour-calibreweb-108.png create mode 100644 icons/harbour-calibreweb.png create mode 100644 icons/make_icons.py create mode 100644 qml/cover/CoverPage.qml create mode 100644 qml/harbour-calibreweb.qml create mode 100644 qml/pages/BookDetailPage.qml create mode 100644 qml/pages/FeedPage.qml create mode 100644 qml/pages/MainPage.qml create mode 100644 qml/pages/SearchPage.qml create mode 100644 qml/pages/SettingsPage.qml create mode 100644 rpm/harbour-calibreweb.desktop create mode 100644 rpm/harbour-calibreweb.spec create mode 100644 src/apiclient.cpp create mode 100644 src/apiclient.h create mode 100644 src/downloader.cpp create mode 100644 src/downloader.h create mode 100644 src/main.cpp create mode 100644 src/opdsparser.cpp create mode 100644 src/opdsparser.h create mode 100644 src/settings.cpp create mode 100644 src/settings.h diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8761a50 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +build/ +build-*/ +*.rpm +*.user +Makefile +.qmake.stash diff --git a/README.md b/README.md new file mode 100644 index 0000000..130b802 --- /dev/null +++ b/README.md @@ -0,0 +1,99 @@ +# harbour-calibreweb + +Client **OPDS** per Sailfish OS: naviga e scarica libri dalla tua istanza +**Calibre Web** (o dal Content Server di Calibre). + +Funziona con qualsiasi server OPDS, ma è pensato per Calibre Web: +feed di navigazione, ricerca, copertine, download con progresso. + +## Perché OPDS e non l'API REST + +Le versioni recenti di calibre-web (upstream) **non hanno più l'API REST**: +`/api/*` è stata rimossa e non esiste in nessuna release 0.6.9+ né in master. +L'interfaccia stabile e documentata è **OPDS** (`/opds`, `/opds/new`, +`/opds/search?query=...`), che usa HTTP Basic Auth ed espone i link di +download per formato. OPDS è supportato anche dal Content Server di Calibre, +quindi questa app copre entrambi i backend. + +## Funzionalità + +- Sezioni del catalogo (novità, autori, categorie, scaffali, ...) dall'indice `/opds` +- Paginazione automatica (link `rel="next"`) +- Ricerca testuale (`/opds/search`) +- Copertine con cache locale +- Dettaglio libro: autore, descrizione, formati con dimensione +- Download con barra di progresso in `~/Downloads` +- Autenticazione HTTP Basic (utente vuoto = browse anonimo) +- Opzione "ignora errori SSL" per certificati autofirmati + +## Build + +Serve il [Sailfish SDK](https://sailfishos.org/wiki/Sailfish_SDK) +(su Linux: `flatpak install flathub org.sailfishos.sdk` o tarball). + +```sh +git clone harbour-calibreweb +cd harbour-calibreweb + +# scegli un target, es.: +sfdk target install SailfishOS-4.6.0.11-armv7hl +sfdk target use SailfishOS-4.6.0.11-armv7hl + +sfdk build # produce l'RPM in RPMS/ +sfdk deploy # installa sul dispositivo connesso +``` + +In alternativa, installa l'RPM a mano: + +```sh +pkcon install-local harbour-calibreweb-0.1.0-1.armv7hl.rpm +``` + +Target consigliati in base al dispositivo: `armv7hl` (Xperia X, Jolla C…), +`aarch64` (Xperia 10 II/III, Jolla C2), `x86_64` (emulatore). + +## Configurazione + +Apri l'app → menù → **Impostazioni**: + +| Campo | Valore | +|---|---| +| Indirizzo Calibre Web | es. `https://calibre.example.com` (o con sottopath) | +| Utente | vuoto se il browse anonimo è attivo su calibre-web | +| Password | quella dell'account (Basic Auth) | +| Ignora SSL | attivalo solo se il server ha un certificato autofirmato | + +Poi **Prova connessione** e naviga. + +## Struttura + +``` +src/ C++: settings (QSettings), apiclient (feed+copertine), + downloader (progressi), opdsparser (Atom/OPDS), main +qml/pages/ MainPage (indice), FeedPage (liste+paginazione), + BookDetailPage (dettaglio+download), SearchPage, SettingsPage +qml/cover/ cover dell'app +icons/ icone 86/108 px + script di generazione +rpm/ spec e desktop file +``` + +## Limiti noti + +- La password è salvata in chiaro nelle impostazioni dell'app + (`~/.config/harbour/calibreweb.conf`) — accettabile per uso personale. +- La ricerca usa il motore del server; risultati identici alla UI web. +- Il core C++ (parsing, auth, download) è testato con Qt 5.15 contro feed + OPDS reali e un mock con Basic Auth; la UI Silica richiede il SDK per + la verifica su dispositivo. + +## Test del core + +```sh +# mock server OPDS + test end-to-end (vedi repository /tmp in locale): +python3 /tmp/mock_opds.py +# compila e lancia /tmp/opds_test (Qt 5.15+) +``` + +## Licenza + +MIT diff --git a/harbour-calibreweb.pro b/harbour-calibreweb.pro new file mode 100644 index 0000000..1de7d98 --- /dev/null +++ b/harbour-calibreweb.pro @@ -0,0 +1,28 @@ +TEMPLATE = app +TARGET = harbour-calibreweb + +CONFIG += sailfishapp +CONFIG += c++11 + +SOURCES += \ + src/main.cpp \ + src/settings.cpp \ + src/apiclient.cpp \ + src/downloader.cpp \ + src/opdsparser.cpp + +HEADERS += \ + src/settings.h \ + src/apiclient.h \ + src/downloader.h \ + src/opdsparser.h + +OTHER_FILES += \ + qml/harbour-calibreweb.qml \ + qml/pages/*.qml \ + qml/cover/*.qml \ + rpm/harbour-calibreweb.spec \ + rpm/harbour-calibreweb.desktop \ + harbour-calibreweb.yaml + +DISTFILES += $$OTHER_FILES diff --git a/harbour-calibreweb.yaml b/harbour-calibreweb.yaml new file mode 100644 index 0000000..a3ed13c --- /dev/null +++ b/harbour-calibreweb.yaml @@ -0,0 +1,15 @@ +Name: harbour-calibreweb +Summary: Client OPDS per Calibre Web +Version: 0.1.0 +Release: 1 +Group: Qt/Qt +License: MIT +URL: https://github.com/kaneda/harbour-calibreweb +Description: | + Client per navigare e scaricare libri da un'istanza Calibre Web + (o dal Content Server di Calibre) tramite il protocollo OPDS. + Supporta autenticazione di base, ricerca e download in tutti i + formati disponibili (EPUB, MOBI, PDF, ...). +Sources: +- '%{name}-%{version}.tar.gz' +Builder: qmake5 diff --git a/icons/harbour-calibreweb-108.png b/icons/harbour-calibreweb-108.png new file mode 100644 index 0000000000000000000000000000000000000000..52581d193ec574629f5512c07f83f247e47b5dd4 GIT binary patch literal 796 zcmeAS@N?(olHy`uVBq!ia0vp^IUvlz1|<8_!p|}=Fg^BkaSW-L^LDPGpRA!k+guIy zg&xjbAulJ3T?|kzWbcw`Zn9PgbnjZU#dE&WA`V}!JfT(Ux_x`MU5`8e=R>XRg0Ir& zzaNu(U!CL(G?Zb+fz^S`d;foUd4I*mdCKMc>v&FeJ&evkRe&*ded|+N^V#svf?8@bbj-23H zyesGA)A?M&%9=f|J~^IZ7FARnPfKT`fuR6)!DG^|8IsPeU$=YR z|IeS_Po96jw&u3!y|>R#uY2w8+}iojD}cD$;m*B+!Wt31zW!y$f~sQqf6rJwd;e*dyLaqo94V-X z<=?o(K>V4h;pvEJhWF0#Kjln2w6FfRkkbcM&ErPLobRYFPgmPG<83047TT1Q_fGyy zO6!9KFB{Cxv{b&>u`Nl`T2!sAa{7!zn~J~O+A3!tD5kIZ(ebKL;Lb9;f46^L-Fi~I zB$Pz|?T(=NY9vms_6loxaAoLQ(bnD}l3?YXo2Kcqw!g7@)x|w##=*Tc(|z z$OQ`8H+pYWb=uj}&RqTT;Ewazo6o;r|MEWczQ?-Ox0MpH(ziFCd9qpD-Bxl@+M|2_ z_}zGyR`$*b{!?@2uC`C02j}TmceWl{Fn`CPjMpHuaYUA~rp~^4RmSuI`aclr2bs@XRE(WT3Gfm9F#ykUHx3vIVCg!0AVs~ Af&c&j literal 0 HcmV?d00001 diff --git a/icons/harbour-calibreweb.png b/icons/harbour-calibreweb.png new file mode 100644 index 0000000000000000000000000000000000000000..f08f3d0de62dae7eb30067860e1ae6be3c513331 GIT binary patch literal 629 zcmeAS@N?(olHy`uVBq!ia0vp^VIa)G1|&nn62CJrFxhyzIEGZrc{_Kb-(dq0SKdd? zRvV@?cFJB`<^A02FFTvvV(0%0gVI%6A`;UbWM3S*VBffMY3bY>eznQ&mg&r@A5ULA zds#wdWx+P8?l>QdE!nfnW=?Tn=UuOu& z$8Gc|ef&8{TWsamD?e9?bYC%8WyAV4qT{&cv@ORTesWE6oztPZ_Uwke6C(sWL>uuE zg;SF+zN``4`uF?s^B2#Xf4?61d;L7Q#oPQDJ4G+DAC!EC!N`9 z)(J9jZRgpWY(-Zl=x*Hn@W7e2=5uoQ-LvQKc{4dCZ`-A>WyiN)kM#`y>#q0h?bn+U zi^U^b_r6;~ zU#O|=ubgTny5e=BmcYb4$IB9>HUwTd6QTfg_oS}3Nzyka?kW?~Ryw_%_cg~%&HZtA z_UpgwvaFQyzx@9npXExK_~kY(v5y~K?wzD&w%tO0->)DwyU3)678~#EaZw0kNpeLa z=5*x#@_Dr{xmtOnl7RLh&1q! 0 + } + + Label { + x: Theme.horizontalPageMargin + width: parent.width - 2 * Theme.horizontalPageMargin + text: bookData.authors + color: Theme.secondaryColor + wrapMode: Text.Wrap + visible: text.length > 0 + } + + Label { + x: Theme.horizontalPageMargin + width: parent.width - 2 * Theme.horizontalPageMargin + text: bookData.summary + wrapMode: Text.Wrap + font.pixelSize: Theme.fontSizeSmall + visible: text.length > 0 + } + + SectionHeader { text: "Formati" } + + Repeater { + model: bookData.formats + delegate: ListItem { + id: formatItem + contentHeight: Theme.itemSizeMedium + enabled: !page.downloading + + Label { + anchors.left: parent.left + anchors.leftMargin: Theme.horizontalPageMargin + anchors.verticalCenter: parent.verticalCenter + text: modelData.format + + (modelData.size > 0 ? " (" + formatSize(modelData.size) + ")" : "") + color: formatItem.highlighted ? Theme.highlightColor : Theme.primaryColor + } + + onClicked: startDownload(modelData) + } + } + + Label { + x: Theme.horizontalPageMargin + width: parent.width - 2 * Theme.horizontalPageMargin + text: page.downloadError + color: Theme.errorColor + wrapMode: Text.Wrap + visible: text.length > 0 + } + + Item { width: 1; height: Theme.paddingLarge } + } + } + + Column { + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + visible: page.downloading + height: Theme.itemSizeMedium + + ProgressBar { + id: progressBar + anchors.left: parent.left + anchors.right: parent.right + value: 0 + minimumValue: 0 + maximumValue: 1 + } + Label { + anchors.horizontalCenter: parent.horizontalCenter + text: "Download in corso…" + color: Theme.secondaryColor + font.pixelSize: Theme.fontSizeSmall + } + } + + Toast { + id: toast + } + + function formatSize(bytes) { + if (bytes <= 0) + return "" + if (bytes < 1048576) + return Math.round(bytes / 1024) + " KB" + return (bytes / 1048576).toFixed(1) + " MB" + } + + function resolveUrl(u) { + if (u.indexOf("http://") === 0 || u.indexOf("https://") === 0) + return u + var base = appSettings.baseUrl + return base + (u.charAt(0) === "/" ? u : "/" + u) + } + + function sanitizeFileName(s) { + return s.replace(/[\\/:*?"<>|]/g, "_").replace(/\s+/g, " ").trim() + } + + function startDownload(format) { + if (page.downloading) + return + var ext = format.format.toLowerCase() + var dest = appSettings.downloadDir + "/" + + sanitizeFileName(bookData.title) + "." + ext + page.downloading = true + page.downloadError = "" + progressBar.value = 0 + progressBar.maximumValue = format.size > 0 ? format.size : 1 + downloader.download(resolveUrl(format.url), dest) + } + + Component.onCompleted: { + if (bookData.coverUrl && bookData.coverUrl.length > 0) + apiClient.fetchImage(bookData.coverUrl, "detail:" + bookData.id) + } + + Connections { + target: apiClient + onImageReady: { + if (id === "detail:" + bookData.id) + page.coverSource = localPath + } + } + + Connections { + target: downloader + onProgress: { + if (!page.downloading) + return + progressBar.value = received + if (total > 0) + progressBar.maximumValue = total + } + onFinished: { + page.downloading = false + if (ok) { + page.downloadError = "" + toast.show("Salvato in " + path) + } else { + page.downloadError = "Errore: " + error + } + } + } +} diff --git a/qml/pages/FeedPage.qml b/qml/pages/FeedPage.qml new file mode 100644 index 0000000..428354b --- /dev/null +++ b/qml/pages/FeedPage.qml @@ -0,0 +1,187 @@ +import QtQuick 2.0 +import Sailfish.Silica 1.0 + +Page { + id: page + + property string feedUrl + property string pageTitle + property string nextUrl: "" + property bool loading: false + property bool loadingMore: false + property string errorMessage: "" + + SilicaListView { + id: listView + anchors.fill: parent + header: PageHeader { title: page.pageTitle } + model: feedModel + clip: true + + delegate: ListItem { + id: item + contentHeight: Theme.itemSizeMedium + + Image { + id: coverThumb + anchors.left: parent.left + anchors.leftMargin: Theme.horizontalPageMargin + anchors.verticalCenter: parent.verticalCenter + width: Theme.itemSizeMedium * 0.68 + height: Theme.itemSizeMedium * 0.92 + fillMode: Image.PreserveAspectFit + visible: source.length > 0 && !model.isNav + source: model.coverSource + } + + Column { + anchors.left: model.isNav ? parent.left : coverThumb.right + anchors.leftMargin: Theme.horizontalPageMargin + anchors.right: parent.right + anchors.rightMargin: Theme.horizontalPageMargin + anchors.verticalCenter: parent.verticalCenter + + Label { + width: parent.width + text: model.entryTitle + color: item.highlighted ? Theme.highlightColor : Theme.primaryColor + truncationMode: TruncationMode.Elide + } + Label { + width: parent.width + text: model.entryAuthors + color: Theme.secondaryColor + font.pixelSize: Theme.fontSizeSmall + truncationMode: TruncationMode.Elide + visible: text.length > 0 && !model.isNav + } + } + + Icon { + anchors.right: parent.right + anchors.rightMargin: Theme.horizontalPageMargin + anchors.verticalCenter: parent.verticalCenter + source: "image://theme/icon-m-right" + visible: model.isNav + } + + onClicked: { + if (model.isNav) { + pageStack.push(Qt.resolvedUrl("FeedPage.qml"), + { feedUrl: model.entryLink, pageTitle: model.entryTitle }) + } else { + pageStack.push(Qt.resolvedUrl("BookDetailPage.qml"), + { bookData: model.entryData }) + } + } + } + + footer: Item { + width: parent.width + height: page.nextUrl.length > 0 ? Theme.itemSizeSmall : 0 + BusyIndicator { + anchors.centerIn: parent + running: page.loadingMore + size: BusyIndicatorSize.Small + } + } + + PullDownMenu { + MenuItem { + text: "Aggiorna" + onClicked: reload() + } + MenuItem { + text: "Impostazioni" + onClicked: pageStack.push(Qt.resolvedUrl("SettingsPage.qml")) + } + } + + ViewPlaceholder { + enabled: feedModel.count === 0 && !page.loading + text: page.errorMessage.length > 0 ? page.errorMessage : "Nessun risultato" + } + + BusyIndicator { + anchors.centerIn: parent + running: page.loading + size: BusyIndicatorSize.Large + } + + onContentYChanged: tryLoadMore() + onCountChanged: tryLoadMore() + } + + ListModel { + id: feedModel + } + + function reload() { + feedModel.clear() + page.nextUrl = "" + loadFeed(page.feedUrl) + } + + function loadFeed(url) { + page.loading = true + page.errorMessage = "" + apiClient.getFeed(url) + } + + function loadMore() { + page.loadingMore = true + apiClient.getFeed(page.nextUrl) + } + + function tryLoadMore() { + if (page.nextUrl.length === 0 || page.loadingMore || page.loading) + return + if (listView.contentY + listView.height >= listView.contentHeight - Theme.itemSizeMedium * 2) + loadMore() + } + + onNextUrlChanged: tryLoadMore() + + Component.onCompleted: loadFeed(feedUrl) + + Connections { + target: apiClient + onFeedReady: { + if (pageStack.currentPage !== page) + return + page.loading = false + page.loadingMore = false + for (var i = 0; i < entries.length; i++) { + var e = entries[i] + var isNav = e.isNavigation + feedModel.append({ + entryTitle: e.title, + entryAuthors: e.authors, + entryLink: e.linkUrl, + isNav: isNav, + entryData: e, + coverSource: "", + coverId: e.id + }) + if (!isNav && e.coverUrl.length > 0) + apiClient.fetchImage(e.coverUrl, e.id) + } + page.nextUrl = nextUrl + if (feedModel.count === 0 && page.errorMessage.length === 0) + page.errorMessage = "Nessun risultato" + } + onFeedError: { + page.loading = false + page.loadingMore = false + page.errorMessage = message + } + onImageReady: { + for (var i = 0; i < feedModel.count; i++) { + if (feedModel.get(i).coverId === id) { + feedModel.setProperty(i, "coverSource", localPath) + break + } + } + } + } +} diff --git a/qml/pages/MainPage.qml b/qml/pages/MainPage.qml new file mode 100644 index 0000000..5d2fa94 --- /dev/null +++ b/qml/pages/MainPage.qml @@ -0,0 +1,107 @@ +import QtQuick 2.0 +import Sailfish.Silica 1.0 + +Page { + id: page + + property bool loading: false + property string errorMessage: "" + + SilicaListView { + id: listView + anchors.fill: parent + header: PageHeader { title: "Calibre Web" } + model: sectionModel + + delegate: ListItem { + id: sectionItem + contentHeight: Theme.itemSizeMedium + + Label { + anchors.left: parent.left + anchors.leftMargin: Theme.horizontalPageMargin + anchors.verticalCenter: parent.verticalCenter + text: model.title + color: sectionItem.highlighted ? Theme.highlightColor : Theme.primaryColor + } + + Icon { + anchors.right: parent.right + anchors.rightMargin: Theme.horizontalPageMargin + anchors.verticalCenter: parent.verticalCenter + source: "image://theme/icon-m-right" + } + + onClicked: { + pageStack.push(Qt.resolvedUrl("FeedPage.qml"), + { feedUrl: model.linkUrl, pageTitle: model.title }) + } + } + + PullDownMenu { + MenuItem { + text: "Cerca" + onClicked: pageStack.push(Qt.resolvedUrl("SearchPage.qml")) + } + MenuItem { + text: "Impostazioni" + onClicked: pageStack.push(Qt.resolvedUrl("SettingsPage.qml")) + } + MenuItem { + text: "Aggiorna" + onClicked: loadIndex() + } + } + + ViewPlaceholder { + enabled: sectionModel.count === 0 && !page.loading + text: page.errorMessage.length > 0 + ? page.errorMessage + : "Apri le impostazioni e inserisci l'indirizzo del tuo Calibre Web" + } + + BusyIndicator { + anchors.centerIn: parent + running: page.loading + size: BusyIndicatorSize.Large + } + } + + ListModel { + id: sectionModel + } + + function loadIndex() { + var base = appSettings.baseUrl + if (base.length === 0) { + page.errorMessage = "Server non configurato" + return + } + page.loading = true + page.errorMessage = "" + apiClient.getFeed(base + "/opds") + } + + Component.onCompleted: loadIndex() + + Connections { + target: apiClient + onFeedReady: { + if (pageStack.currentPage !== page) + return + page.loading = false + sectionModel.clear() + for (var i = 0; i < entries.length; i++) { + var e = entries[i] + if (e.isNavigation) + sectionModel.append({ title: e.title, linkUrl: e.linkUrl }) + } + if (sectionModel.count === 0) + page.errorMessage = "Nessuna sezione trovata nel feed OPDS" + } + onFeedError: { + page.loading = false + page.errorMessage = message + } + } +} diff --git a/qml/pages/SearchPage.qml b/qml/pages/SearchPage.qml new file mode 100644 index 0000000..b3470b0 --- /dev/null +++ b/qml/pages/SearchPage.qml @@ -0,0 +1,52 @@ +import QtQuick 2.0 +import Sailfish.Silica 1.0 + +Page { + id: page + + SilicaFlickable { + anchors.fill: parent + contentHeight: column.height + + Column { + id: column + width: parent.width + + PageHeader { title: "Cerca" } + + SearchField { + id: searchField + width: parent.width + placeholderText: "Titolo o autore…" + EnterKey.enabled: text.length > 0 + EnterKey.onClicked: search() + } + + Button { + anchors.horizontalCenter: parent.horizontalCenter + text: "Cerca" + enabled: searchField.text.length > 0 + onClicked: search() + } + + Label { + x: Theme.horizontalPageMargin + width: parent.width - 2 * Theme.horizontalPageMargin + text: "La ricerca usa il motore del server (OPDS search)." + color: Theme.secondaryColor + font.pixelSize: Theme.fontSizeSmall + wrapMode: Text.Wrap + } + } + } + + function search() { + var base = appSettings.baseUrl + var query = searchField.text.trim() + if (base.length === 0 || query.length === 0) + return + var url = base + "/opds/search?query=" + encodeURIComponent(query) + pageStack.push(Qt.resolvedUrl("FeedPage.qml"), + { feedUrl: url, pageTitle: "Risultati: " + query }) + } +} diff --git a/qml/pages/SettingsPage.qml b/qml/pages/SettingsPage.qml new file mode 100644 index 0000000..335bf71 --- /dev/null +++ b/qml/pages/SettingsPage.qml @@ -0,0 +1,103 @@ +import QtQuick 2.0 +import Sailfish.Silica 1.0 + +Page { + id: page + + property string testResult: "" + property bool testOk: false + + SilicaFlickable { + anchors.fill: parent + contentHeight: column.height + + Column { + id: column + width: parent.width + + PageHeader { title: "Impostazioni" } + + SectionHeader { text: "Server" } + + TextField { + id: serverField + width: parent.width + label: "Indirizzo Calibre Web" + placeholderText: "https://calibre.example.com" + text: appSettings.serverUrl + inputMethodHints: Qt.ImhUrlCharactersOnly + onTextChanged: appSettings.serverUrl = text + } + + TextField { + id: userField + width: parent.width + label: "Utente (vuoto = accesso anonimo)" + text: appSettings.username + onTextChanged: appSettings.username = text + } + + TextField { + id: passField + width: parent.width + label: "Password" + text: appSettings.password + echoMode: TextInput.Password + onTextChanged: appSettings.password = text + } + + Switch { + text: "Ignora errori certificato SSL" + description: "Da attivare se il server usa un certificato autofirmato" + checked: appSettings.ignoreSslErrors + onCheckedChanged: appSettings.ignoreSslErrors = checked + } + + SectionHeader { text: "Connessione" } + + Button { + anchors.horizontalCenter: parent.horizontalCenter + text: "Prova connessione" + onClicked: testConnection() + } + + Label { + x: Theme.horizontalPageMargin + width: parent.width - 2 * Theme.horizontalPageMargin + text: page.testResult + color: page.testOk ? Theme.secondaryHighlightColor : Theme.errorColor + wrapMode: Text.Wrap + visible: text.length > 0 + } + + Item { width: 1; height: Theme.paddingLarge } + } + } + + function testConnection() { + var base = appSettings.baseUrl + if (base.length === 0) { + page.testResult = "Inserisci l'indirizzo del server" + page.testOk = false + return + } + page.testResult = "Verifica in corso…" + apiClient.getFeed(base + "/opds") + } + + Connections { + target: apiClient + onFeedReady: { + if (pageStack.currentPage !== page) + return + page.testOk = true + page.testResult = "Connessione OK: " + entries.length + " sezioni trovate" + } + onFeedError: { + if (pageStack.currentPage !== page) + return + page.testOk = false + page.testResult = message + } + } +} diff --git a/rpm/harbour-calibreweb.desktop b/rpm/harbour-calibreweb.desktop new file mode 100644 index 0000000..9eed84e --- /dev/null +++ b/rpm/harbour-calibreweb.desktop @@ -0,0 +1,9 @@ +[Desktop Entry] +Name=Calibre Web +Name[it_IT]=Calibre Web +Comment=Client OPDS per Calibre Web +Comment[it_IT]=Client OPDS per Calibre Web +Exec=harbour-calibreweb +Icon=harbour-calibreweb +Type=Application +Categories=Office;Viewer; diff --git a/rpm/harbour-calibreweb.spec b/rpm/harbour-calibreweb.spec new file mode 100644 index 0000000..963d778 --- /dev/null +++ b/rpm/harbour-calibreweb.spec @@ -0,0 +1,47 @@ +Name: harbour-calibreweb +Summary: Client OPDS per Calibre Web +Version: 0.1.0 +Release: 1 +Group: Qt/Qt +License: MIT +URL: https://github.com/kaneda/harbour-calibreweb +Source0: %{name}-%{version}.tar.gz +Source1: %{name}.desktop + +Requires: sailfishsilica-qt5 >= 0.10.9 +BuildRequires: pkgconfig(sailfishapp) >= 1.0.2 +BuildRequires: pkgconfig(Qt5Core) +BuildRequires: pkgconfig(Qt5Qml) +BuildRequires: pkgconfig(Qt5Quick) +BuildRequires: desktop-file-utils + +%description +Client per navigare e scaricare libri da un'istanza Calibre Web +(o dal Content Server di Calibre) tramite il protocollo OPDS. +Supporta autenticazione di base, ricerca, copertine e download +in tutti i formati disponibili sulla libreria. + +%prep +%setup -q -n %{name}-%{version} + +%build +%qmake5 +make %{?_smp_mflags} + +%install +make install INSTALL_ROOT=%{buildroot} +mkdir -p %{buildroot}%{_datadir}/applications +install -m 0644 %{SOURCE1} %{buildroot}%{_datadir}/applications/%{name}.desktop +mkdir -p %{buildroot}%{_datadir}/icons/hicolor/86x86/apps +mkdir -p %{buildroot}%{_datadir}/icons/hicolor/108x108/apps +install -m 0644 icons/%{name}.png %{buildroot}%{_datadir}/icons/hicolor/86x86/apps/%{name}.png +install -m 0644 icons/%{name}-108.png %{buildroot}%{_datadir}/icons/hicolor/108x108/apps/%{name}-108.png +desktop-file-validate %{buildroot}%{_datadir}/applications/%{name}.desktop || true + +%files +%defattr(-,root,root,-) +%{_bindir}/%{name} +%{_datadir}/applications/%{name}.desktop +%{_datadir}/icons/hicolor/86x86/apps/%{name}.png +%{_datadir}/icons/hicolor/108x108/apps/%{name}.png +%{_datadir}/%{name}/qml diff --git a/src/apiclient.cpp b/src/apiclient.cpp new file mode 100644 index 0000000..ec7f6ae --- /dev/null +++ b/src/apiclient.cpp @@ -0,0 +1,135 @@ +#include "apiclient.h" + +#include "opdsparser.h" +#include "settings.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +ApiClient::ApiClient(Settings *settings, QObject *parent) + : QObject(parent) + , m_settings(settings) +{ + m_cacheDir = QStandardPaths::writableLocation(QStandardPaths::CacheLocation) + + QStringLiteral("/covers"); + QDir().mkpath(m_cacheDir); +} + +QString ApiClient::resolveUrl(const QString &relative) const +{ + if (relative.isEmpty()) + return QString(); + if (relative.startsWith(QLatin1String("http://")) + || relative.startsWith(QLatin1String("https://"))) + return relative; + const QString base = m_settings->baseUrl(); + if (relative.startsWith(QLatin1Char('/'))) + return base + relative; + return base + QLatin1Char('/') + relative; +} + +void ApiClient::applyAuth(QNetworkRequest &request) +{ + const QString auth = m_settings->authHeader(); + if (!auth.isEmpty()) + request.setRawHeader("Authorization", auth.toUtf8()); +} + +QNetworkReply *ApiClient::startGet(const QUrl &url) +{ + QNetworkRequest request(url); + request.setRawHeader("Accept", + "application/atom+xml, application/xml, text/xml;q=0.9, */*;q=0.8"); + 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)); + } + return reply; +} + +void ApiClient::getFeed(const QString &url) +{ + QNetworkReply *reply = startGet(QUrl(url)); + connect(reply, &QNetworkReply::finished, this, [this, reply]() { + reply->deleteLater(); + const int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + if (reply->error() != QNetworkReply::NoError || status >= 400) { + QString message; + if (status == 401) + message = QStringLiteral( + "Autenticazione richiesta (401). Controlla utente e password."); + else if (status == 404) + message = QStringLiteral("Endpoint non trovato (404). Controlla l'indirizzo."); + else + message = reply->errorString(); + emit feedError(message); + return; + } + + const QByteArray body = reply->readAll(); + QVariantList entries; + QString nextUrl; + QString feedTitle; + QString parseError; + if (OpdsParser::parse(body, &entries, &nextUrl, &feedTitle, &parseError)) { + emit feedReady(entries, resolveUrl(nextUrl), feedTitle); + } else { + emit feedError(parseError.isEmpty() + ? QStringLiteral("Risposta non valida dal server") + : parseError); + } + }); +} + +void ApiClient::fetchImage(const QString &url, const QString &id) +{ + if (url.isEmpty()) { + emit imageReady(id, QString()); + return; + } + + const QString resolved = resolveUrl(url); + const QUrl imageUrl(resolved); + + QString fileName = QString::fromLatin1( + QCryptographicHash::hash(imageUrl.path().toUtf8(), QCryptographicHash::Md5).toHex()); + const QString suffix = QFileInfo(imageUrl.path()).suffix(); + if (!suffix.isEmpty() && suffix.length() <= 5) + fileName += QLatin1Char('.') + suffix; + const QString localPath = m_cacheDir + QLatin1Char('/') + fileName; + + if (QFile::exists(localPath)) { + emit imageReady(id, localPath); + return; + } + + QNetworkReply *reply = startGet(imageUrl); + connect(reply, &QNetworkReply::finished, this, [this, reply, id, localPath]() { + reply->deleteLater(); + const int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + if (reply->error() != QNetworkReply::NoError || status >= 400) { + emit imageReady(id, QString()); + return; + } + QFile file(localPath); + if (file.open(QIODevice::WriteOnly)) { + file.write(reply->readAll()); + file.close(); + emit imageReady(id, localPath); + } else { + emit imageReady(id, QString()); + } + }); +} diff --git a/src/apiclient.h b/src/apiclient.h new file mode 100644 index 0000000..37ea93c --- /dev/null +++ b/src/apiclient.h @@ -0,0 +1,40 @@ +#ifndef APICLIENT_H +#define APICLIENT_H + +#include +#include +#include + +class QNetworkReply; +class QNetworkRequest; +class Settings; + +// Cliente OPDS: scarica i feed (getFeed), li parsa e li espone come QVariantList +// di entry (title, authors, summary, coverUrl, formats, isNavigation, linkUrl). +// Gestisce anche il fetch delle copertine in una cache locale su disco. +class ApiClient : public QObject +{ + Q_OBJECT + +public: + explicit ApiClient(Settings *settings, QObject *parent = 0); + + Q_INVOKABLE void getFeed(const QString &url); + Q_INVOKABLE void fetchImage(const QString &url, const QString &id); + +signals: + void feedReady(const QVariantList &entries, const QString &nextUrl, const QString &feedTitle); + void feedError(const QString &message); + void imageReady(const QString &id, const QString &localPath); + +private: + QNetworkReply *startGet(const QUrl &url); + void applyAuth(QNetworkRequest &request); + QString resolveUrl(const QString &relative) const; + + Settings *m_settings; + QNetworkAccessManager m_nam; + QString m_cacheDir; +}; + +#endif // APICLIENT_H diff --git a/src/downloader.cpp b/src/downloader.cpp new file mode 100644 index 0000000..bb5d543 --- /dev/null +++ b/src/downloader.cpp @@ -0,0 +1,78 @@ +#include "downloader.h" + +#include "settings.h" + +#include +#include +#include +#include +#include + +Downloader::Downloader(Settings *settings, QObject *parent) + : QObject(parent) + , m_settings(settings) +{ +} + +void Downloader::download(const QString &url, const QString &destPath) +{ + const QUrl requestUrl(url); + QNetworkRequest request(requestUrl); + request.setRawHeader("User-Agent", "harbour-calibreweb/0.1"); + request.setAttribute(QNetworkRequest::FollowRedirectsAttribute, true); + + const QString auth = m_settings->authHeader(); + if (!auth.isEmpty()) + request.setRawHeader("Authorization", auth.toUtf8()); + + QNetworkReply *reply = m_nam.get(request); + if (m_settings->ignoreSslErrors()) { + connect(reply, &QNetworkReply::sslErrors, reply, + static_cast &)>( + &QNetworkReply::ignoreSslErrors)); + } + + QFile *file = new QFile(destPath); + if (!file->open(QIODevice::WriteOnly)) { + delete file; + reply->abort(); + reply->deleteLater(); + emit finished(destPath, false, + QStringLiteral("Impossibile creare il file di destinazione")); + return; + } + m_files.insert(reply, file); + m_paths.insert(reply, destPath); + + connect(reply, &QNetworkReply::downloadProgress, this, [this, reply](qint64 received, qint64 total) { + emit progress(received, total, m_paths.value(reply)); + }); + connect(reply, &QNetworkReply::readyRead, this, [this, reply]() { + QFile *file = m_files.value(reply); + if (file) + file->write(reply->readAll()); + }); + connect(reply, &QNetworkReply::finished, this, [this, reply]() { + QFile *file = m_files.take(reply); + const QString path = m_paths.take(reply); + const int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + const bool ok = reply->error() == QNetworkReply::NoError && status >= 200 && status < 300; + if (file) { + file->flush(); + file->close(); + delete file; + } + if (!ok && QFile::exists(path)) + QFile::remove(path); + reply->deleteLater(); + + QString error; + if (!ok) { + if (status == 401) + error = QStringLiteral("Autenticazione richiesta (401)"); + else + error = reply->errorString(); + } + emit finished(path, ok, error); + }); +} diff --git a/src/downloader.h b/src/downloader.h new file mode 100644 index 0000000..da82253 --- /dev/null +++ b/src/downloader.h @@ -0,0 +1,34 @@ +#ifndef DOWNLOADER_H +#define DOWNLOADER_H + +#include +#include +#include + +class QFile; +class QNetworkReply; +class Settings; + +// Download di file (libri) con segnali di progresso. +// Supporta più download concorrenti. +class Downloader : public QObject +{ + Q_OBJECT + +public: + explicit Downloader(Settings *settings, QObject *parent = 0); + + Q_INVOKABLE void download(const QString &url, const QString &destPath); + +signals: + void progress(qint64 received, qint64 total, const QString &path); + void finished(const QString &path, bool ok, const QString &error); + +private: + Settings *m_settings; + QNetworkAccessManager m_nam; + QHash m_files; + QHash m_paths; +}; + +#endif // DOWNLOADER_H diff --git a/src/main.cpp b/src/main.cpp new file mode 100644 index 0000000..ae67fd4 --- /dev/null +++ b/src/main.cpp @@ -0,0 +1,27 @@ +#include +#include +#include +#include + +#include "apiclient.h" +#include "downloader.h" +#include "settings.h" + +Q_DECL_EXPORT int main(int argc, char *argv[]) +{ + QGuiApplication *app = SailfishApp::application(argc, argv); + QQuickView *view = SailfishApp::createView(); + + Settings settings; + ApiClient apiClient(&settings); + Downloader downloader(&settings); + + view->rootContext()->setContextProperty(QStringLiteral("appSettings"), &settings); + view->rootContext()->setContextProperty(QStringLiteral("apiClient"), &apiClient); + view->rootContext()->setContextProperty(QStringLiteral("downloader"), &downloader); + + view->setSource(SailfishApp::pathTo(QStringLiteral("qml/harbour-calibreweb.qml"))); + view->show(); + + return app->exec(); +} diff --git a/src/opdsparser.cpp b/src/opdsparser.cpp new file mode 100644 index 0000000..dcd6b36 --- /dev/null +++ b/src/opdsparser.cpp @@ -0,0 +1,123 @@ +#include "opdsparser.h" + +#include +#include +#include + +static QString elementText(QXmlStreamReader &reader) +{ + return reader.readElementText(QXmlStreamReader::IncludeChildElements).trimmed(); +} + +static QVariantMap parseEntry(QXmlStreamReader &reader) +{ + QVariantMap entry; + QStringList authors; + QVariantList formats; + QString title; + QString summary; + QString id; + QString coverHref; + QString thumbHref; + QString navHref; + + while (!reader.atEnd()) { + reader.readNext(); + if (reader.tokenType() == QXmlStreamReader::EndElement + && reader.name() == QLatin1String("entry")) + break; + if (reader.tokenType() != QXmlStreamReader::StartElement) + continue; + + const QStringRef name = reader.name(); + if (name == QLatin1String("title")) { + title = elementText(reader); + } else if (name == QLatin1String("id")) { + id = elementText(reader); + } else if (name == QLatin1String("name")) { + const QString author = elementText(reader); + if (!author.isEmpty()) + authors.append(author); + } else if (name == QLatin1String("content") || name == QLatin1String("summary")) { + if (summary.isEmpty()) + summary = elementText(reader); + } else if (name == QLatin1String("link")) { + const QXmlStreamAttributes attrs = reader.attributes(); + const QString rel = attrs.value(QLatin1String("rel")).toString(); + const QString href = attrs.value(QLatin1String("href")).toString(); + const QString type = attrs.value(QLatin1String("type")).toString(); + + if (rel == QLatin1String("http://opds-spec.org/image")) { + coverHref = href; + } else if (rel == QLatin1String("http://opds-spec.org/image/thumbnail")) { + thumbHref = href; + } else if (rel == QLatin1String("http://opds-spec.org/acquisition")) { + QVariantMap format; + format.insert(QStringLiteral("format"), attrs.value(QLatin1String("title")).toString()); + format.insert(QStringLiteral("mime"), type); + format.insert(QStringLiteral("size"), attrs.value(QLatin1String("length")).toString().toLongLong()); + format.insert(QStringLiteral("url"), href); + formats.append(format); + } else if (rel == QLatin1String("subsection")) { + if (navHref.isEmpty()) + navHref = href; + } else if (navHref.isEmpty() && type.contains(QLatin1String("opds-catalog"))) { + // calibre-web: i link di navigazione dell'indice non hanno rel="subsection" + navHref = href; + } + } + } + + entry.insert(QStringLiteral("title"), title); + entry.insert(QStringLiteral("authors"), authors.join(QStringLiteral(", "))); + entry.insert(QStringLiteral("summary"), summary); + entry.insert(QStringLiteral("coverUrl"), !coverHref.isEmpty() ? coverHref : thumbHref); + entry.insert(QStringLiteral("formats"), formats); + entry.insert(QStringLiteral("id"), id); + entry.insert(QStringLiteral("isNavigation"), !navHref.isEmpty()); + entry.insert(QStringLiteral("linkUrl"), navHref); + return entry; +} + +bool OpdsParser::parse(const QByteArray &xml, + QVariantList *entries, + QString *nextUrl, + QString *feedTitle, + QString *errorMessage) +{ + QXmlStreamReader reader(xml); + QVariantList result; + QString next; + QString title; + + while (!reader.atEnd()) { + reader.readNext(); + if (reader.tokenType() != QXmlStreamReader::StartElement) + continue; + + const QStringRef name = reader.name(); + if (name == QLatin1String("entry")) { + result.append(parseEntry(reader)); + } else if (name == QLatin1String("link")) { + const QXmlStreamAttributes attrs = reader.attributes(); + if (attrs.value(QLatin1String("rel")).toString() == QLatin1String("next")) + next = attrs.value(QLatin1String("href")).toString(); + } else if (name == QLatin1String("title") && title.isEmpty()) { + title = elementText(reader); + } + } + + if (reader.hasError() && result.isEmpty()) { + if (errorMessage) + *errorMessage = reader.errorString(); + return false; + } + + if (entries) + *entries = result; + if (nextUrl) + *nextUrl = next; + if (feedTitle) + *feedTitle = title; + return true; +} diff --git a/src/opdsparser.h b/src/opdsparser.h new file mode 100644 index 0000000..4326fbe --- /dev/null +++ b/src/opdsparser.h @@ -0,0 +1,22 @@ +#ifndef OPDSPARSER_H +#define OPDSPARSER_H + +#include +#include +#include + +// Parser minimalista dei feed Atom/OPDS. +// Ogni diventa un QVariantMap con: +// title, authors, summary, coverUrl, formats (lista di {format, mime, size, url}), +// id, isNavigation, linkUrl +class OpdsParser +{ +public: + static bool parse(const QByteArray &xml, + QVariantList *entries, + QString *nextUrl, + QString *feedTitle, + QString *errorMessage); +}; + +#endif // OPDSPARSER_H diff --git a/src/settings.cpp b/src/settings.cpp new file mode 100644 index 0000000..097a03f --- /dev/null +++ b/src/settings.cpp @@ -0,0 +1,88 @@ +#include "settings.h" + +#include +#include + +Settings::Settings(QObject *parent) + : QObject(parent) + , m_settings(QStringLiteral("harbour"), QStringLiteral("calibreweb")) +{ +} + +QString Settings::serverUrl() const +{ + return m_settings.value(QStringLiteral("serverUrl")).toString(); +} + +void Settings::setServerUrl(const QString &value) +{ + if (value == serverUrl()) + return; + m_settings.setValue(QStringLiteral("serverUrl"), value); + emit serverUrlChanged(); +} + +QString Settings::username() const +{ + return m_settings.value(QStringLiteral("username")).toString(); +} + +void Settings::setUsername(const QString &value) +{ + if (value == username()) + return; + m_settings.setValue(QStringLiteral("username"), value); + emit usernameChanged(); +} + +QString Settings::password() const +{ + return m_settings.value(QStringLiteral("password")).toString(); +} + +void Settings::setPassword(const QString &value) +{ + if (value == password()) + return; + m_settings.setValue(QStringLiteral("password"), value); + emit passwordChanged(); +} + +bool Settings::ignoreSslErrors() const +{ + return m_settings.value(QStringLiteral("ignoreSslErrors"), false).toBool(); +} + +void Settings::setIgnoreSslErrors(bool value) +{ + if (value == ignoreSslErrors()) + return; + m_settings.setValue(QStringLiteral("ignoreSslErrors"), value); + emit ignoreSslErrorsChanged(); +} + +QString Settings::downloadDir() const +{ + QString dir = QStandardPaths::writableLocation(QStandardPaths::DownloadLocation); + if (dir.isEmpty()) + dir = QStringLiteral("/home/nemo/Downloads"); + QDir().mkpath(dir); + return dir; +} + +QString Settings::authHeader() const +{ + const QString user = username(); + if (user.isEmpty()) + return QString(); + const QString credentials = user + QLatin1Char(':') + password(); + return QStringLiteral("Basic ") + QString::fromLatin1(credentials.toUtf8().toBase64()); +} + +QString Settings::baseUrl() const +{ + QString url = serverUrl().trimmed(); + while (url.endsWith(QLatin1Char('/'))) + url.chop(1); + return url; +} diff --git a/src/settings.h b/src/settings.h new file mode 100644 index 0000000..7e89564 --- /dev/null +++ b/src/settings.h @@ -0,0 +1,50 @@ +#ifndef SETTINGS_H +#define SETTINGS_H + +#include +#include + +class Settings : public QObject +{ + Q_OBJECT + + Q_PROPERTY(QString serverUrl READ serverUrl WRITE setServerUrl NOTIFY serverUrlChanged) + Q_PROPERTY(QString username READ username WRITE setUsername NOTIFY usernameChanged) + Q_PROPERTY(QString password READ password WRITE setPassword NOTIFY passwordChanged) + Q_PROPERTY(bool ignoreSslErrors READ ignoreSslErrors WRITE setIgnoreSslErrors NOTIFY ignoreSslErrorsChanged) + Q_PROPERTY(QString downloadDir READ downloadDir CONSTANT) + +public: + explicit Settings(QObject *parent = 0); + + QString serverUrl() const; + void setServerUrl(const QString &value); + + QString username() const; + void setUsername(const QString &value); + + QString password() const; + void setPassword(const QString &value); + + bool ignoreSslErrors() const; + void setIgnoreSslErrors(bool value); + + QString downloadDir() const; + + // Header "Authorization: Basic ..." pronto da usare, vuoto se utente non impostato + QString authHeader() const; + + // URL del server senza slash finale + QString baseUrl() const; + +signals: + void serverUrlChanged(); + void usernameChanged(); + void passwordChanged(); + void ignoreSslErrorsChanged(); + +private: + QSettings m_settings; +}; + +#endif // SETTINGS_H