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