Client OPDS per Calibre Web (Sailfish OS): core C++ testato, UI Silica, packaging RPM

This commit is contained in:
2026-08-12 01:45:31 +02:00
commit 85b4f8e9b9
25 changed files with 1518 additions and 0 deletions
+176
View File
@@ -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
}
}
}
}
+187
View File
@@ -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
}
}
}
}
}
+107
View File
@@ -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
}
}
}
+52
View File
@@ -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 })
}
}
+103
View File
@@ -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
}
}
}