Client OPDS per Calibre Web (Sailfish OS): core C++ testato, UI Silica, packaging RPM
This commit is contained in:
@@ -0,0 +1,6 @@
|
|||||||
|
build/
|
||||||
|
build-*/
|
||||||
|
*.rpm
|
||||||
|
*.user
|
||||||
|
Makefile
|
||||||
|
.qmake.stash
|
||||||
@@ -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 <questo repo> 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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 796 B |
Binary file not shown.
|
After Width: | Height: | Size: 629 B |
@@ -0,0 +1,55 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Genera le icone dell'app: harbour-calibreweb.png (86x86) e -108 (108x108)."""
|
||||||
|
from PIL import Image, ImageDraw
|
||||||
|
|
||||||
|
BG = (46, 52, 64) # #2E3440
|
||||||
|
ACCENT = (136, 192, 208) # #88C0D0
|
||||||
|
PAGE = (229, 233, 240) # #E5E9F0
|
||||||
|
SPINE = (94, 129, 172) # #5E81AC
|
||||||
|
|
||||||
|
|
||||||
|
def draw_icon(size):
|
||||||
|
img = Image.new("RGBA", (size, size), (0, 0, 0, 0))
|
||||||
|
d = ImageDraw.Draw(img)
|
||||||
|
margin = size * 0.045
|
||||||
|
radius = size * 0.16
|
||||||
|
# sfondo arrotondato
|
||||||
|
d.rounded_rectangle([margin, margin, size - margin, size - margin],
|
||||||
|
radius=radius, fill=BG)
|
||||||
|
|
||||||
|
# libro aperto: due pagine trapezoidali + dorso
|
||||||
|
cx = size / 2
|
||||||
|
top = size * 0.34
|
||||||
|
bot = size * 0.68
|
||||||
|
half = size * 0.16
|
||||||
|
outer_l = size * 0.22
|
||||||
|
outer_r = size * 0.78
|
||||||
|
|
||||||
|
left_page = [(cx - half, top), (cx, top), (cx, bot), (outer_l, bot + size * 0.04)]
|
||||||
|
right_page = [(cx, top), (cx + half, top), (outer_r, bot + size * 0.04), (cx, bot)]
|
||||||
|
|
||||||
|
d.polygon(left_page, fill=ACCENT)
|
||||||
|
d.polygon(right_page, fill=ACCENT)
|
||||||
|
|
||||||
|
# dorso
|
||||||
|
d.rectangle([cx - size * 0.014, top, cx + size * 0.014, bot], fill=SPINE)
|
||||||
|
|
||||||
|
# righe delle pagine
|
||||||
|
lw = max(1, int(size * 0.012))
|
||||||
|
for y_frac in (0.45, 0.52, 0.59):
|
||||||
|
y = size * y_frac
|
||||||
|
d.line([(cx - half + size * 0.03, y), (outer_l + size * 0.06, y + size * 0.025)],
|
||||||
|
fill=PAGE, width=lw)
|
||||||
|
d.line([(cx + half - size * 0.03, y), (outer_r - size * 0.06, y + size * 0.025)],
|
||||||
|
fill=PAGE, width=lw)
|
||||||
|
return img
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
draw_icon(108).save("icons/harbour-calibreweb-108.png")
|
||||||
|
draw_icon(86).save("icons/harbour-calibreweb.png")
|
||||||
|
print("icone scritte")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import QtQuick 2.0
|
||||||
|
import Sailfish.Silica 1.0
|
||||||
|
|
||||||
|
CoverBackground {
|
||||||
|
Column {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
spacing: Theme.paddingMedium
|
||||||
|
|
||||||
|
Image {
|
||||||
|
anchors.horizontalCenter: parent.horizontalCenter
|
||||||
|
source: "/usr/share/harbour-calibreweb/icons/harbour-calibreweb.png"
|
||||||
|
sourceSize.width: 86
|
||||||
|
sourceSize.height: 86
|
||||||
|
}
|
||||||
|
|
||||||
|
Label {
|
||||||
|
anchors.horizontalCenter: parent.horizontalCenter
|
||||||
|
text: "Calibre Web"
|
||||||
|
color: Theme.primaryColor
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import QtQuick 2.0
|
||||||
|
import Sailfish.Silica 1.0
|
||||||
|
import "pages"
|
||||||
|
import "cover"
|
||||||
|
|
||||||
|
ApplicationWindow {
|
||||||
|
id: app
|
||||||
|
|
||||||
|
initialPage: Component { MainPage {} }
|
||||||
|
cover: Component { CoverPage {} }
|
||||||
|
|
||||||
|
allowedOrientations: Orientation.Portrait
|
||||||
|
_defaultPageOrientations: Orientation.Portrait
|
||||||
|
}
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
import QtQuick 2.0
|
||||||
|
import Sailfish.Silica 1.0
|
||||||
|
|
||||||
|
Page {
|
||||||
|
id: page
|
||||||
|
|
||||||
|
property var bookData: ({})
|
||||||
|
property string coverSource: ""
|
||||||
|
property bool downloading: false
|
||||||
|
property string downloadError: ""
|
||||||
|
|
||||||
|
SilicaFlickable {
|
||||||
|
anchors.fill: parent
|
||||||
|
contentHeight: column.height + Theme.paddingLarge
|
||||||
|
|
||||||
|
Column {
|
||||||
|
id: column
|
||||||
|
width: parent.width
|
||||||
|
|
||||||
|
PageHeader { title: bookData.title }
|
||||||
|
|
||||||
|
Image {
|
||||||
|
id: cover
|
||||||
|
anchors.horizontalCenter: parent.horizontalCenter
|
||||||
|
width: Math.min(parent.width * 0.45, 260)
|
||||||
|
height: width * 1.5
|
||||||
|
fillMode: Image.PreserveAspectFit
|
||||||
|
source: page.coverSource
|
||||||
|
visible: source.length > 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
#include "apiclient.h"
|
||||||
|
|
||||||
|
#include "opdsparser.h"
|
||||||
|
#include "settings.h"
|
||||||
|
|
||||||
|
#include <QCryptographicHash>
|
||||||
|
#include <QDir>
|
||||||
|
#include <QFile>
|
||||||
|
#include <QFileInfo>
|
||||||
|
#include <QNetworkReply>
|
||||||
|
#include <QNetworkRequest>
|
||||||
|
#include <QSslError>
|
||||||
|
#include <QStandardPaths>
|
||||||
|
#include <QUrl>
|
||||||
|
|
||||||
|
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<void (QNetworkReply::*)(const QList<QSslError> &)>(
|
||||||
|
&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());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
#ifndef APICLIENT_H
|
||||||
|
#define APICLIENT_H
|
||||||
|
|
||||||
|
#include <QNetworkAccessManager>
|
||||||
|
#include <QObject>
|
||||||
|
#include <QVariantList>
|
||||||
|
|
||||||
|
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
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
#include "downloader.h"
|
||||||
|
|
||||||
|
#include "settings.h"
|
||||||
|
|
||||||
|
#include <QFile>
|
||||||
|
#include <QNetworkReply>
|
||||||
|
#include <QNetworkRequest>
|
||||||
|
#include <QSslError>
|
||||||
|
#include <QUrl>
|
||||||
|
|
||||||
|
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<void (QNetworkReply::*)(const QList<QSslError> &)>(
|
||||||
|
&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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
#ifndef DOWNLOADER_H
|
||||||
|
#define DOWNLOADER_H
|
||||||
|
|
||||||
|
#include <QHash>
|
||||||
|
#include <QNetworkAccessManager>
|
||||||
|
#include <QObject>
|
||||||
|
|
||||||
|
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<QNetworkReply *, QFile *> m_files;
|
||||||
|
QHash<QNetworkReply *, QString> m_paths;
|
||||||
|
};
|
||||||
|
|
||||||
|
#endif // DOWNLOADER_H
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
#include <QGuiApplication>
|
||||||
|
#include <QQuickView>
|
||||||
|
#include <QQmlContext>
|
||||||
|
#include <sailfishapp.h>
|
||||||
|
|
||||||
|
#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();
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
#include "opdsparser.h"
|
||||||
|
|
||||||
|
#include <QStringList>
|
||||||
|
#include <QVariantMap>
|
||||||
|
#include <QXmlStreamReader>
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
#ifndef OPDSPARSER_H
|
||||||
|
#define OPDSPARSER_H
|
||||||
|
|
||||||
|
#include <QByteArray>
|
||||||
|
#include <QVariantList>
|
||||||
|
#include <QString>
|
||||||
|
|
||||||
|
// Parser minimalista dei feed Atom/OPDS.
|
||||||
|
// Ogni <entry> 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
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
#include "settings.h"
|
||||||
|
|
||||||
|
#include <QDir>
|
||||||
|
#include <QStandardPaths>
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
#ifndef SETTINGS_H
|
||||||
|
#define SETTINGS_H
|
||||||
|
|
||||||
|
#include <QObject>
|
||||||
|
#include <QSettings>
|
||||||
|
|
||||||
|
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
|
||||||
Reference in New Issue
Block a user