Client OPDS per Calibre Web (Sailfish OS): core C++ testato, UI Silica, packaging RPM
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user