Compare commits

..
1 Commits
Author SHA1 Message Date
kaneda cb907bf4dc Initial commit 2026-08-20 22:02:01 +02:00
34 changed files with 0 additions and 3561 deletions
-6
View File
@@ -1,6 +0,0 @@
build/
build-*/
*.rpm
*.user
Makefile
.qmake.stash
-105
View File
@@ -1,105 +0,0 @@
# harbour-calibreweb
**OPDS** client for Sailfish OS: browse and download books from your
**Calibre Web** instance (or Calibre's Content Server).
Works with any OPDS server, but is designed for Calibre Web:
navigation feeds, search, covers, downloads with progress.
## Why OPDS instead of the REST API
Recent calibre-web releases (upstream) **no longer ship the REST API**:
`/api/*` was removed and does not exist in any 0.6.9+ release nor in master.
The stable, documented interface is **OPDS** (`/opds`, `/opds/new`,
`/opds/search?query=...`), which uses HTTP Basic Auth and exposes
per-format download links. OPDS is also supported by Calibre's Content
Server, so this app covers both backends.
## Features
- Catalog sections (new arrivals, authors, categories, shelves, ...) from the `/opds` index
- Automatic pagination (`rel="next"` links)
- Text search (`/opds/search`)
- Covers with local cache
- Book detail: author, description, formats with size
- Download with progress bar to `~/Downloads`
- HTTP Basic Auth (empty user = anonymous browsing)
- "Ignore SSL errors" option for self-signed certificates
## Build
You need the [Sailfish SDK](https://sailfishos.org/wiki/Sailfish_SDK)
(on Linux: `flatpak install flathub org.sailfishos.sdk` or the tarball).
```sh
git clone <this repo> harbour-calibreweb
cd harbour-calibreweb
# pick a target, e.g.:
sfdk target install SailfishOS-4.6.0.11-armv7hl
sfdk target use SailfishOS-4.6.0.11-armv7hl
sfdk build # produces the RPM in RPMS/
sfdk deploy # installs on the connected device
```
Alternatively, install the RPM manually:
```sh
pkcon install-local harbour-calibreweb-0.1.0-1.armv7hl.rpm
```
Recommended targets by device: `armv7hl` (Xperia X, Jolla C...),
`aarch64` (Xperia 10 II/III, Jolla C2), `x86_64` (emulator).
For the **Jolla Phone 2026** (MediaTek Dimensity, jp2601): `aarch64` with
Sailfish OS 5.0, e.g. `SailfishOS-5.0.0.62-aarch64` (or the latest 5.0.x
available in your SDK version).
## Configuration
Open the app → menu → **Settings**:
| Field | Value |
|---|---|
| Calibre Web address | e.g. `https://calibre.example.com` (subpath supported) |
| User | leave empty if anonymous browsing is enabled on calibre-web |
| Password | your account password (Basic Auth) |
| Ignore SSL | enable only if the server uses a self-signed certificate |
Then hit **Test connection** and browse.
## Structure
```
src/ C++: settings (QSettings), apiclient (feeds+covers),
downloader (progress), opdsparser (Atom/OPDS), main
qml/pages/ MainPage (index), FeedPage (lists+pagination),
BookDetailPage (detail+download), SearchPage, SettingsPage
qml/cover/ app cover
icons/ 86/108 px icons + generation script
rpm/ spec and desktop file
```
## Known limitations
- The password is stored in plain text in the app settings
(`/home/nemo/.config/harbour/calibreweb.conf`) — acceptable for personal
use. The file is fixed and shared between the launcher (nemo) and SSH
debug (defaultuser): the config does not depend on which user launches
the app.
- Search uses the server's engine; results are identical to the web UI.
- The C++ core (parsing, auth, download) is tested with Qt 5.15 against
real OPDS feeds and a Basic Auth mock; the Silica UI requires the SDK
for on-device verification.
## Core tests
```sh
# mock OPDS server + end-to-end test (see local /tmp repo):
python3 /tmp/mock_opds.py
# compile and run /tmp/opds_test (Qt 5.15+)
```
## License
MIT
-102
View File
@@ -1,104 +1,2 @@
# 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).
Per il **Jolla Phone 2026** (MediaTek Dimensity, jp2601): `aarch64` con
Sailfish OS 5.0, es. `SailfishOS-5.0.0.62-aarch64` (o l'ultimo 5.0.x
disponibile nella tua versione dell'SDK).
## 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
(`/home/nemo/.config/harbour/calibreweb.conf`) — accettabile per uso
personale. Il file è fisso e condiviso tra launcher (nemo) e debug SSH
(defaultuser): la config non dipende dall'utente che avvia l'app.
- 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
-51
View File
@@ -1,51 +0,0 @@
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 \
src/epub.cpp
HEADERS += \
src/settings.h \
src/apiclient.h \
src/downloader.h \
src/opdsparser.h \
src/epub.h
LIBS += -lz
OTHER_FILES += \
qml/harbour-calibreweb.qml \
qml/pages/*.qml \
qml/cover/*.qml \
rpm/harbour-calibreweb.spec \
rpm/harbour-calibreweb.desktop \
rpm/harbour-calibreweb.yaml \
translations/harbour-calibreweb_it.ts
DISTFILES += $$OTHER_FILES
# Translations are NOT handled by the sailfishapp_i18n template on the 5.1 kit
# (the .qm is never generated/installed reliably). The spec builds the .qm
# with lrelease and installs it into /usr/share/translations directly;
# libsailfishapp loads harbour-calibreweb_<locale>.qm automatically.
# Source strings are ENGLISH (default language); harbour-calibreweb_it.ts
# provides the Italian catalogue.
# Icone installate via make install (struttura standard Sailfish: icons/<size>/<name>.png)
icons86.path = /usr/share/icons/hicolor/86x86/apps
icons86.files = $$PWD/icons/86x86/harbour-calibreweb.png
icons108.path = /usr/share/icons/hicolor/108x108/apps
icons108.files = $$PWD/icons/108x108/harbour-calibreweb.png
icons128.path = /usr/share/icons/hicolor/128x128/apps
icons128.files = $$PWD/icons/128x128/harbour-calibreweb.png
icons172.path = /usr/share/icons/hicolor/172x172/apps
icons172.files = $$PWD/icons/172x172/harbour-calibreweb.png
INSTALLS += icons86 icons108 icons128 icons172
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

-87
View File
@@ -1,87 +0,0 @@
#!/usr/bin/env python3
"""Genera le icone dell'app in stile Sailfish OS:
sfondo a gradiente azzurro->blu con angoli molto arrotondati,
libro aperto bianco con ombra morbida e righe di testo tenui.
Dimensioni: 86, 108, 128, 172 (struttura standard hicolor)."""
import os
from PIL import Image, ImageDraw
# gradiente Sailfish: azzurro chiaro (alto) -> blu profondo (basso)
C_TOP = (94, 210, 240) # #5ED2F0
C_BOT = (14, 111, 168) # #0E6FA8
PAGE = (255, 255, 255)
TEXT = (120, 190, 225) # righe del testo, tenue
SPINE = (200, 225, 240)
def gradient(size, c1, c2):
img = Image.new("RGB", (size, size))
d = ImageDraw.Draw(img)
for y in range(size):
t = y / (size - 1)
d.line([(0, y), (size, y)],
fill=(int(c1[0] + (c2[0] - c1[0]) * t),
int(c1[1] + (c2[1] - c1[1]) * t),
int(c1[2] + (c2[2] - c1[2]) * t)))
return img
def draw_icon(size):
m = size * 0.05
r = size * 0.22
out = Image.new("RGBA", (size, size), (0, 0, 0, 0))
# sfondo: gradiente ritagliato nel rounded rect
grad = gradient(size, C_TOP, C_BOT).convert("RGBA")
mask = Image.new("L", (size, size), 0)
ImageDraw.Draw(mask).rounded_rectangle([m, m, size - m, size - m],
radius=r, fill=255)
out.paste(grad, (0, 0), mask)
d = ImageDraw.Draw(out)
cx = size / 2
top = size * 0.33
bot = size * 0.66
half = size * 0.15
ol = size * 0.24
orr = size * 0.76
def pages():
return ([(cx - half, top), (cx, top), (cx, bot), (ol, bot + size * 0.035)],
[(cx, top), (cx + half, top), (orr, bot + size * 0.035), (cx, bot)])
lp, rp = pages()
# ombra morbida sotto il libro (rilievo stile Sailfish)
sh = size * 0.022
shadow = Image.new("RGBA", (size, size), (0, 0, 0, 0))
ds = ImageDraw.Draw(shadow)
lps, rps = pages()
ds.polygon([(x, y + sh) for x, y in lps], fill=(0, 60, 100, 90))
ds.polygon([(x, y + sh) for x, y in rps], fill=(0, 60, 100, 90))
out.alpha_composite(shadow)
d.polygon(lp, fill=PAGE)
d.polygon(rp, fill=PAGE)
d.rectangle([cx - size * 0.016, top, cx + size * 0.016, bot], fill=SPINE)
# righe di testo sulle pagine
lw = max(1, int(size * 0.012))
for yf, dy in ((0.46, 0.022), (0.53, 0.026), (0.60, 0.030)):
y = size * yf
d.line([(cx - half + size * 0.045, y), (ol + size * 0.05, y + size * dy)],
fill=TEXT, width=lw)
d.line([(cx + half - size * 0.045, y), (orr - size * 0.05, y + size * dy)],
fill=TEXT, width=lw)
return out
def main():
for s in (86, 108, 128, 172):
os.makedirs(f"icons/{s}x{s}", exist_ok=True)
draw_icon(s).save(f"icons/{s}x{s}/harbour-calibreweb.png")
print("icone scritte: 86, 108, 128, 172")
if __name__ == "__main__":
main()
-23
View File
@@ -1,23 +0,0 @@
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/icons/hicolor/86x86/apps/harbour-calibreweb.png"
sourceSize.width: 86
sourceSize.height: 86
}
Label {
anchors.horizontalCenter: parent.horizontalCenter
text: qsTr("Calibre Web")
color: Theme.primaryColor
font.pixelSize: Theme.fontSizeSmall
}
}
}
-14
View File
@@ -1,14 +0,0 @@
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
}
-270
View File
@@ -1,270 +0,0 @@
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: ""
property bool readingNow: false
property string readingUrl: ""
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
}
Button {
anchors.horizontalCenter: parent.horizontalCenter
text: qsTr("Read (EPUB)")
visible: page.hasEpubFormat()
enabled: !page.downloading
onClicked: startReading()
}
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: qsTr("Formats") }
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: qsTr("Downloading…")
color: Theme.secondaryColor
font.pixelSize: Theme.fontSizeSmall
}
}
// banner di conferma download (Notices non è istanziabile in questa Silica)
property string downloadNotice: ""
Label {
x: Theme.horizontalPageMargin
width: parent.width - 2 * Theme.horizontalPageMargin
text: page.downloadNotice
color: Theme.highlightColor
wrapMode: Text.Wrap
visible: text.length > 0
}
Timer {
id: hideNoticeTimer
interval: 4000
onTriggered: page.downloadNotice = ""
}
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
// il server dietro sottopath (es. /calibre) emette link che lo includono già:
// evita di raddoppiarlo (solo operazioni su stringhe, niente regex)
var schemeEnd = base.indexOf("://")
var slash = base.indexOf("/", schemeEnd + 3)
var basePath = slash >= 0 ? base.substring(slash) : ""
if (basePath.length > 1 && u.indexOf(basePath) === 0)
return base + u.substring(basePath.length)
return base + (u.charAt(0) === "/" ? u : "/" + u)
}
function sanitizeFileName(s) {
var out = ""
for (var i = 0; i < s.length; i++) {
var c = s.charAt(i)
if (c === '/' || c === ':' || c === '*' || c === '?' || c === '"' || c === '<' || c === '>' || c === '|' || c.charCodeAt(0) === 92)
out += '_'
else
out += c
}
return out.trim()
}
function hasEpubFormat() {
var fmts = bookData.formats
if (!fmts)
return false
for (var i = 0; i < fmts.length; i++)
if (String(fmts[i].format).toLowerCase() === "epub")
return true
return false
}
function firstEpubFormat() {
var fmts = bookData.formats
if (!fmts)
return null
for (var i = 0; i < fmts.length; i++)
if (String(fmts[i].format).toLowerCase() === "epub")
return fmts[i]
return null
}
// estrae l'id numerico calibre da un url di download: /opds/download/<id>/<formato>/
function extractBookId(u) {
var parts = u.split("/")
for (var i = 0; i < parts.length - 1; i++)
if (parts[i] === "download" && parts[i + 1].length > 0
&& !isNaN(parseInt(parts[i + 1])))
return parseInt(parts[i + 1])
return 0
}
function startReading() {
var f = page.firstEpubFormat()
if (!f)
return
var id = page.extractBookId(f.url)
if (id <= 0)
return
page.readingNow = true
page.readingUrl = f.url
page.downloading = true
page.downloadError = ""
downloader.download(resolveUrl(f.url), appSettings.bookFilePath(id, "epub"))
}
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, "detailcover")
}
Connections {
target: apiClient
// nota: parametro rinominato (imageId) — "id" è parola chiave QML e rompe il confronto
onImageReady: function(imageId, localPath) {
if (imageId === "detailcover")
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) {
if (page.readingNow) {
page.readingNow = false
var id = page.extractBookId(page.readingUrl)
pageStack.push(Qt.resolvedUrl("ReaderPage.qml"),
{ epubPath: path, bookId: id, bookFormat: "epub" })
return
}
page.downloadError = ""
page.downloadNotice = qsTr("Saved to ") + path
hideNoticeTimer.restart()
} else {
page.downloadNotice = ""
page.readingNow = false
page.downloadError = qsTr("Error: ") + error
}
}
}
}
-41
View File
@@ -1,41 +0,0 @@
import QtQuick 2.0
import Sailfish.Silica 1.0
Dialog {
id: dialog
property var model: []
property int selectedIndex: 0
SilicaListView {
anchors.fill: parent
header: DialogHeader { title: qsTr("Table of contents") }
model: dialog.model
delegate: ListItem {
id: item
contentHeight: Theme.itemSizeMedium
Label {
anchors.left: parent.left
anchors.leftMargin: Theme.horizontalPageMargin
anchors.right: parent.right
anchors.rightMargin: Theme.horizontalPageMargin
anchors.verticalCenter: parent.verticalCenter
text: (modelData.index + 1) + ". " + modelData.title
color: item.highlighted ? Theme.highlightColor : Theme.primaryColor
truncationMode: TruncationMode.Elide
}
onClicked: {
dialog.selectedIndex = modelData.index
dialog.accept()
}
}
ViewPlaceholder {
enabled: dialog.model.length === 0
text: qsTr("No chapters")
}
}
}
-187
View File
@@ -1,187 +0,0 @@
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: qsTr("Refresh")
onClicked: reload()
}
MenuItem {
text: qsTr("Settings")
onClicked: pageStack.push(Qt.resolvedUrl("SettingsPage.qml"))
}
}
ViewPlaceholder {
enabled: feedModel.count === 0 && !page.loading
text: page.errorMessage.length > 0 ? page.errorMessage : qsTr("No results")
}
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 = qsTr("No results")
}
onFeedError: {
page.loading = false
page.loadingMore = false
page.errorMessage = message
}
onImageReady: function(imageId, localPath) {
for (var i = 0; i < feedModel.count; i++) {
if (feedModel.get(i).coverId === imageId) {
feedModel.setProperty(i, "coverSource", localPath)
break
}
}
}
}
}
-107
View File
@@ -1,107 +0,0 @@
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: qsTr("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: qsTr("Search")
onClicked: pageStack.push(Qt.resolvedUrl("SearchPage.qml"))
}
MenuItem {
text: qsTr("Settings")
onClicked: pageStack.push(Qt.resolvedUrl("SettingsPage.qml"))
}
MenuItem {
text: qsTr("Refresh")
onClicked: loadIndex()
}
}
ViewPlaceholder {
enabled: sectionModel.count === 0 && !page.loading
text: page.errorMessage.length > 0
? page.errorMessage
: qsTr("Open the settings and enter your Calibre Web address")
}
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 = qsTr("Server not configured")
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 = qsTr("No sections found in the OPDS feed")
}
onFeedError: {
page.loading = false
page.errorMessage = message
}
}
}
-73
View File
@@ -1,73 +0,0 @@
import QtQuick 2.0
import Sailfish.Silica 1.0
// Regola i margini del testo nel lettore senza uscire dal libro.
// Anteprima LIVE: ogni variazione degli slider viene inoltrata al lettore via
// previewHandler (runJavaScript sul documento aperto), quindi il testo si
// adatta subito. I valori vengono persistiti solo all'accettazione.
Dialog {
id: dialog
property int marginLr: 24
property int marginTb: 0
// funzione chiamata a ogni variazione: previewHandler(marginLr, marginTb)
property var previewHandler: null
function preview() {
if (dialog.previewHandler)
dialog.previewHandler(dialog.marginLr, dialog.marginTb)
}
Column {
width: parent.width
DialogHeader {
title: qsTr("Text margins")
acceptText: qsTr("Apply")
}
Slider {
id: lrSlider
width: parent.width
label: qsTr("Side margins")
minimumValue: 0
maximumValue: 80
stepSize: 4
value: dialog.marginLr
valueText: value + " px"
onValueChanged: {
dialog.marginLr = value
dialog.preview()
}
}
Slider {
id: tbSlider
width: parent.width
label: qsTr("Top and bottom")
minimumValue: 0
maximumValue: 80
stepSize: 4
value: dialog.marginTb
valueText: value + " px"
onValueChanged: {
dialog.marginTb = value
dialog.preview()
}
}
Label {
x: Theme.horizontalPageMargin
width: parent.width - 2 * Theme.horizontalPageMargin
text: qsTr("Changes apply immediately. Confirm to save them for all books.")
color: Theme.secondaryColor
wrapMode: Text.Wrap
font.pixelSize: Theme.fontSizeSmall
}
}
onAccepted: {
appSettings.readerMargin = dialog.marginLr
appSettings.readerMarginV = dialog.marginTb
}
}
-382
View File
@@ -1,382 +0,0 @@
import QtQuick 2.0
import Sailfish.Silica 1.0
import Sailfish.WebView 1.0
import harbour.calibreweb 1.0
Page {
id: page
property string epubPath: ""
property int bookId: 0
property string bookFormat: "epub"
property int currentChapter: 0
property double chapterFraction: 0.0
property bool ready: false
property bool restoring: false
property int saveCounter: 0
property string errorMessage: ""
// reflow: scala il font (il testo si ri-avvolge), non lo zoom grafico
property double fontScale: 1.0
allowedOrientations: Orientation.All
EpubBook {
id: book
}
SilicaFlickable {
anchors.fill: parent
contentHeight: parent.height
// il PullDownMenu va DENTRO una vista Silica, non a livello di Page
PullDownMenu {
MenuItem {
text: qsTr("Table of contents")
onClicked: showChapterList()
}
MenuItem {
text: qsTr("Text margins")
onClicked: {
var dlg = pageStack.push(Qt.resolvedUrl("MarginDialog.qml"), {
marginLr: appSettings.readerMargin,
marginTb: appSettings.readerMarginV,
previewHandler: page.previewMargins
})
dlg.accepted.connect(function() {
// i valori sono già persistiti da onAccepted; rinfresca subito
applyMarginsJS()
})
}
}
MenuItem {
text: qsTr("Settings")
onClicked: pageStack.push(Qt.resolvedUrl("SettingsPage.qml"))
}
MenuItem {
text: qsTr("Close book")
onClicked: {
page.maybeSave(true)
// differito: pop() subito dall'handler della pagina che viene
// distrutta crasha; pop(null) = torna alla prima pagina
closeBookTimer.start()
}
}
}
// controlli del lettore: un solo trascinamento in alto li apre
PushUpMenu {
id: readerMenu
Label {
anchors.horizontalCenter: parent.horizontalCenter
width: parent.width - 2 * Theme.horizontalPageMargin
text: (book.chapterCount > 0)
? (page.currentChapter + 1) + "/" + book.chapterCount
+ " · " + book.chapterTitle(page.currentChapter)
+ " · " + Math.round(page.overallProgress() * 100) + "%"
: "…"
truncationMode: TruncationMode.Elide
color: Theme.primaryColor
horizontalAlignment: Text.AlignHCenter
font.pixelSize: Theme.fontSizeSmall
}
Row {
anchors.horizontalCenter: parent.horizontalCenter
spacing: Theme.paddingSmall
IconButton {
icon.source: "image://theme/icon-m-left"
enabled: page.currentChapter > 0
onClicked: gotoChapter(page.currentChapter - 1)
}
Button {
text: "A"
width: Theme.itemSizeSmall
height: Theme.itemSizeMedium
onClicked: page.changeFontScale(-0.15)
}
Button {
text: "A+"
width: Theme.itemSizeSmall
height: Theme.itemSizeMedium
onClicked: page.changeFontScale(0.15)
}
IconButton {
icon.source: "image://theme/icon-m-right"
enabled: page.currentChapter < book.chapterCount - 1
onClicked: gotoChapter(page.currentChapter + 1)
}
}
ProgressBar {
width: parent.width
value: page.overallProgress()
minimumValue: 0
maximumValue: 1
}
}
WebView {
id: web
anchors.fill: parent
active: true
onLoadedChanged: {
if (!loaded)
return
loadWatchdog.stop()
page.ready = true
// ripristina la posizione in frazione sull'altezza ricalcolata
if (page.chapterFraction > 0.01) {
web.runJavaScript(
"window.scrollTo(0," + page.chapterFraction
+ "*Math.max(1,document.documentElement.scrollHeight-window.innerHeight));true",
function() { })
}
page.restoring = false
}
}
// Paginazione a tap: niente scroll libero. La meta' destra va avanti di
// una pagina, la sinistra indietro. Le fasce superiore e inferiore sono
// lasciate ai menu (PullDown in alto, PushUp in basso).
MouseArea {
id: tapZone
anchors.fill: parent
anchors.topMargin: Theme.itemSizeLarge
anchors.bottomMargin: Theme.itemSizeLarge + Theme.paddingLarge
onClicked: {
if (mouse.x > width / 2)
page.nextPage()
else
page.prevPage()
}
}
}
BusyIndicator {
anchors.centerIn: parent
running: !page.ready
size: BusyIndicatorSize.Large
}
// watchdog: se il capitolo non si carica, niente spinner infinito
Timer {
id: loadWatchdog
interval: 15000
onTriggered: {
if (!web.loaded && !page.ready) {
page.errorMessage = qsTr("The reader is not responding (WebView). The EPUB file may be damaged.")
page.ready = true
}
}
}
// ritardo di apertura: evita la race tra onCompleted e l'init del WebView
Timer {
id: openTimer
interval: 100
onTriggered: openChapter()
}
// chiusura libro differita (pop dalla MainPage, non dall'handler del ReaderPage)
Timer {
id: closeBookTimer
interval: 50
onTriggered: pageStack.pop(null)
}
Label {
anchors.centerIn: parent
text: page.errorMessage
color: Theme.errorColor
visible: page.errorMessage.length > 0
}
// ---- controlli spostati nel PushUpMenu (trascina in alto) ----
// ---- timer di tracciamento ----
Timer {
id: trackTimer
interval: 2000
running: page.ready
repeat: true
onTriggered: readScrollPosition()
}
function overallProgress() {
if (book.chapterCount <= 0)
return 0
return (page.currentChapter + page.chapterFraction) / book.chapterCount
}
function readScrollPosition() {
if (!web.loaded)
return
web.runJavaScript(
"window.scrollY / Math.max(1, document.documentElement.scrollHeight - window.innerHeight)",
function(f) {
var v = Math.max(0, Math.min(1, f))
if (Math.abs(v - page.chapterFraction) > 0.005) {
page.chapterFraction = v
maybeSave(true)
}
})
}
function maybeSave(force) {
if (bookId <= 0)
return
page.saveCounter++
// salva in locale ogni ~10 s (o subito su forzatura)
if (force || page.saveCounter % 5 === 0) {
appSettings.setBookmarkKey(page.bookId, page.bookFormat,
book.makeBookmarkKey(page.currentChapter, page.chapterFraction))
}
}
function gotoChapter(index) {
if (index < 0 || index >= book.chapterCount)
return
if (index !== page.currentChapter) {
maybeSave(true)
page.chapterFraction = 0
}
page.currentChapter = index
loadChapter()
}
function changeFontScale(delta) {
page.fontScale = Math.max(0.7, Math.min(2.5, page.fontScale + delta))
// ricarica il capitolo con la nuova dimensione: il reflow avviene al load
loadChapter()
}
// pagina successiva: se l'ultima pagina del capitolo, passa al capitolo dopo
function nextPage() {
if (!web.loaded || !page.ready)
return
web.runJavaScript(
"var vh=window.innerHeight;" +
"var cur=Math.floor(window.scrollY/vh);" +
"var max=Math.max(0,document.documentElement.scrollHeight-window.innerHeight);" +
"var target=(cur+1)*vh;" +
"if(target<=max){window.scrollTo(0,target);true}else{false}",
function(hasNext) {
if (hasNext)
readScrollPosition()
else
gotoChapter(page.currentChapter + 1)
})
}
// pagina precedente: se la prima del capitolo, torna al capitolo prima
function prevPage() {
if (!web.loaded || !page.ready)
return
web.runJavaScript(
"var vh=window.innerHeight;" +
"var cur=Math.floor(window.scrollY/vh);" +
"if(cur>0){window.scrollTo(0,(cur-1)*vh);true}else{false}",
function(hasPrev) {
if (hasPrev)
readScrollPosition()
else
gotoChapter(page.currentChapter - 1)
})
}
function fontPx() {
return Math.round(16 * page.fontScale * 100) / 100
}
// carica il capitolo corrente via loadHtml: lo stile di reflow (font-size
// e margini) è iniettato nell'HTML dal parser, quindi niente JS post-load
function loadChapter() {
var html = book.chapterHtml(page.currentChapter, page.fontPx(),
appSettings.readerMargin, appSettings.readerMarginV)
if (html.length === 0) {
page.errorMessage = qsTr("Chapter unreadable")
page.ready = true
return
}
page.ready = false
page.restoring = true
loadWatchdog.restart()
web.loadHtml(html, book.chapterBaseUrl(page.currentChapter))
}
function showChapterList() {
var dialog = pageStack.push(Qt.resolvedUrl("ChapterListDialog.qml"),
{ model: book.chapterList() })
dialog.accepted.connect(function() {
gotoChapter(dialog.selectedIndex)
})
}
// JS che sovrascrive i margini sul documento aperto: stile inline con
// 'important' batte lo stylesheet iniettato. Effetto immediato, niente reload.
function marginJs(lr, tb) {
return "var h=document.documentElement,b=document.body;"
+ "h.style.setProperty('margin-left','" + lr + "px','important');"
+ "h.style.setProperty('margin-right','" + lr + "px','important');"
+ "b.style.setProperty('margin-left','" + lr + "px','important');"
+ "b.style.setProperty('margin-right','" + lr + "px','important');"
+ "b.style.setProperty('margin-top','" + tb + "px','important');"
+ "b.style.setProperty('margin-bottom','" + tb + "px','important');"
+ "true"
}
// riapplica i margini salvati nelle impostazioni (senza ricaricare il libro)
function applyMarginsJS() {
if (!web.loaded || !page.ready)
return
web.runJavaScript(marginJs(appSettings.readerMargin, appSettings.readerMarginV),
function() { })
}
// anteprima live dal dialogo margini: valori non ancora persistiti
function previewMargins(lr, tb) {
if (!web.loaded || !page.ready)
return
web.runJavaScript(marginJs(lr, tb), function() { })
}
Component.onCompleted: {
page.ready = false
if (!book.open(page.epubPath)) {
page.errorMessage = qsTr("Unable to open the EPUB file")
page.ready = true
return
}
openTimer.start()
}
function openChapter() {
// ripristina il segno locale
var key = appSettings.bookmarkKey(page.bookId, page.bookFormat)
var ch = 0
var fr = 0.0
if (key.length > 0) {
var r = book.parseBookmarkKey(key)
if (r && r.hasOwnProperty("chapter")) {
ch = Math.max(0, Math.min(r.chapter, book.chapterCount - 1))
fr = r.fraction
}
}
page.currentChapter = ch
page.chapterFraction = fr
loadChapter()
}
onStatusChanged: {
if (status === PageStatus.Inactive) {
maybeSave(true)
} else if (status === PageStatus.Active) {
// riapplica i margini se cambiati nelle impostazioni:
// via JS sul documento aperto, senza ricaricare il capitolo
if (page.ready && book.chapterCount() > 0)
applyMarginsJS()
}
}
}
-52
View File
@@ -1,52 +0,0 @@
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: qsTr("Search") }
SearchField {
id: searchField
width: parent.width
placeholderText: qsTr("Title or author…")
EnterKey.enabled: text.length > 0
EnterKey.onClicked: search()
}
Button {
anchors.horizontalCenter: parent.horizontalCenter
text: qsTr("Search")
enabled: searchField.text.length > 0
onClicked: search()
}
Label {
x: Theme.horizontalPageMargin
width: parent.width - 2 * Theme.horizontalPageMargin
text: qsTr("Search uses the server engine (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: qsTr("Results: ") + query })
}
}
-189
View File
@@ -1,189 +0,0 @@
import QtQuick 2.0
import Sailfish.Silica 1.0
Page {
id: page
property string testResult: ""
property bool testOk: false
property bool testing: false
property string languageNotice: ""
SilicaFlickable {
anchors.fill: parent
contentHeight: column.height
Column {
id: column
width: parent.width
PageHeader { title: qsTr("Settings") }
SectionHeader { text: qsTr("Server") }
TextField {
id: serverField
anchors.left: parent.left
anchors.right: parent.right
label: qsTr("Calibre Web address")
placeholderText: "https://calibre.example.com"
text: appSettings.serverUrl
inputMethodHints: Qt.ImhUrlCharactersOnly
onTextChanged: appSettings.serverUrl = text
}
TextField {
id: userField
anchors.left: parent.left
anchors.right: parent.right
label: qsTr("User (empty = anonymous access)")
text: appSettings.username
onTextChanged: appSettings.username = text
}
TextField {
id: passField
anchors.left: parent.left
anchors.right: parent.right
label: qsTr("Password")
text: appSettings.password
echoMode: TextInput.Password
onTextChanged: appSettings.password = text
}
TextSwitch {
text: qsTr("Ignore SSL certificate errors")
description: qsTr("Enable if the server uses a self-signed certificate")
checked: appSettings.ignoreSslErrors
onCheckedChanged: appSettings.ignoreSslErrors = checked
}
SectionHeader { text: qsTr("Reader") }
Slider {
id: marginSlider
width: parent.width
label: qsTr("Side margins")
minimumValue: 0
maximumValue: 80
stepSize: 4
value: appSettings.readerMargin
valueText: value + " px"
onValueChanged: appSettings.readerMargin = value
}
Slider {
id: marginVSlider
width: parent.width
label: qsTr("Top and bottom margin")
minimumValue: 0
maximumValue: 80
stepSize: 4
value: appSettings.readerMarginV
valueText: value + " px"
onValueChanged: appSettings.readerMarginV = value
}
SectionHeader { text: qsTr("Connection") }
Button {
anchors.horizontalCenter: parent.horizontalCenter
text: qsTr("Test connection")
enabled: !page.testing
onClicked: testConnection()
}
Item {
width: parent.width
height: page.testing ? Theme.itemSizeSmall : 0
BusyIndicator {
anchors.horizontalCenter: parent.horizontalCenter
running: page.testing
size: BusyIndicatorSize.Small
}
}
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
}
SectionHeader { text: qsTr("Language") }
ComboBox {
id: langBox
width: parent.width
label: qsTr("Language")
currentIndex: appSettings.language === "it" ? 2 : (appSettings.language === "en" ? 1 : 0)
menu: ContextMenu {
MenuItem { text: qsTr("System (default)") }
MenuItem { text: "English" }
MenuItem { text: "Italiano" }
}
onCurrentIndexChanged: {
var v = currentIndex === 2 ? "it" : (currentIndex === 1 ? "en" : "system")
if (v !== appSettings.language) {
appSettings.language = v
page.languageNotice = qsTr("Language will be applied after restarting the app")
langNoticeTimer.restart()
}
}
}
Label {
x: Theme.horizontalPageMargin
width: parent.width - 2 * Theme.horizontalPageMargin
text: page.languageNotice
color: Theme.highlightColor
wrapMode: Text.Wrap
visible: text.length > 0
}
Timer {
id: langNoticeTimer
interval: 4000
onTriggered: page.languageNotice = ""
}
Item { width: 1; height: Theme.paddingLarge }
}
}
function testConnection() {
var base = appSettings.serverUrl.trim()
if (base.length === 0) {
page.testResult = qsTr("Enter the server address")
page.testOk = false
return
}
// normalizza: senza schema aggiunge https:// e lo salva nel campo
if (base.indexOf("http://") !== 0 && base.indexOf("https://") !== 0)
base = "https://" + base
appSettings.serverUrl = base
page.testing = true
page.testResult = qsTr("Testing…")
apiClient.getFeed(base + "/opds")
}
Connections {
target: apiClient
onFeedReady: {
if (pageStack.currentPage !== page)
return
page.testing = false
page.testOk = true
page.testResult = qsTr("Connection OK: %1 sections found").arg(entries.length)
}
onFeedError: {
if (pageStack.currentPage !== page)
return
page.testing = false
page.testOk = false
page.testResult = message
}
}
}
-20
View File
@@ -1,20 +0,0 @@
[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
X-Nemo-Application-Type=silica-qt5
Categories=Office;Viewer;
# Profilo SailJail esplicito: senza X-Sailjail la sandbox di Sailfish 5.1 usa il
# profilo default, che nasconde ~/.config/harbour (config non persistente).
# Con OrganizationName/ApplicationName la sandbox rende persistente
# ~/.config/harbour/calibreweb/ (AppConfigLocation). Internet = rete,
# Downloads = salvataggio libri.
[X-Sailjail]
Permissions=Internet;Downloads
OrganizationName=harbour
ApplicationName=calibreweb
-106
View File
@@ -1,106 +0,0 @@
Name: harbour-calibreweb
Summary: Client OPDS per Calibre Web
Version: 0.2.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, download
in tutti i formati disponibili e lettura EPUB integrata con
reflow del testo, margini configurabili e posizione salvata
in locale.
%changelog
* Wed Aug 19 2026 Carlo Baratto <carlo@carlobaratto.it> - 0.2.0-1
- Sandbox SailJail (Sailfish 4.4+/5.x): profilo X-Sailjail esplicito nel desktop
file (Internet;Downloads, org harbour/app calibreweb) e config in
AppConfigLocation (~/.config/harbour/calibreweb/calibreweb.conf, l'unica zona
persistente della sandbox). Senza profilo la sandbox nascondeva ~/.config/harbour
e le impostazioni dal launcher sparivano (config solo funzionante da SSH).
Migrazione automatica dal vecchio file per-utente.
- Fix crash 'Close book': pop(null) differito con Timer (distruggere la pagina
dall'handler del suo MenuItem crashava)
- Config condivisa nemo/defaultuser: permessi aperti su /home/nemo/.config/harbour
e sul file a ogni scrittura (il launcher gira come nemo, il debug SSH come
defaultuser: prima le impostazioni salvate da un lato sparivano dall'altro)
- Selettore lingua nelle impostazioni (System/English/Italiano, applicata al
riavvio; QTranslator in main.cpp, pattern quill). Catalogo .qm nel data dir
dell'app (/usr/share/harbour-calibreweb/translations, il validatore RPM
rifiuta /usr/share/translations)
- Localizzazione: lingua di default inglese (qsTr ovunque), catalogo
italiano harbour-calibreweb_it.ts/.qm nel data dir dell'app
- Fix configurazione persa: file di configurazione unico e fisso
(/home/nemo/.config/harbour/calibreweb.conf) indipendente dall'utente
che avvia l'app (launcher nemo / debug SSH defaultuser), con migrazione
automatica dei valori gia' salvati
- Config auto-adattiva: la home grafica su Jolla Phone 2026 (Sailfish 5.1)
e' /home/rootitanium (non nemo): candidate rootitanium -> nemo -> home
dell'utente corrente, file esistente prima, poi prima home scrivibile;
permessi aperti sulla home scelta a ogni scrittura
- Lettore EPUB nativo (WebView): capitoli, reflow A-/A+, margini
configurabili, posizione salvata in locale (QSettings) e ripristinata
all'apertura
- Parser EPUB in C++ (ZIP minimale + container/OPF/spine, TOC EPUB2+3)
- Rimossa la sync server dell'avanzamento (route web con login di
sessione): resta solo il segno locale
- URL risolti RFC 3986 (sottopath /calibre non piu raddoppiato),
copertine validate prima del cache, pagine corrette per Silica 5.1
- Icone in struttura standard hicolor
%prep
%setup -q -n %{name}-%{version}
%build
%qmake5
make %{?_smp_mflags}
# Translation catalogue: generated explicitly with lrelease (the sailfishapp_i18n
# template does not produce the .qm reliably on the 5.1 kit). The .ts lives in
# the source tree; sfdk uses the sources live (shadow build), so locate it
# across the possible layouts.
if [ -f %{_builddir}/../%{name}-%{version}/translations/%{name}_it.ts ]; then
TS=%{_builddir}/../%{name}-%{version}/translations/%{name}_it.ts
elif [ -f %{_builddir}/%{name}-%{version}/translations/%{name}_it.ts ]; then
TS=%{_builddir}/%{name}-%{version}/translations/%{name}_it.ts
elif [ -f %{_builddir}/translations/%{name}_it.ts ]; then
TS=%{_builddir}/translations/%{name}_it.ts
else
echo "ERROR: %{name}_it.ts not found" >&2
exit 1
fi
lrelease "$TS" -qm %{_builddir}/%{name}_it.qm
%install
make install INSTALL_ROOT=%{buildroot}
mkdir -p %{buildroot}%{_datadir}/applications
install -m 0644 %{SOURCE1} %{buildroot}%{_datadir}/applications/%{name}.desktop
desktop-file-validate %{buildroot}%{_datadir}/applications/%{name}.desktop || true
# Italian translation: installed in the app data dir (the RPM validator
# rejects /usr/share/translations); main.cpp loads it from there.
mkdir -p %{buildroot}%{_datadir}/%{name}/translations
install -m 0644 %{_builddir}/%{name}_it.qm %{buildroot}%{_datadir}/%{name}/translations/
%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}/icons/hicolor/128x128/apps/%{name}.png
%{_datadir}/icons/hicolor/172x172/apps/%{name}.png
%{_datadir}/%{name}/qml
%{_datadir}/%{name}/translations/%{name}*.qm
-15
View File
@@ -1,15 +0,0 @@
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, copertine, lettore EPUB
e download in tutti i formati disponibili.
Sources:
- '%{name}-%{version}.tar.gz'
Builder: qmake5
-187
View File
@@ -1,187 +0,0 @@
#include "apiclient.h"
#include "opdsparser.h"
#include "settings.h"
#include <QCryptographicHash>
#include <QDebug>
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QNetworkReply>
#include <QNetworkRequest>
#include <QSslError>
#include <QStandardPaths>
#include <QTimer>
#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;
// risoluzione RFC 3986: gestisce i path assoluti del server (es. /calibre/opds/books)
// senza raddoppiare il sottopath già presente nel baseUrl
QUrl base(m_settings->baseUrl());
return base.resolved(QUrl(relative)).toString();
}
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));
}
// timeout di sicurezza (30 s): senza, una richiesta verso un host irraggiungibile
// resta appesa all'infinito
QTimer *timer = new QTimer(reply);
timer->setSingleShot(true);
timer->setInterval(30000);
connect(timer, &QTimer::timeout, reply, &QNetworkReply::abort);
timer->start();
return reply;
}
void ApiClient::getFeed(const QString &url)
{
// i link dei feed OPDS sono spesso relativi (/opds/new): risolvi contro il server
const QString resolved = resolveUrl(url);
qDebug() << "getFeed request:" << resolved;
QNetworkReply *reply = startGet(QUrl(resolved));
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 (reply->error() == QNetworkReply::OperationCanceledError)
message = QStringLiteral("Timeout: il server non risponde (30 s)");
else 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();
qDebug() << "getFeed ERROR status:" << status
<< "error:" << reply->error() << "-" << message;
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)) {
qDebug() << "getFeed OK status:" << status
<< "entries:" << entries.size();
emit feedReady(entries, resolveUrl(nextUrl), feedTitle);
} else {
// risposta 2xx ma non XML: quasi sempre una pagina HTML (redirect/login/URL errato)
const QByteArray contentType = reply->header(
QNetworkRequest::ContentTypeHeader).toByteArray();
QString message;
if (contentType.contains("html") || body.trimmed().startsWith("<!DOCTYPE")
|| body.trimmed().startsWith("<html")) {
message = QStringLiteral(
"Il server ha risposto con una pagina HTML, non OPDS. "
"Controlla l'indirizzo (es. sottopath /calibre) e che il browse anonimo "
"o le credenziali siano corretti.");
} else {
message = parseError.isEmpty()
? QStringLiteral("Risposta non valida dal server")
: parseError;
}
qDebug() << "getFeed parse error:" << parseError
<< "content-type:" << contentType;
emit feedError(message);
}
});
}
static bool looksLikeImage(const QByteArray &data)
{
// sniff dei magic byte per PNG/JPEG/GIF/WebP senza dipendere da QtGui
return data.startsWith("\x89PNG") || data.startsWith("\xFF\xD8")
|| data.startsWith("GIF8") || data.startsWith("RIFF");
}
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, imageUrl]() {
reply->deleteLater();
const int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
if (reply->error() != QNetworkReply::NoError || status >= 400) {
emit imageReady(id, QString());
return;
}
const QByteArray body = reply->readAll();
const QByteArray contentType = reply->header(
QNetworkRequest::ContentTypeHeader).toByteArray();
// non cachare risposte che non sono immagini (es. pagine HTML di errore 200)
const bool isImage = contentType.startsWith("image/")
|| (contentType.isEmpty() && looksLikeImage(body));
if (!isImage) {
qDebug() << "fetchImage: risposta non immagine scartata" << imageUrl;
emit imageReady(id, QString());
return;
}
QFile file(localPath);
if (file.open(QIODevice::WriteOnly)) {
file.write(body);
file.close();
emit imageReady(id, localPath);
} else {
emit imageReady(id, QString());
}
});
}
-40
View File
@@ -1,40 +0,0 @@
#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
-78
View File
@@ -1,78 +0,0 @@
#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);
});
}
-34
View File
@@ -1,34 +0,0 @@
#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
-464
View File
@@ -1,464 +0,0 @@
#include "epub.h"
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QHash>
#include <QStringList>
#include <QUrl>
#include <QXmlStreamReader>
#include <zlib.h>
#include <cstring>
// ---------------------------------------------------------------------------
// Lettore ZIP minimale (solo STORE e DEFLATE, niente crittografia)
// ---------------------------------------------------------------------------
namespace {
struct ZipEntry
{
QString name;
quint16 method;
quint32 compSize;
quint32 uncompSize;
quint32 localOffset;
};
quint16 le16(const QByteArray &d, int off)
{
return (quint8)d.at(off) | ((quint8)d.at(off + 1) << 8);
}
quint32 le32(const QByteArray &d, int off)
{
return (quint32)(quint8)d.at(off)
| ((quint32)(quint8)d.at(off + 1) << 8)
| ((quint32)(quint8)d.at(off + 2) << 16)
| ((quint32)(quint8)d.at(off + 3) << 24);
}
bool readZipEntries(const QByteArray &data, QList<ZipEntry> *entries)
{
// EOCD: cerca la firma PK\x05\x06 negli ultimi 64 KB + commento
const int eocdMax = qMin(data.size(), 65557);
int eocd = -1;
for (int i = data.size() - eocdMax; i <= data.size() - 22; ++i) {
if (data.at(i) == 'P' && data.at(i + 1) == 'K'
&& data.at(i + 2) == '\x05' && data.at(i + 3) == '\x06') {
eocd = i;
break;
}
}
if (eocd < 0)
return false;
const quint32 cdOffset = le32(data, eocd + 16);
const quint16 cdCount = le16(data, eocd + 10);
int off = cdOffset;
for (int i = 0; i < cdCount; ++i) {
if (off + 46 > data.size()
|| data.at(off) != 'P' || data.at(off + 1) != 'K'
|| data.at(off + 2) != '\x01' || data.at(off + 3) != '\x02')
return false;
ZipEntry e;
e.method = le16(data, off + 10);
e.compSize = le32(data, off + 20);
e.uncompSize = le32(data, off + 24);
const quint16 nameLen = le16(data, off + 28);
const quint16 extraLen = le16(data, off + 30);
const quint16 commentLen = le16(data, off + 32);
e.localOffset = le32(data, off + 42);
e.name = QString::fromUtf8(data.mid(off + 46, nameLen));
entries->append(e);
off += 46 + nameLen + extraLen + commentLen;
}
return true;
}
QByteArray extractEntryData(const QByteArray &data, const ZipEntry &e)
{
if ((int)e.localOffset + 30 > data.size())
return QByteArray();
const int lo = e.localOffset;
if (data.at(lo) != 'P' || data.at(lo + 1) != 'K'
|| data.at(lo + 2) != '\x03' || data.at(lo + 3) != '\x04')
return QByteArray();
const quint16 nameLen = le16(data, lo + 26);
const quint16 extraLen = le16(data, lo + 28);
const int dataOff = lo + 30 + nameLen + extraLen;
if (dataOff + (int)e.compSize > data.size())
return QByteArray();
const QByteArray raw = data.mid(dataOff, e.compSize);
if (e.method == 0) // STORE
return raw;
if (e.method == 8) { // DEFLATE (raw, senza header zlib)
QByteArray out;
out.resize(e.uncompSize);
z_stream zs;
std::memset(&zs, 0, sizeof(zs));
if (inflateInit2(&zs, -15) != Z_OK)
return QByteArray();
zs.next_in = reinterpret_cast<Bytef *>(const_cast<char *>(raw.constData()));
zs.avail_in = raw.size();
zs.next_out = reinterpret_cast<Bytef *>(out.data());
zs.avail_out = out.size();
const int r = inflate(&zs, Z_FINISH);
inflateEnd(&zs);
if (r != Z_STREAM_END)
return QByteArray();
out.resize(zs.total_out);
return out;
}
return QByteArray(); // metodo non supportato
}
} // namespace
// ---------------------------------------------------------------------------
// EpubBook
// ---------------------------------------------------------------------------
EpubBook::EpubBook(QObject *parent)
: QObject(parent)
{
}
EpubBook::~EpubBook()
{
close();
}
void EpubBook::close()
{
if (!m_baseDir.isEmpty()) {
QDir(m_baseDir).removeRecursively();
m_baseDir.clear();
}
m_chapters.clear();
m_title.clear();
m_author.clear();
}
bool EpubBook::open(const QString &epubPath)
{
close();
m_opfDir.clear();
QFile f(epubPath);
if (!f.open(QIODevice::ReadOnly))
return false;
const QByteArray data = f.readAll();
f.close();
QList<ZipEntry> entries;
if (!readZipEntries(data, &entries))
return false;
m_baseDir = QDir::tempPath() + QStringLiteral("/harbour-calibreweb/books/")
+ QString::number(qHash(epubPath), 16);
if (!QDir().mkpath(m_baseDir))
return false;
// estrai tutto (skip directory e path traversal)
for (const ZipEntry &e : entries) {
if (e.name.endsWith(QLatin1Char('/')) || e.name.contains(QStringLiteral("..")))
continue;
const QByteArray content = extractEntryData(data, e);
if (content.isNull())
continue;
const QString dest = m_baseDir + QLatin1Char('/') + e.name;
QDir().mkpath(QFileInfo(dest).absolutePath());
QFile out(dest);
if (out.open(QIODevice::WriteOnly)) {
out.write(content);
out.close();
}
}
if (!parseContainer())
return false;
if (!parseOpf())
return false;
const bool ok = !m_chapters.isEmpty();
emit changed();
return ok;
}
bool EpubBook::parseContainer()
{
QFile f(m_baseDir + QStringLiteral("/META-INF/container.xml"));
if (!f.open(QIODevice::ReadOnly))
return false;
QXmlStreamReader r(&f);
while (!r.atEnd() && !r.hasError()) {
if (r.readNextStartElement() && r.name() == QLatin1String("rootfile")) {
const QString fullPath = r.attributes().value(QLatin1String("full-path")).toString();
if (!fullPath.isEmpty()) {
// path RELATIVO alla root epub, non assoluto rispetto alla CWD
m_opfDir = QFileInfo(fullPath).path();
if (m_opfDir == QStringLiteral("."))
m_opfDir.clear();
return true;
}
}
}
return false;
}
QString EpubBook::absolutePath(const QString &relativeToOpfDir) const
{
QString rel = relativeToOpfDir;
rel.replace(QLatin1Char('\\'), QLatin1Char('/'));
if (rel.contains(QLatin1String("..")))
return QString();
if (!m_opfDir.isEmpty())
rel = m_opfDir + QLatin1Char('/') + rel;
return QDir(m_baseDir).filePath(rel);
}
bool EpubBook::parseOpf()
{
QFile f(absolutePath(QStringLiteral("content.opf")));
if (!f.exists())
f.setFileName(absolutePath(QStringLiteral("package.opf")));
if (!f.open(QIODevice::ReadOnly))
return false;
QXmlStreamReader r(&f);
// manifest: id -> {href, media-type, properties}
QHash<QString, QString> manifestHref;
QHash<QString, QString> manifestType;
QHash<QString, QString> manifestProps;
// spine: idrefs in ordine
QStringList spine;
QString navItemId;
QString ncxItemId;
while (!r.atEnd() && !r.hasError()) {
const QXmlStreamReader::TokenType t = r.readNext();
if (t != QXmlStreamReader::StartElement)
continue;
const QStringRef name = r.name();
const QXmlStreamAttributes a = r.attributes();
if (name == QLatin1String("title") && m_title.isEmpty()) {
m_title = r.readElementText().trimmed();
} else if (name == QLatin1String("creator") && m_author.isEmpty()) {
m_author = r.readElementText().trimmed();
} else if (name == QLatin1String("item")) {
const QString id = a.value(QLatin1String("id")).toString();
manifestHref.insert(id, a.value(QLatin1String("href")).toString());
manifestType.insert(id, a.value(QLatin1String("media-type")).toString());
const QString props = a.value(QLatin1String("properties")).toString();
manifestProps.insert(id, props);
if (props.contains(QLatin1String("nav")))
navItemId = id;
if (a.value(QLatin1String("media-type")).toString()
== QLatin1String("application/x-dtbncx+xml"))
ncxItemId = id;
} else if (name == QLatin1String("itemref")) {
spine.append(a.value(QLatin1String("idref")).toString());
}
}
if (spine.isEmpty() || manifestHref.isEmpty())
return false;
// TOC: EPUB3 nav oppure EPUB2 NCX
if (!navItemId.isEmpty())
parseTocEpub3(absolutePath(manifestHref.value(navItemId)));
else if (!ncxItemId.isEmpty())
parseTocEpub2(absolutePath(manifestHref.value(ncxItemId)));
// mappa path assoluto -> titolo dal TOC
QHash<QString, QString> titleByPath;
for (const EpubChapter &c : m_chapters)
titleByPath.insert(c.path, c.title);
m_chapters.clear();
// costruisci i capitoli nell'ordine dello spine
int n = 0;
for (const QString &idref : spine) {
const QString href = manifestHref.value(idref);
if (href.isEmpty())
continue;
const QString abs = absolutePath(href);
if (abs.isEmpty() || !QFile::exists(abs))
continue;
EpubChapter c;
c.id = idref;
c.path = abs;
c.title = titleByPath.value(abs);
if (c.title.isEmpty())
c.title = QStringLiteral("Capitolo ") + QString::number(n + 1);
m_chapters.append(c);
++n;
}
return true;
}
void EpubBook::parseTocEpub3(const QString &navPath)
{
QFile f(navPath);
if (!f.open(QIODevice::ReadOnly))
return;
QXmlStreamReader r(&f);
bool inTocNav = false;
int depth = 0;
while (!r.atEnd() && !r.hasError()) {
const QXmlStreamReader::TokenType t = r.readNext();
if (t == QXmlStreamReader::StartElement) {
const QStringRef name = r.name();
if (name == QLatin1String("nav")) {
if (!inTocNav) {
const QString type = r.attributes()
.value(QLatin1String("http://www.idpf.org/2007/ops"),
QLatin1String("type")).toString();
if (type != QLatin1String("toc"))
continue;
inTocNav = true;
}
++depth;
continue;
}
if (!inTocNav || depth == 0)
continue;
if (name == QLatin1String("a")) {
const QString href = r.attributes().value(QLatin1String("href")).toString();
const QString text = r.readElementText().trimmed();
if (!href.isEmpty() && !text.isEmpty()) {
EpubChapter c;
c.path = absolutePath(href);
c.title = text;
m_chapters.append(c);
}
}
} else if (t == QXmlStreamReader::EndElement && r.name() == QLatin1String("nav")) {
if (inTocNav && --depth <= 0)
break;
}
}
}
void EpubBook::parseTocEpub2(const QString &ncxPath)
{
QFile f(ncxPath);
if (!f.open(QIODevice::ReadOnly))
return;
QXmlStreamReader r(&f);
EpubChapter pending;
bool havePending = false;
while (!r.atEnd() && !r.hasError()) {
const QXmlStreamReader::TokenType t = r.readNext();
if (t == QXmlStreamReader::StartElement) {
const QStringRef name = r.name();
if (name == QLatin1String("navPoint")) {
// nel NCX il <text> (titolo) viene PRIMA del <content> (src):
// accumula in pending e chiudi alla fine del navPoint
pending = EpubChapter();
havePending = true;
} else if (name == QLatin1String("text")) {
if (havePending)
pending.title = r.readElementText().trimmed();
} else if (name == QLatin1String("content")) {
const QString src = r.attributes().value(QLatin1String("src")).toString();
if (havePending)
pending.path = absolutePath(src);
}
} else if (t == QXmlStreamReader::EndElement
&& r.name() == QLatin1String("navPoint")) {
if (havePending && !pending.path.isEmpty())
m_chapters.append(pending);
havePending = false;
}
}
}
QString EpubBook::chapterFilePath(int index) const
{
if (index < 0 || index >= m_chapters.size())
return QString();
return m_chapters.at(index).path;
}
QString EpubBook::chapterTitle(int index) const
{
if (index < 0 || index >= m_chapters.size())
return QString();
return m_chapters.at(index).title;
}
QVariantList EpubBook::chapterList() const
{
QVariantList list;
for (int i = 0; i < m_chapters.size(); ++i) {
QVariantMap m;
m.insert(QStringLiteral("index"), i);
m.insert(QStringLiteral("title"), m_chapters.at(i).title);
list.append(m);
}
return list;
}
QString EpubBook::chapterHtml(int index, int fontSizePx, int marginPx, int marginVerticalPx) const
{
if (index < 0 || index >= m_chapters.size())
return QString();
QFile f(m_chapters.at(index).path);
if (!f.open(QIODevice::ReadOnly))
return QString();
QString html = QString::fromUtf8(f.readAll());
// stile di reflow iniettato nel documento: !important vince sui CSS del libro.
// I margini si possono aggiornare a caldo via runJavaScript (setProperty con
// 'important' inline), senza ricaricare il capitolo.
const QString style = QStringLiteral(
"<style id=\"cw-font\">html,body{font-size:%1px!important;"
"margin-left:%2px!important;margin-right:%2px!important;"
"margin-top:%3px!important;margin-bottom:%3px!important}"
"p,div,li,td,blockquote,section{font-size:1em!important}</style>")
.arg(fontSizePx).arg(marginPx).arg(marginVerticalPx);
const int headEnd = html.indexOf(QStringLiteral("</head>"), 0, Qt::CaseInsensitive);
if (headEnd >= 0)
html.insert(headEnd, style);
else
html.prepend(style);
return html;
}
QString EpubBook::chapterBaseUrl(int index) const
{
if (index < 0 || index >= m_chapters.size())
return QString();
return QUrl::fromLocalFile(
QFileInfo(m_chapters.at(index).path).absolutePath() + QStringLiteral("/"))
.toString();
}
QString EpubBook::makeBookmarkKey(int chapterIndex, double fraction) const
{
return QStringLiteral("cw:v1:%1:%2").arg(chapterIndex).arg(fraction, 0, 'f', 4);
}
QVariantMap EpubBook::parseBookmarkKey(const QString &key) const
{
QVariantMap result;
const QStringList parts = key.split(QLatin1Char(':'));
if (parts.size() != 4 || parts.at(0) != QLatin1String("cw")
|| parts.at(1) != QLatin1String("v1"))
return result;
bool okChapter = false, okFrac = false;
const int ch = parts.at(2).toInt(&okChapter);
const double fr = parts.at(3).toDouble(&okFrac);
if (!okChapter || !okFrac)
return result;
result.insert(QStringLiteral("chapter"), ch);
result.insert(QStringLiteral("fraction"), fr);
return result;
}
-67
View File
@@ -1,67 +0,0 @@
#ifndef EPUB_H
#define EPUB_H
#include <QList>
#include <QObject>
#include <QString>
#include <QVariantList>
struct EpubChapter
{
QString id;
QString path; // path assoluto del file HTML estratto
QString title;
};
class EpubBook : public QObject
{
Q_OBJECT
Q_PROPERTY(QString title READ title NOTIFY changed)
Q_PROPERTY(QString author READ author NOTIFY changed)
Q_PROPERTY(int chapterCount READ chapterCount NOTIFY changed)
public:
explicit EpubBook(QObject *parent = 0);
~EpubBook();
Q_INVOKABLE bool open(const QString &epubPath);
Q_INVOKABLE void close();
QString title() const { return m_title; }
QString author() const { return m_author; }
int chapterCount() const { return m_chapters.size(); }
Q_INVOKABLE QString chapterFilePath(int index) const;
Q_INVOKABLE QString chapterTitle(int index) const;
// elenco [{index, title}] per la lista capitoli
Q_INVOKABLE QVariantList chapterList() const;
// HTML del capitolo con lo stile di reflow iniettato (font-size e margini in px)
Q_INVOKABLE QString chapterHtml(int index, int fontSizePx, int marginPx, int marginVerticalPx) const;
// base URL (file://) per risolvere immagini/CSS relativi del capitolo
Q_INVOKABLE QString chapterBaseUrl(int index) const;
// chiave segno locale, es. "cw:v1:3:0.42" (capitolo 3, 42% del capitolo)
Q_INVOKABLE QString makeBookmarkKey(int chapterIndex, double fraction) const;
// restituisce {chapter, fraction} oppure mappa vuota se la chiave non è valida
Q_INVOKABLE QVariantMap parseBookmarkKey(const QString &key) const;
signals:
void changed();
private:
bool extractZip(const QString &epubPath);
bool parseContainer();
bool parseOpf();
void parseTocEpub3(const QString &navPath);
void parseTocEpub2(const QString &ncxPath);
QString absolutePath(const QString &relativeToOpfDir) const;
QString m_baseDir;
QString m_opfDir;
QString m_title;
QString m_author;
QList<EpubChapter> m_chapters;
};
#endif // EPUB_H
-71
View File
@@ -1,71 +0,0 @@
#include <QDebug>
#include <QGuiApplication>
#include <QLocale>
#include <QQuickView>
#include <QQmlContext>
#include <QTranslator>
#include <sailfishapp.h>
#include "apiclient.h"
#include "downloader.h"
#include "epub.h"
#include "settings.h"
Q_DECL_EXPORT int main(int argc, char *argv[])
{
QGuiApplication *app = SailfishApp::application(argc, argv);
// Identita' applicazione allineata al profilo X-Sailjail del desktop file:
// QStandardPaths::AppConfigLocation diventa ~/.config/harbour/calibreweb,
// l'unica zona config persistente della sandbox di Sailfish 5.1.
app->setOrganizationName(QStringLiteral("harbour"));
app->setApplicationName(QStringLiteral("calibreweb"));
Settings settings;
// User-chosen language (Settings -> Language). "system" = follow the
// Sailfish UI language. "en" = force English. "it" = force Italian.
// The .qm lives in the app data dir (the RPM validator rejects
// /usr/share/translations), so libsailfishapp's auto-load cannot be
// used: resolve the language here and install our own translator.
QString lang = settings.language();
if (lang == "system") {
const QString sys = QLocale::system().name();
lang = sys.startsWith(QLatin1String("it")) ? QStringLiteral("it")
: QStringLiteral("en");
}
if (lang == "en" || lang == "it") {
const QList<QTranslator *> trs = app->findChildren<QTranslator *>();
foreach (QTranslator *t, trs) {
app->removeTranslator(t);
t->deleteLater();
}
if (lang == "it") {
QTranslator *translator = new QTranslator(app);
if (translator->load(QStringLiteral("harbour-calibreweb_it"),
QStringLiteral("/usr/share/harbour-calibreweb/translations"))) {
app->installTranslator(translator);
} else {
qWarning() << "harbour-calibreweb: cannot load harbour-calibreweb_it.qm";
}
}
}
QQuickView *view = SailfishApp::createView();
qDebug() << "harbour-calibreweb v0.2.0 build" << __DATE__ << __TIME__;
qmlRegisterType<EpubBook>("harbour.calibreweb", 1, 0, "EpubBook");
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();
}
-123
View File
@@ -1,123 +0,0 @@
#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;
}
-22
View File
@@ -1,22 +0,0 @@
#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
-260
View File
@@ -1,260 +0,0 @@
#include "settings.h"
#include <QDebug>
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QStandardPaths>
QString Settings::configPath()
{
// Path canonico per la sandbox SailJail di Sailfish 4.4+/5.x: con
// org/app impostati (harbour/calibreweb, vedi main.cpp e X-Sailjail nel
// desktop file), AppConfigLocation = ~/.config/harbour/calibreweb, l'unica
// zona config che la sandbox rende PERSISTENTE tra gli avvii. Senza
// profilo esplicito la sandbox nasconde ~/.config/harbour (config persa).
const QString dir = QStandardPaths::writableLocation(QStandardPaths::AppConfigLocation);
QDir().mkpath(dir);
return dir + QStringLiteral("/calibreweb.conf");
}
Settings::Settings(QObject *parent)
: QObject(parent)
, m_settings(configPath(), QSettings::IniFormat)
{
migrateIfNeeded();
openPermissions();
// diagnostica: path reale del file di configurazione e valori letti all'avvio
qDebug() << "Settings file:" << m_settings.fileName();
if (m_settings.status() != QSettings::NoError)
qDebug() << "Settings WARNING: file di configurazione non accessibile (permessi?)";
qDebug() << "Settings read: serverUrl=[" << serverUrl() << "] user=[" << username()
<< "] ssl=" << ignoreSslErrors() << "margin=" << readerMargin()
<< "marginV=" << readerMarginV();
}
void Settings::migrateIfNeeded()
{
if (!serverUrl().isEmpty())
return; // il file condiviso ha gia' una configurazione
// Sorgenti candidate per la migrazione: il vecchio file per-utente
// (~/.config/harbour/calibreweb.conf) e i path usati dalle build precedenti.
// La prima con valori vince.
const QStringList candidates = QStringList()
<< QDir::homePath() + QStringLiteral("/.config/harbour/calibreweb.conf")
<< QStringLiteral("/home/rootitanium/.config/harbour/calibreweb.conf")
<< QStringLiteral("/home/nemo/.config/harbour/calibreweb.conf")
<< QStringLiteral("/home/defaultuser/.config/harbour/calibreweb.conf");
for (const QString &cand : candidates) {
QSettings src(cand, QSettings::IniFormat);
const QString url = src.value(QStringLiteral("serverUrl")).toString();
if (url.isEmpty())
continue;
m_settings.setValue(QStringLiteral("serverUrl"), url);
m_settings.setValue(QStringLiteral("username"), src.value(QStringLiteral("username")));
m_settings.setValue(QStringLiteral("password"), src.value(QStringLiteral("password")));
m_settings.setValue(QStringLiteral("ignoreSslErrors"),
src.value(QStringLiteral("ignoreSslErrors")));
m_settings.setValue(QStringLiteral("readerMargin"),
src.value(QStringLiteral("readerMargin"), 24));
m_settings.setValue(QStringLiteral("readerMarginV"),
src.value(QStringLiteral("readerMarginV"), 0));
// segnalibri di lettura per libro+formato
src.beginGroup(QStringLiteral("bookmarks"));
const QStringList groups = src.childGroups();
for (const QString &g : groups) {
src.beginGroup(g);
const QStringList keys = src.childKeys();
for (const QString &k : keys)
m_settings.setValue(QStringLiteral("bookmarks/%1/%2").arg(g).arg(k),
src.value(k));
src.endGroup();
}
src.endGroup();
m_settings.sync();
openPermissions();
qDebug() << "Settings: importati i valori da" << cand;
return;
}
}
void Settings::openPermissions()
{
// Apre la catena di directory e il file di configurazione della home scelta
// (utente grafico, home spesso 700): il debug SSH (defaultuser) deve poter
// leggere/scrivere lo stesso file. Funziona quando l'app gira come owner
// della home (dal launcher); da SSH i permessi restano quelli gia' aperti.
const QString file = m_settings.fileName(); // .../calibreweb.conf
const QString dir = QFileInfo(file).absolutePath(); // .../.config/harbour
const QString configDir = QFileInfo(dir).absolutePath(); // .../.config
const QString home = QFileInfo(configDir).absolutePath(); // /home/<utente>
QFile::setPermissions(home,
QFile::ReadOwner | QFile::WriteOwner | QFile::ExeOwner
| QFile::ReadGroup | QFile::ExeGroup
| QFile::ReadOther | QFile::ExeOther);
QFile::setPermissions(configDir,
QFile::ReadOwner | QFile::WriteOwner | QFile::ExeOwner
| QFile::ReadGroup | QFile::ExeGroup
| QFile::ReadOther | QFile::ExeOther);
QFile::setPermissions(dir,
QFile::ReadOwner | QFile::WriteOwner | QFile::ExeOwner
| QFile::ReadGroup | QFile::WriteGroup | QFile::ExeGroup
| QFile::ReadOther | QFile::WriteOther | QFile::ExeOther);
QFile::setPermissions(file,
QFile::ReadOwner | QFile::WriteOwner
| QFile::ReadGroup | QFile::WriteGroup
| QFile::ReadOther | QFile::WriteOther);
}
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);
m_settings.sync(); // scrivi subito su disco: l'app può crashare alla chiusura (WebView)
openPermissions();
emit serverUrlChanged();
emit baseUrlChanged();
}
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);
m_settings.sync();
openPermissions();
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);
m_settings.sync();
openPermissions();
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);
m_settings.sync();
openPermissions();
emit ignoreSslErrorsChanged();
}
int Settings::readerMargin() const
{
return m_settings.value(QStringLiteral("readerMargin"), 24).toInt();
}
void Settings::setReaderMargin(int value)
{
if (value == readerMargin())
return;
m_settings.setValue(QStringLiteral("readerMargin"), value);
m_settings.sync();
openPermissions();
emit readerMarginChanged();
}
int Settings::readerMarginV() const
{
return m_settings.value(QStringLiteral("readerMarginV"), 0).toInt();
}
void Settings::setReaderMarginV(int value)
{
if (value == readerMarginV())
return;
m_settings.setValue(QStringLiteral("readerMarginV"), value);
m_settings.sync();
openPermissions();
emit readerMarginVChanged();
}
QString Settings::language() const
{
return m_settings.value(QStringLiteral("language"), QStringLiteral("system")).toString();
}
void Settings::setLanguage(const QString &value)
{
if (value == language())
return;
m_settings.setValue(QStringLiteral("language"), value);
m_settings.sync();
openPermissions();
emit languageChanged();
}
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;
}
QString Settings::bookmarkKey(int bookId, const QString &format) const
{
return m_settings.value(QStringLiteral("bookmarks/%1/%2").arg(bookId).arg(format))
.toString();
}
void Settings::setBookmarkKey(int bookId, const QString &format, const QString &key)
{
m_settings.setValue(QStringLiteral("bookmarks/%1/%2").arg(bookId).arg(format), key);
m_settings.sync();
openPermissions();
}
QString Settings::bookFilePath(int bookId, const QString &format) const
{
const QString dir = QStandardPaths::writableLocation(QStandardPaths::CacheLocation)
+ QStringLiteral("/books");
QDir().mkpath(dir);
return dir + QStringLiteral("/%1.%2").arg(bookId).arg(format);
}
-92
View File
@@ -1,92 +0,0 @@
#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(int readerMargin READ readerMargin WRITE setReaderMargin NOTIFY readerMarginChanged)
Q_PROPERTY(int readerMarginV READ readerMarginV WRITE setReaderMarginV NOTIFY readerMarginVChanged)
// lingua scelta dall'utente: "system" (predefinita, lingua UI Sailfish), "en", "it"
Q_PROPERTY(QString language READ language WRITE setLanguage NOTIFY languageChanged)
Q_PROPERTY(QString downloadDir READ downloadDir CONSTANT)
// derivata da serverUrl (senza slash finale): accesso QML come appSettings.baseUrl
Q_PROPERTY(QString baseUrl READ baseUrl NOTIFY baseUrlChanged)
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);
// margine laterale del testo nel lettore, in px
int readerMargin() const;
void setReaderMargin(int value);
// margine verticale (alto e basso, simmetrico) del testo nel lettore, in px
int readerMarginV() const;
void setReaderMarginV(int value);
// lingua dell'interfaccia: "system" (default) | "en" | "it"
QString language() const;
void setLanguage(const QString &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;
// --- segnalibri (posizione di lettura, per libro+formato) ---
Q_INVOKABLE QString bookmarkKey(int bookId, const QString &format) const;
Q_INVOKABLE void setBookmarkKey(int bookId, const QString &format, const QString &key);
// path del file libro scaricato in cache (per il lettore)
Q_INVOKABLE QString bookFilePath(int bookId, const QString &format) const;
signals:
void serverUrlChanged();
void usernameChanged();
void passwordChanged();
void ignoreSslErrorsChanged();
void readerMarginChanged();
void readerMarginVChanged();
void languageChanged();
void baseUrlChanged();
private:
// Path fisso condiviso: l'app deve leggere/scrivere SEMPRE lo stesso file,
// sia avviata dal launcher (utente nemo) sia da SSH di debug (defaultuser).
// Con QSettings("harbour","calibreweb") il file dipende dalla HOME
// dell'utente che avvia -> configurazione invisibile dall'altro utente.
static QString configPath();
// Importa i valori dalla vecchia config per-utente (~/.config/harbour/...) se
// il file condiviso e' vuoto: copre la configurazione fatta prima del fix.
void migrateIfNeeded();
// Apre i permessi della catena /home/nemo/.config/harbour e del file:
// il launcher gira come nemo (home 700), il debug SSH come defaultuser —
// entrambi devono poter leggere/scrivere lo stesso file di configurazione.
void openPermissions();
QSettings m_settings;
};
#endif // SETTINGS_H
-293
View File
@@ -1,293 +0,0 @@
<?xml version='1.0' encoding='utf-8'?>
<TS version="2.1" language="it_IT">
<context>
<name>BookDetailPage</name>
<message>
<location filename="../qml/pages/BookDetailPage.qml" line="36" />
<source>Read (EPUB)</source>
<translation>Leggi (EPUB)</translation>
</message>
<message>
<location filename="../qml/pages/BookDetailPage.qml" line="60" />
<source>Formats</source>
<translation>Formati</translation>
</message>
<message>
<location filename="../qml/pages/BookDetailPage.qml" line="112" />
<source>Downloading</source>
<translation>Download in corso</translation>
</message>
<message>
<location filename="../qml/pages/BookDetailPage.qml" line="261" />
<source>Saved to </source>
<translation>Salvato in </translation>
</message>
<message>
<location filename="../qml/pages/BookDetailPage.qml" line="266" />
<source>Error: </source>
<translation>Errore: </translation>
</message>
</context>
<context>
<name>ChapterListDialog</name>
<message>
<location filename="../qml/pages/ChapterListDialog.qml" line="12" />
<source>Table of contents</source>
<translation>Indice capitoli</translation>
</message>
<message>
<location filename="../qml/pages/ChapterListDialog.qml" line="38" />
<source>No chapters</source>
<translation>Nessun capitolo</translation>
</message>
</context>
<context>
<name>CoverPage</name>
<message>
<location filename="../qml/cover/CoverPage.qml" line="18" />
<source>Calibre Web</source>
<translation>Calibre Web</translation>
</message>
</context>
<context>
<name>FeedPage</name>
<message>
<location filename="../qml/pages/FeedPage.qml" line="91" />
<source>Refresh</source>
<translation>Aggiorna</translation>
</message>
<message>
<location filename="../qml/pages/FeedPage.qml" line="95" />
<source>Settings</source>
<translation>Impostazioni</translation>
</message>
<message>
<location filename="../qml/pages/FeedPage.qml" line="102" />
<location filename="../qml/pages/FeedPage.qml" line="171" />
<source>No results</source>
<translation>Nessun risultato</translation>
</message>
</context>
<context>
<name>MainPage</name>
<message>
<location filename="../qml/pages/MainPage.qml" line="13" />
<source>Calibre Web</source>
<translation>Calibre Web</translation>
</message>
<message>
<location filename="../qml/pages/MainPage.qml" line="43" />
<source>Search</source>
<translation>Cerca</translation>
</message>
<message>
<location filename="../qml/pages/MainPage.qml" line="47" />
<source>Settings</source>
<translation>Impostazioni</translation>
</message>
<message>
<location filename="../qml/pages/MainPage.qml" line="51" />
<source>Refresh</source>
<translation>Aggiorna</translation>
</message>
<message>
<location filename="../qml/pages/MainPage.qml" line="60" />
<source>Open the settings and enter your Calibre Web address</source>
<translation>Apri le impostazioni e inserisci l'indirizzo del tuo Calibre Web</translation>
</message>
<message>
<location filename="../qml/pages/MainPage.qml" line="77" />
<source>Server not configured</source>
<translation>Server non configurato</translation>
</message>
<message>
<location filename="../qml/pages/MainPage.qml" line="100" />
<source>No sections found in the OPDS feed</source>
<translation>Nessuna sezione trovata nel feed OPDS</translation>
</message>
</context>
<context>
<name>MarginDialog</name>
<message>
<location filename="../qml/pages/MarginDialog.qml" line="25" />
<source>Text margins</source>
<translation>Margini del testo</translation>
</message>
<message>
<location filename="../qml/pages/MarginDialog.qml" line="26" />
<source>Apply</source>
<translation>Applica</translation>
</message>
<message>
<location filename="../qml/pages/MarginDialog.qml" line="32" />
<source>Side margins</source>
<translation>Margini laterali</translation>
</message>
<message>
<location filename="../qml/pages/MarginDialog.qml" line="47" />
<source>Top and bottom</source>
<translation>Alto e basso</translation>
</message>
<message>
<location filename="../qml/pages/MarginDialog.qml" line="62" />
<source>Changes apply immediately. Confirm to save them for all books.</source>
<translation>Le modifiche si applicano subito al testo. Conferma per salvarle per tutti i libri.</translation>
</message>
</context>
<context>
<name>ReaderPage</name>
<message>
<location filename="../qml/pages/ReaderPage.qml" line="35" />
<source>Table of contents</source>
<translation>Indice capitoli</translation>
</message>
<message>
<location filename="../qml/pages/ReaderPage.qml" line="39" />
<source>Text margins</source>
<translation>Margini del testo</translation>
</message>
<message>
<location filename="../qml/pages/ReaderPage.qml" line="53" />
<source>Settings</source>
<translation>Impostazioni</translation>
</message>
<message>
<location filename="../qml/pages/ReaderPage.qml" line="57" />
<source>Close book</source>
<translation>Chiudi libro</translation>
</message>
<message>
<location filename="../qml/pages/ReaderPage.qml" line="170" />
<source>The reader is not responding (WebView). The EPUB file may be damaged.</source>
<translation>Il lettore non risponde (WebView). Il file EPUB potrebbe essere danneggiato.</translation>
</message>
<message>
<location filename="../qml/pages/ReaderPage.qml" line="293" />
<source>Chapter unreadable</source>
<translation>Capitolo non leggibile</translation>
</message>
<message>
<location filename="../qml/pages/ReaderPage.qml" line="342" />
<source>Unable to open the EPUB file</source>
<translation>Impossibile aprire il libro EPUB</translation>
</message>
</context>
<context>
<name>SearchPage</name>
<message>
<location filename="../qml/pages/SearchPage.qml" line="15" />
<location filename="../qml/pages/SearchPage.qml" line="27" />
<source>Search</source>
<translation>Cerca</translation>
</message>
<message>
<location filename="../qml/pages/SearchPage.qml" line="20" />
<source>Title or author…</source>
<translation>Titolo o autore…</translation>
</message>
<message>
<location filename="../qml/pages/SearchPage.qml" line="35" />
<source>Search uses the server engine (OPDS search).</source>
<translation>La ricerca usa il motore del server (OPDS search).</translation>
</message>
<message>
<location filename="../qml/pages/SearchPage.qml" line="50" />
<source>Results: </source>
<translation>Risultati: </translation>
</message>
</context>
<context>
<name>SettingsPage</name>
<message>
<location filename="../qml/pages/SettingsPage.qml" line="20" />
<source>Settings</source>
<translation>Impostazioni</translation>
</message>
<message>
<location filename="../qml/pages/SettingsPage.qml" line="22" />
<source>Server</source>
<translation>Server</translation>
</message>
<message>
<location filename="../qml/pages/SettingsPage.qml" line="28" />
<source>Calibre Web address</source>
<translation>Indirizzo Calibre Web</translation>
</message>
<message>
<location filename="../qml/pages/SettingsPage.qml" line="39" />
<source>User (empty = anonymous access)</source>
<translation>Utente (vuoto = accesso anonimo)</translation>
</message>
<message>
<location filename="../qml/pages/SettingsPage.qml" line="48" />
<source>Password</source>
<translation>Password</translation>
</message>
<message>
<location filename="../qml/pages/SettingsPage.qml" line="55" />
<source>Ignore SSL certificate errors</source>
<translation>Ignora errori certificato SSL</translation>
</message>
<message>
<location filename="../qml/pages/SettingsPage.qml" line="56" />
<source>Enable if the server uses a self-signed certificate</source>
<translation>Da attivare se il server usa un certificato autofirmato</translation>
</message>
<message>
<location filename="../qml/pages/SettingsPage.qml" line="61" />
<source>Reader</source>
<translation>Lettore</translation>
</message>
<message>
<location filename="../qml/pages/SettingsPage.qml" line="66" />
<source>Side margins</source>
<translation>Margini laterali</translation>
</message>
<message>
<location filename="../qml/pages/SettingsPage.qml" line="78" />
<source>Top and bottom margin</source>
<translation>Margine alto e basso</translation>
</message>
<message>
<location filename="../qml/pages/SettingsPage.qml" line="87" />
<source>Connection</source>
<translation>Connessione</translation>
</message>
<message>
<location filename="../qml/pages/SettingsPage.qml" line="91" />
<source>Test connection</source>
<translation>Prova connessione</translation>
</message>
<message>
<location filename="../qml/pages/SettingsPage.qml" line="115" />
<location filename="../qml/pages/SettingsPage.qml" line="120" />
<source>Language</source>
<translation type="finished">Lingua</translation>
</message>
<message>
<location filename="../qml/pages/SettingsPage.qml" line="123" />
<source>System (default)</source>
<translation type="finished">Sistema (predefinita)</translation>
</message>
<message>
<location filename="../qml/pages/SettingsPage.qml" line="131" />
<source>Language will be applied after restarting the app</source>
<translation type="finished">La lingua verrà applicata al riavvio dell'app</translation>
</message>
<message>
<location filename="../qml/pages/SettingsPage.qml" line="159" />
<source>Enter the server address</source>
<translation>Inserisci l'indirizzo del server</translation>
</message>
<message>
<location filename="../qml/pages/SettingsPage.qml" line="168" />
<source>Testing</source>
<translation>Verifica in corso</translation>
</message>
<message>
<location filename="../qml/pages/SettingsPage.qml" line="179" />
<source>Connection OK: %1 sections found</source>
<translation>Connessione OK: %1 sezioni trovate</translation>
</message>
</context>
</TS>