Compare commits

..
1 Commits
Author SHA1 Message Date
kaneda cb907bf4dc Initial commit 2026-08-20 22:02:01 +02:00
37 changed files with 0 additions and 4622 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 # 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
-55
View File
@@ -1,55 +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 \
src/readerstate.cpp \
src/pagedtextitem.cpp
HEADERS += \
src/settings.h \
src/apiclient.h \
src/downloader.h \
src/opdsparser.h \
src/epub.h \
src/readerstate.h \
src/pagedtextitem.h
LIBS += -lz
OTHER_FILES += \
qml/harbour-calibreweb.qml \
qml/pages/*.qml \
qml/cover/*.qml \
rpm/harbour-calibreweb.spec \
rpm/harbour-calibreweb.desktop \
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 the app data dir
# (/usr/share/harbour-calibreweb/translations — the RPM validator rejects
# /usr/share/translations); main.cpp loads it explicitly (see language block).
# 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()
-52
View File
@@ -1,52 +0,0 @@
import QtQuick 2.0
import Sailfish.Silica 1.0
// Cover: se un libro è aperto (readerState attivo dal ReaderPage) mostra
// titolo e posizione di lettura, altrimenti icona e nome dell'app.
CoverBackground {
Column {
anchors.centerIn: parent
width: parent.width - 2 * Theme.paddingLarge
spacing: Theme.paddingMedium
visible: !readerState.active
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
}
}
Column {
anchors.centerIn: parent
width: parent.width - 2 * Theme.paddingLarge
spacing: Theme.paddingSmall
visible: readerState.active
Label {
width: parent.width
text: readerState.title
color: Theme.primaryColor
font.pixelSize: Theme.fontSizeSmall
horizontalAlignment: Text.AlignHCenter
truncationMode: TruncationMode.Elide
}
Label {
width: parent.width
text: readerState.subtitle
color: Theme.secondaryColor
font.pixelSize: Theme.fontSizeExtraSmall
horizontalAlignment: Text.AlignHCenter
truncationMode: TruncationMode.Elide
}
}
}
-15
View File
@@ -1,15 +0,0 @@
import QtQuick 2.0
import Sailfish.Silica 1.0
import "pages"
import "cover"
ApplicationWindow {
id: app
initialPage: Component { MainPage {} }
cover: Component { CoverPage {} }
// Niente vincoli a livello window: ogni pagina dichiara le proprie
// orientazioni (il lettore ruota in landscape, le pagine di lista
// restano in portrait). _defaultPageOrientations è API privata.
}
-272
View File
@@ -1,272 +0,0 @@
import QtQuick 2.0
import Sailfish.Silica 1.0
Page {
id: page
allowedOrientations: Orientation.Portrait
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")
}
}
}
-189
View File
@@ -1,189 +0,0 @@
import QtQuick 2.0
import Sailfish.Silica 1.0
Page {
id: page
allowedOrientations: Orientation.Portrait
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
}
}
}
}
}
-109
View File
@@ -1,109 +0,0 @@
import QtQuick 2.0
import Sailfish.Silica 1.0
Page {
id: page
allowedOrientations: Orientation.Portrait
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: 24
// 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
}
}
-398
View File
@@ -1,398 +0,0 @@
import QtQuick 2.0
import Sailfish.Silica 1.0
import harbour.calibreweb 1.0
// Lettore a pagine REALI: il capitolo (rich text Qt) viene impaginato da
// PagedTextItem (QTextDocument + QPainter, pattern QQuickPaintedItem come
// harbour-books) in pagine misurate dal layout: niente scroll, niente stime,
// niente testo che sborda e l'ultima pagina e' allineata da sola.
// La finestra di lettura e' la GEOMETRIA dell'item: i margini (laterali,
// alto/basso) e l'inset del notch valgono su OGNI pagina.
// La posizione di lettura e' un indice di carattere: sopravvive al reflow del
// font e alla rotazione senza timer ne' stime.
Page {
id: page
property string epubPath: ""
property int bookId: 0
property string bookFormat: "epub"
property int currentChapter: 0
// posizione di lettura nel capitolo corrente in caratteri di testo
// (metrica del capitolo, coerente coi segnalibri salvati)
property int charOffset: 0
// caratteri da ripristinare nel capitolo appena aperto (-1 = nessuno)
property int pendingChars: -1
// ultimo offset assoluto salvato (evita scritture continue)
property int lastSavedAbs: -1
// pagina corrente / pagine totali del capitolo (per la label del menu)
property int pageIndex: 1
property int pageCount: 1
property bool ready: false
property string errorMessage: ""
// reflow: scala il font (il testo si ri-impagina), persistita in appSettings
property double fontScale: appSettings.readerFontScale
// anteprima margini dal dialog (valori temporanei, -1 = usa le impostazioni)
property int previewMarginL: -1
property int previewMarginT: -1
allowedOrientations: Orientation.All
// Inset superiore per display con notch (Jolla Phone 2026): la finestra
// dell'app inizia sopra la fine del ritaglio, quindi in portrait la zona di
// lettura parte piu' in basso di un'altezza barra di stato, e il testo non
// finisce mai sotto il notch (in landscape il notch e' laterale: gestito
// dai margini orizzontali). Theme.statusBarHeight esiste nelle Silica
// recenti: se manca si usa un valore prudente.
function statusBarInset() {
var h = Theme.statusBarHeight
return (typeof h === "number" && h > 0) ? h : 48
}
property int notchInset: page.isPortrait ? page.statusBarInset() : 0
EpubBook {
id: book
}
SilicaFlickable {
id: outerFlick
anchors.fill: parent
// NON scrollabile: il contenuto non supera mai la viewport
contentHeight: outerFlick.height
// il PullDownMenu va DENTRO una vista Silica, non a livello di Page
PullDownMenu {
id: pullMenu
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; esci dal preview
page.previewMarginL = -1
page.previewMarginT = -1
})
}
}
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.pageIndex + "/" + page.pageCount + " · "
+ (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.1)
}
Button {
text: "A+"
width: Theme.itemSizeSmall
height: Theme.itemSizeMedium
onClicked: page.changeFontScale(0.1)
}
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
}
}
// Motore di paginazione: l'item impagina il rich text del capitolo e
// disegna la pagina corrente. I margini sono anchors: la finestra di
// lettura vive DENTRO i margini, quindi il testo non puo' uscirne.
PagedTextItem {
id: reader
anchors.fill: parent
anchors.leftMargin: page.previewMarginL >= 0
? page.previewMarginL : appSettings.readerMargin
anchors.rightMargin: page.previewMarginL >= 0
? page.previewMarginL : appSettings.readerMargin
anchors.topMargin: page.notchInset + (page.previewMarginT >= 0
? page.previewMarginT
: appSettings.readerMarginV)
anchors.bottomMargin: page.previewMarginT >= 0
? page.previewMarginT : appSettings.readerMarginV
fontFamily: Theme.fontFamily
fontPixelSize: page.fontPx()
textColor: Theme.primaryColor
// il reflow (font, geometria) mantiene la posizione internamente:
// qui si aggiornano solo label, cover e segnalibro
onPageChanged: page.syncState()
onLayoutChanged: page.syncState()
}
// Paginazione a tap: 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()
}
}
}
Label {
anchors.centerIn: parent
text: page.errorMessage
color: Theme.errorColor
visible: page.errorMessage.length > 0
}
// chiusura libro differita (pop dalla MainPage, non dall'handler del ReaderPage)
Timer {
id: closeBookTimer
interval: 50
onTriggered: pageStack.pop(null)
}
// posizione di lettura nella metrica del capitolo (0..1), dal motore
function readerFrac() {
var n = reader.characterCount
return n > 0 ? reader.position / n : 0.0
}
// converte un offset di caratteri (metrica capitolo) nella posizione del
// motore: le due metriche differiscono di poco, la frazione le allinea
function posForOffset(off) {
var len = book.chapterTextLength(page.currentChapter)
var n = reader.characterCount
if (len <= 0 || n <= 0)
return 0
if (off >= len)
return Math.max(0, n - 1)
return Math.round((off / len) * n)
}
// offset di caratteri (metrica capitolo) della pagina corrente: e' quello
// che finisce nel segnalibro, coerente con le chiavi salvate (v2)
function offsetForPos() {
return Math.round(readerFrac() * book.chapterTextLength(page.currentChapter))
}
function overallProgress() {
if (book.chapterCount <= 0)
return 0
var total = book.chapterStartChar(book.chapterCount)
if (total <= 0)
return 0
var start = book.chapterStartChar(page.currentChapter)
return (start + page.charOffset) / total
}
// aggiorna label, cover e segnalibro dopo un cambio pagina o un reflow
function syncState() {
if (!page.ready)
return
page.pageIndex = reader.page + 1
page.pageCount = reader.pageCount
page.charOffset = offsetForPos()
updateCoverState()
maybeSave(false)
}
function maybeSave(force) {
if (bookId <= 0)
return
// posizione assoluta nel libro = capitolo + caratteri del capitolo
var abs = book.chapterStartChar(page.currentChapter) + page.charOffset
// salva subito su forzatura, altrimenti solo se la posizione è cambiata
// di almeno 20 caratteri (evita scritture continue)
if (force || Math.abs(abs - page.lastSavedAbs) > 20) {
page.lastSavedAbs = abs
appSettings.setBookmarkKey(page.bookId, page.bookFormat,
book.makeBookmarkKey(page.currentChapter, page.charOffset))
}
}
function gotoChapter(index) {
if (index < 0 || index >= book.chapterCount)
return
if (index !== page.currentChapter) {
maybeSave(true)
page.lastSavedAbs = -1
}
page.currentChapter = index
page.pendingChars = -1
// carica il capitolo e vai all'inizio (posizione esplicita)
reader.html = book.chapterRichText(index)
reader.goToPosition(0)
syncState()
}
function changeFontScale(delta) {
// limite ampio e passo 0.1: tanti ingrandimenti prima del limite
appSettings.readerFontScale = Math.max(0.7, Math.min(3.5, page.fontScale + delta))
// il binding aggiorna fontPixelSize: il motore si ri-impagina e
// mantiene da solo la posizione per carattere
}
// pagina successiva: a fine capitolo passa al successivo
function nextPage() {
if (!page.ready)
return
if (reader.page >= reader.pageCount - 1) {
// fine capitolo: segna la coda e passa al capitolo dopo
page.charOffset = Math.max(0, book.chapterTextLength(page.currentChapter) - 1)
maybeSave(true)
gotoChapter(page.currentChapter + 1)
} else {
reader.nextPage()
}
}
// pagina precedente: dall'inizio del capitolo va al capitolo prima
// SULL'ULTIMA PAGINA (non salta la coda del capitolo)
function prevPage() {
if (!page.ready)
return
if (reader.page > 0) {
reader.previousPage()
} else if (page.currentChapter > 0) {
maybeSave(true)
var prev = page.currentChapter - 1
page.currentChapter = prev
reader.html = book.chapterRichText(prev)
// posizione esplicita: fondo del capitolo precedente
reader.goToPosition(Math.max(0, reader.characterCount - 1))
syncState()
}
}
function fontPx() {
return Math.round(16 * page.fontScale * 100) / 100
}
// aggiorna lo stato per la cover (titolo + capitolo + percentuale)
function updateCoverState() {
if (book.chapterCount <= 0)
return
var pct = Math.round(page.overallProgress() * 100)
readerState.setReading(book.title(),
(page.currentChapter + 1) + "/" + book.chapterCount
+ " · " + book.chapterTitle(page.currentChapter)
+ " · " + pct + "%")
}
function showChapterList() {
var dialog = pageStack.push(Qt.resolvedUrl("ChapterListDialog.qml"),
{ model: book.chapterList() })
dialog.accepted.connect(function() {
gotoChapter(dialog.selectedIndex)
})
}
// anteprima live dal dialogo margini: la geometria dell'item cambia e il
// motore si ri-impagina mantenendo la posizione di lettura
function previewMargins(lr, tb) {
page.previewMarginL = lr
page.previewMarginT = tb
}
// apre un capitolo e va alla posizione indicata (metrica capitolo)
function loadChapter(index, offset) {
page.currentChapter = index
reader.html = book.chapterRichText(index)
var target = offset >= 0
? Math.round((offset / Math.max(1, book.chapterTextLength(index)))
* reader.characterCount)
: 0
reader.goToPosition(target)
syncState()
}
Component.onCompleted: {
page.ready = false
if (!book.open(page.epubPath)) {
page.errorMessage = qsTr("Unable to open the EPUB file")
page.ready = true
return
}
// ripristina il segno locale: posizione per CARATTERE (v2),
// le vecchie chiavi v1 vengono migrate da parseBookmarkKey
var key = appSettings.bookmarkKey(page.bookId, page.bookFormat)
var ch = 0
var off = -1
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))
off = r.hasOwnProperty("charOffset") ? r.charOffset : -1
}
}
page.ready = true
loadChapter(ch, off)
}
Component.onDestruction: readerState.clearReading()
onStatusChanged: {
if (status === PageStatus.Active) {
if (page.ready && book.chapterCount > 0)
updateCoverState()
} else if (status === PageStatus.Inactive) {
maybeSave(true)
}
}
}
-54
View File
@@ -1,54 +0,0 @@
import QtQuick 2.0
import Sailfish.Silica 1.0
Page {
id: page
allowedOrientations: Orientation.Portrait
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 })
}
}
-180
View File
@@ -1,180 +0,0 @@
import QtQuick 2.0
import Sailfish.Silica 1.0
Page {
id: page
allowedOrientations: Orientation.Portrait
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
}
PasswordField {
id: passField
anchors.left: parent.left
anchors.right: parent.right
label: qsTr("Password")
text: appSettings.password
// Icona nativa per mostrare/nascondere il testo digitato
showEchoModeToggle: true
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("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 }
Label {
anchors.horizontalCenter: parent.horizontalCenter
text: qsTr("Version") + " " + appVersion
color: Theme.secondaryColor
font.pixelSize: Theme.fontSizeSmall
}
}
}
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
appSettings.flush() // i campi di testo scrivono senza sync a ogni tasto
page.testing = true
page.testResult = qsTr("Testing…")
apiClient.getFeed(base + "/opds")
}
// uscita dalla pagina: scrivi su disco le modifiche in sospeso
onStatusChanged: {
if (status === PageStatus.Inactive)
appSettings.flush()
}
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
-283
View File
@@ -1,283 +0,0 @@
Name: harbour-calibreweb
Summary: Client OPDS per Calibre Web
Version: 0.5.1
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
* Mon Oct 05 2026 Carlo Baratto <carlo@carlobaratto.it> - 0.5.1-1
- Impostazioni: il campo Password e' un PasswordField con
showEchoModeToggle, quindi l'icona nel campo mostra/nasconde il
testo digitato (nessuna nuova stringa da tradurre)
* Mon Sep 07 2026 Carlo Baratto <carlo@carlobaratto.it> - 0.5.0-1
- Nuovo motore di lettura: PagedTextItem (QQuickPaintedItem + QTextDocument,
pattern di harbour-books) impagina il capitolo in pagine misurate dal layout.
Niente piu' Flickable a step ne' "deficit": pagine esatte, ultima pagina
allineata, margini e inset notch validi su ogni pagina, posizione di lettura
per carattere che sopravvive a reflow del font e rotazione (senza timer).
Segnalibri invariati (stessa metrica, chiavi v2 compatibili)
* Mon Sep 07 2026 Carlo Baratto <carlo@carlobaratto.it> - 0.4.13-1
- Lettore: il testo non finisce piu' sotto il notch del display (Jolla Phone
2026): in portrait la finestra di lettura parte piu' in basso di un'altezza
barra di stato (Theme.statusBarHeight) oltre al margine scelto
* Mon Sep 07 2026 Carlo Baratto <carlo@carlobaratto.it> - 0.4.12-1
- Default margine verticale lettore 24 (era 0): il testo arrivava a filo dei
bordi alto/basso su config senza valore o migrate dalle build vecchie
(la migrazione non scrive piu' chiavi margine spurie a 0)
- MarginDialog coerente: slider "Top and bottom" parte da 24
* Fri Aug 21 2026 Carlo Baratto <carlo@carlobaratto.it> - 0.4.11-1
- Rimossi gli slider margini dalle Impostazioni: i margini si regolano solo dal
lettore (Margini del testo nel PullDownMenu)
- Margini alto/basso STRUTTURALI: la finestra di lettura vive dentro i margini
(top/bottom margin del Flickable): il testo non puo' uscire dai bordi e i
margini sono identici su ogni pagina (via le fasce colorate della 0.4.9/0.4.10)
* Fri Aug 21 2026 Carlo Baratto <carlo@carlobaratto.it> - 0.4.10-1
- Fix margini lettore (0.4.9): sfondo di lettura UNIFORME nel lettore (colore
pieno per tema, niente gradiente di sistema). Le fasce dei margini usano lo
stesso identico colore dello sfondo: margine visibile su ogni pagina, senza
bande stonate e senza effetto "fascia che copre il testo"
* Fri Aug 21 2026 Carlo Baratto <carlo@carlobaratto.it> - 0.4.9-1
- Margini verticali su OGNI schermata del lettore (non solo a inizio capitolo):
due fasce opache (colore di sfondo) sopra e sotto la viewport coprono il testo
a cavallo dei bordi; il padding inferiore del contenitore include il deficit
dell'ultima pagina, cosi' max-scroll e' un multiplo esatto del passo e
l'ultima pagina non ripete righe della penultima. Anteprima live dal dialog
margini inclusa
* Fri Aug 21 2026 Carlo Baratto <carlo@carlobaratto.it> - 0.4.8-1
- Best practice audit: orientazioni per pagina (finestra senza vincoli, il
lettore ora ruota davvero in landscape; pagine di lista in portrait;
rimossa la proprieta' privata _defaultPageOrientations)
- Licenza MIT installata nel pacchetto (/usr/share/licenses/harbour-calibreweb)
- EPUB: lunghezze testo LAZY per capitolo (l'apertura non parse piu' tutto il
libro: niente freeze sui libri grossi; cache al primo accesso)
- Cover dinamica: con un libro aperto mostra titolo e posizione di lettura
(ReaderState esposto come context property, aggiornato dal ReaderPage)
- Impostazioni: niente sync su disco a ogni tasto nei campi di testo
(flush() all'uscita della pagina e prima del test connessione)
- Pulizia: rimossa proprieta' morta chapterFraction, commenti obsoleti
aggiornati (settings.h, .pro)
* Fri Aug 21 2026 Carlo Baratto <carlo@carlobaratto.it> - 0.4.7-1
- Fix margini lettore: il passo di pagina era la viewport intera, quindi il
testo scorreva a filo dei bordi e i margini verticali si vedevano solo
all'inizio del capitolo. Ora il passo e' viewport - 2*margine verticale:
ogni pagina mostra il testo DENTRO i margini (prima riga e ultima riga
distanziate dai bordi), anche l'ultima pagina del capitolo
* Fri Aug 21 2026 Carlo Baratto <carlo@carlobaratto.it> - 0.4.6-1
- Fix impostazioni lettore non salvate: la scala del font era una proprieta'
QML di pagina (mai persistita) -> l'ingrandimento spariva alla chiusura del
libro. Aggiunta readerFontScale in QSettings (default 1.0): il lettore
parte dalla scala salvata e la persiste a ogni cambio A-/A+. Margini gia'
persistiti (verificati con test su disco)
* Fri Aug 21 2026 Carlo Baratto <carlo@carlobaratto.it> - 0.4.5-1
- Fix ingrandimento testo: il restore dopo il reflow usava la frazione di
scroll (instabile col cambio font) e il timer di 80ms poteva scattare prima
del ricalcolo rich text -> posizione persa e ingrandimenti che sembravano
bloccarsi. Ora il restore usa l'offset in CARATTERI (stabile col reflow),
il reflow applica via onContentHeightChanged, il timer di sicurezza e'
passato a 500ms; limite font portato a 3.5x e passo a 0.1 (molti piu'
ingrandimenti prima del limite)
* Fri Aug 21 2026 Carlo Baratto <carlo@carlobaratto.it> - 0.4.4-1
- LETTORE A PAGINE RIGIDE: eliminato del tutto lo scroll libero. Struttura a
due livelli: SilicaFlickable esterno NON scrollabile (contentHeight=height,
solo per i pulley menu) + Flickable interno interactive:false (il dito non
lo muove MAI, contentY cambia solo col tap). Rimossi snap/preventStealing/
onClosed/active: niente piu' compromessi tra scroll e menu, i pulley
funzionano sempre e il testo non si sposta mai col dito
* Fri Aug 21 2026 Carlo Baratto <carlo@carlobaratto.it> - 0.4.3-1
- FIX "Impossibile caricare la pagina": rimossi onClosed sui pulley menu e la
guardia menu.active (API non disponibili su questa versione di Silica:
errore di compilazione QML -> pagina non caricata). Lo snap ora usa solo
API sicure: readerFlick.dragging + menu.open (proprieta' standard); il
timer si riavvia durante le animazioni dei menu, quindi lo snap scatta
solo a tutto fermo e i menu restano aperti
* Fri Aug 21 2026 Carlo Baratto <carlo@carlobaratto.it> - 0.4.2-1
- Fix PushUpMenu che si richiudeva da solo: lo snap usava la guardia
menu.open (vera solo a menu aperto), ma durante l'apertura il pulley sposta
il contenuto e lo snap scattava nel mezzo -> il menu si annullava. Ora la
guardia e' menu.active (vera anche durante il trascinamento); dopo la
chiusura del menu (onClosed) lo snap riallinea il contenuto se serve
* Fri Aug 21 2026 Carlo Baratto <carlo@carlobaratto.it> - 0.4.1-1
- Fix scroll libero e pulley menu: rimosso preventStealing dalla MouseArea
(ruba il gesto al PushUpMenu appena il dito entra nella zona centrale).
Lo scroll libero e' ora neutralizzato dallo SNAP alle pagine: dopo ogni
movimento il contenuto torna alla pagina piu' vicina (soglia 70%, zona
finale parziale -> fondo), i menu pulley funzionano normalmente
* Fri Aug 21 2026 Carlo Baratto <carlo@carlobaratto.it> - 0.4.0-1
- LETTORE RIFATTO senza WebView: i capitoli sono rich text Qt in un Text QML
dentro un SilicaFlickable. Lo scroll (contentY/contentHeight) e' nativo QML
e sempre affidabile: sul WebView di Sailfish lo scroll programmatico non
funziona (window.scrollY stale, contentHeight/scrollPosition non scrivibili)
e il cambio capitolo non scattava mai (regressioni 0.3.x)
- Paginazione a tap su contentY: avanti/indietro di una viewport, cambio
capitolo deterministico (fine = contentY >= contentHeight - height)
- Bookmark v2 invariato (posizione per carattere, stimata dalla frazione di
scroll: stabile col reflow del font); migrazione v1 invariata
- HTML->rich text in C++: tag di base (p, h1-h6, b/i/u/s, liste, tabelle,
blockquote), entita' HTML comuni decodificate, script/style/head/immagini
saltati, fallback per HTML malformato
- NOTA: il lettore non mostra piu' le immagini inline dei capitoli (limite
del rich text Qt); niente scroll libero (paginazione a tap, come richiesto)
* Fri Aug 21 2026 Carlo Baratto <carlo@carlobaratto.it> - 0.3.4-1
- Fix tap avanti: scrollToCharJs usava window.scrollTo(0, rect.top+scrollY-8)
e window.scrollY e' stale sul WebView Sailfish -> il tap oscillava di pochi
pixel invece di avanzare. Ora scrollBy relativo (0, rect.top-8): nessuna
lettura di scrollY, avanzamento di una pagina esatta
* Fri Aug 21 2026 Carlo Baratto <carlo@carlobaratto.it> - 0.3.3-1
- Paginazione per CONTENUTO: il tap avanza di una pagina di testo (offset in
caratteri calcolato sui text node del DOM), la fine del capitolo e' decisa
confrontando l'offset con la lunghezza del capitolo nota in C++ — nessuna
dipendenza dallo scroll del WebView (window.scrollY non riflette lo scroll
reale; contentHeight/scrollPosition nativi non scrivibili su Qt 5.6)
- Impostazioni: numero di versione in fondo alla pagina (label "Version")
- Fix definitivo del cambio capitolo avanti/indietro
* Fri Aug 21 2026 Carlo Baratto <carlo@carlobaratto.it> - 0.3.2-1
- Fix definitivo cambio capitolo: la navigazione non usa piu' window.scrollY/
scrollBy via runJavaScript (lo scroll reale del WebView Sailfish non e' sempre
riflesso in window.scrollY: la posizione non arrivava mai a fine documento e
il cambio capitolo non scattava). Ora paginazione con le proprieta' NATIVE
del WebView (contentHeight/scrollPosition): aritmetica QML pura, affidabile
* Fri Aug 21 2026 Carlo Baratto <carlo@carlobaratto.it> - 0.3.1-1
- Fix passaggio di capitolo: il tap avanti/indietro usava un confronto con max
calcolato (scrollHeight - innerHeight), che sul WebView di Sailfish può essere
sovrastimato: scrollY non raggiungeva mai max e il cambio capitolo non
scattava (si restava dentro il capitolo). Ora lo scroll avviene con scrollBy
e la condizione è "lo scroll si è MOSSO?" — infallibile anche con max errato;
se il documento è bloccato (overflow hidden), unlock CSS e riprova
* Fri Aug 21 2026 Carlo Baratto <carlo@carlobaratto.it> - 0.3.0-1
- Lettore: bookmark per POSIZIONE (numero di carattere, chiave cw:v2:<assoluto>)
invece di capitolo+frazione: la posizione è stabile cambiando font e margini;
le vecchie chiavi cw:v1 vengono migrate automaticamente al primo avvio
- Lettore: paginazione a tap corretta quando il CSS del libro blocca lo scroll
(height:100%/overflow:hidden): stile iniettato con height:auto+overflow:visible
e sblocco JS dei contenitori nascosti — prima ogni tap saltava al capitolo
successivo perdendo tutto il testo del capitolo
- Lettore: prevPage dall'inizio del capitolo torna all'ULTIMA pagina del capitolo
precedente (prima ripartiva dall'inizio saltando la coda del testo)
- Lettore: cambio font A-/A+ mantiene la posizione di lettura (ripristino per
carattere dopo il reflow)
- Lettore: label del menu con pagina corrente/totale del capitolo; avanzamento
calcolato su documentElement E body (libri che scrollano il body)
- Parser EPUB: lunghezze testo per capitolo (text node del DOM) per la mappatura
offset assoluto -> capitolo
- Test: harness C++ locale (Qt) + test JS con DOM mock per conteggio caratteri,
ripristino e paginazione
* 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/
# MIT license: installed in the package (Harbour best practice).
mkdir -p %{buildroot}%{_datadir}/licenses/%{name}
if [ -f %{_builddir}/../%{name}-%{version}/LICENSE ]; then
install -m 0644 %{_builddir}/../%{name}-%{version}/LICENSE %{buildroot}%{_datadir}/licenses/%{name}/
elif [ -f %{_builddir}/%{name}-%{version}/LICENSE ]; then
install -m 0644 %{_builddir}/%{name}-%{version}/LICENSE %{buildroot}%{_datadir}/licenses/%{name}/
elif [ -f %{_builddir}/LICENSE ]; then
install -m 0644 %{_builddir}/LICENSE %{buildroot}%{_datadir}/licenses/%{name}/
else
echo "ERROR: LICENSE not found" >&2
exit 1
fi
%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
%{_datadir}/licenses/%{name}/LICENSE
-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
-856
View File
@@ -1,856 +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
}
// ---------------------------------------------------------------------------
// Lunghezza del testo VISIBILE di un capitolo (criterio DOM/JS):
// tag rimossi, entità decodificate (1 carattere), head/script/style/noscript/
// title esclusi, spazi e newline contati come nel text node del browser.
// ---------------------------------------------------------------------------
int plainTextLength(const QString &html)
{
// conta solo il testo dentro <body> (come il walker JS che parte da
// document.body): l'header XML/DOCTYPE e il <head> non contano
const int bodyStart = html.indexOf(QStringLiteral("<body"), 0, Qt::CaseInsensitive);
const int bodyEnd = html.indexOf(QStringLiteral("</body"), 0, Qt::CaseInsensitive);
const QString scope = (bodyStart >= 0)
? html.mid(bodyStart, (bodyEnd >= bodyStart ? bodyEnd : html.size()) - bodyStart)
: html;
int count = 0;
int i = 0;
const int n = scope.size();
while (i < n) {
const QChar c = scope.at(i);
if (c == QLatin1Char('<')) {
const int close = scope.indexOf(QLatin1Char('>'), i + 1);
if (close < 0) {
++i;
continue;
}
QString tag = scope.mid(i + 1, close - i - 1).trimmed().toLower();
if (tag.startsWith(QLatin1String("!--"))) {
const int end = scope.indexOf(QLatin1String("-->"), close);
i = (end < 0) ? n : end + 3;
continue;
}
if (tag.startsWith(QLatin1Char('/')))
tag = tag.mid(1);
int sp = 0;
while (sp < tag.size() && tag.at(sp).isLetterOrNumber())
++sp;
const QString name = tag.left(sp);
if (name == QLatin1String("script") || name == QLatin1String("style")
|| name == QLatin1String("noscript") || name == QLatin1String("head")
|| name == QLatin1String("title")) {
// salta tutto il contenuto (e la chiusura) del blocco raw
const QString closeTag = QStringLiteral("</") + name;
const int end = scope.indexOf(closeTag, close, Qt::CaseInsensitive);
if (end < 0) {
i = n;
continue;
}
const int gt = scope.indexOf(QLatin1Char('>'), end);
i = (gt < 0) ? n : gt + 1;
continue;
}
i = close + 1;
continue;
}
if (c == QLatin1Char('&')) {
const int semi = scope.indexOf(QLatin1Char(';'), i + 1);
if (semi > i && semi - i <= 10) {
const QString ent = scope.mid(i + 1, semi - i - 1);
if (ent == QLatin1String("amp") || ent == QLatin1String("lt")
|| ent == QLatin1String("gt") || ent == QLatin1String("quot")
|| ent == QLatin1String("apos") || ent == QLatin1String("nbsp")
|| ent.startsWith(QLatin1String("#"))) {
++count;
i = semi + 1;
continue;
}
}
}
++count;
++i;
}
return count;
}
// ---------------------------------------------------------------------------
// HTML -> rich text Qt (per Text QML, niente WebView): tag di base convertiti,
// blocchi raw (script/style/head/...) saltati, immagini ignorate, i tag non
// supportati diventano trasparenti (resta il contenuto).
// ---------------------------------------------------------------------------
// Entità HTML nominate NON standard XML (QXmlStreamReader le rifiuta senza DTD):
// decodificate prima del parse. Quelle XML standard (amp lt gt quot apos) e le
// numeriche (&#NNN; &#xHH;) le gestisce il parser.
QString decodeHtmlEntities(const QString &s)
{
static const QHash<QString, QChar> entities = [] {
QHash<QString, QChar> h;
h.insert(QStringLiteral("nbsp"), QChar(0x00A0));
h.insert(QStringLiteral("mdash"), QChar(0x2014));
h.insert(QStringLiteral("ndash"), QChar(0x2013));
h.insert(QStringLiteral("rsquo"), QChar(0x2019));
h.insert(QStringLiteral("lsquo"), QChar(0x2018));
h.insert(QStringLiteral("rdquo"), QChar(0x201D));
h.insert(QStringLiteral("ldquo"), QChar(0x201C));
h.insert(QStringLiteral("sbquo"), QChar(0x201A));
h.insert(QStringLiteral("bdquo"), QChar(0x201E));
h.insert(QStringLiteral("hellip"), QChar(0x2026));
h.insert(QStringLiteral("dagger"), QChar(0x2020));
h.insert(QStringLiteral("Dagger"), QChar(0x2021));
h.insert(QStringLiteral("bull"), QChar(0x2022));
h.insert(QStringLiteral("middot"), QChar(0x00B7));
h.insert(QStringLiteral("eacute"), QChar(0x00E9));
h.insert(QStringLiteral("egrave"), QChar(0x00E8));
h.insert(QStringLiteral("agrave"), QChar(0x00E0));
h.insert(QStringLiteral("ugrave"), QChar(0x00F9));
h.insert(QStringLiteral("igrave"), QChar(0x00EC));
h.insert(QStringLiteral("ograve"), QChar(0x00F2));
h.insert(QStringLiteral("aacute"), QChar(0x00E1));
h.insert(QStringLiteral("uacute"), QChar(0x00FA));
h.insert(QStringLiteral("iacute"), QChar(0x00ED));
h.insert(QStringLiteral("oacute"), QChar(0x00F3));
h.insert(QStringLiteral("auml"), QChar(0x00E4));
h.insert(QStringLiteral("euml"), QChar(0x00EB));
h.insert(QStringLiteral("iuml"), QChar(0x00EF));
h.insert(QStringLiteral("ouml"), QChar(0x00F6));
h.insert(QStringLiteral("uuml"), QChar(0x00FC));
h.insert(QStringLiteral("szlig"), QChar(0x00DF));
h.insert(QStringLiteral("deg"), QChar(0x00B0));
h.insert(QStringLiteral("plusmn"), QChar(0x00B1));
h.insert(QStringLiteral("times"), QChar(0x00D7));
h.insert(QStringLiteral("divide"), QChar(0x00F7));
h.insert(QStringLiteral("copy"), QChar(0x00A9));
h.insert(QStringLiteral("reg"), QChar(0x00AE));
h.insert(QStringLiteral("trade"), QChar(0x2122));
h.insert(QStringLiteral("euro"), QChar(0x20AC));
h.insert(QStringLiteral("pound"), QChar(0x00A3));
h.insert(QStringLiteral("cent"), QChar(0x00A2));
h.insert(QStringLiteral("yen"), QChar(0x00A5));
h.insert(QStringLiteral("para"), QChar(0x00B6));
h.insert(QStringLiteral("sect"), QChar(0x00A7));
h.insert(QStringLiteral("laquo"), QChar(0x00AB));
h.insert(QStringLiteral("raquo"), QChar(0x00BB));
h.insert(QStringLiteral("lsaquo"), QChar(0x2039));
h.insert(QStringLiteral("rsaquo"), QChar(0x203A));
h.insert(QStringLiteral("prime"), QChar(0x2032));
h.insert(QStringLiteral("Prime"), QChar(0x2033));
h.insert(QStringLiteral("permil"), QChar(0x2030));
return h;
}();
QString out;
out.reserve(s.size());
int i = 0;
const int n = s.size();
while (i < n) {
const QChar c = s.at(i);
if (c == QLatin1Char('&')) {
const int semi = s.indexOf(QLatin1Char(';'), i + 1);
if (semi > i && semi - i <= 12) {
const QString ent = s.mid(i + 1, semi - i - 1);
const QChar ch = entities.value(ent);
if (!ch.isNull()) {
out += ch;
i = semi + 1;
continue;
}
}
}
out += c;
++i;
}
return out;
}
QString htmlToRichText(const QString &html)
{
const int bodyStart = html.indexOf(QStringLiteral("<body"), 0, Qt::CaseInsensitive);
const int bodyEnd = html.indexOf(QStringLiteral("</body"), 0, Qt::CaseInsensitive);
QString scope;
if (bodyStart >= 0) {
// includi anche la chiusura </body>: il frammento deve essere XML valido
const int end = (bodyEnd >= bodyStart) ? bodyEnd + 7 : html.size();
scope = html.mid(bodyStart, end - bodyStart);
} else {
scope = html;
}
scope = decodeHtmlEntities(scope);
QString out;
QStringList stack;
QXmlStreamReader r(scope);
while (!r.atEnd() && !r.hasError()) {
const QXmlStreamReader::TokenType t = r.readNext();
if (t == QXmlStreamReader::StartElement) {
const QString name = r.name().toString().toLower();
if (name == QLatin1String("script") || name == QLatin1String("style")
|| name == QLatin1String("noscript") || name == QLatin1String("head")
|| name == QLatin1String("title")) {
r.skipCurrentElement();
continue;
}
if (name == QLatin1String("br")) {
out += QStringLiteral("<br>");
stack.append(QString());
continue;
}
if (name == QLatin1String("img") || name == QLatin1String("hr")
|| name == QLatin1String("input")) {
stack.append(QString());
continue;
}
QString tag;
if (name == QLatin1String("b") || name == QLatin1String("strong"))
tag = QStringLiteral("b");
else if (name == QLatin1String("i") || name == QLatin1String("em"))
tag = QStringLiteral("i");
else if (name == QLatin1String("u"))
tag = QStringLiteral("u");
else if (name == QLatin1String("s") || name == QLatin1String("strike")
|| name == QLatin1String("del"))
tag = QStringLiteral("s");
else if (name == QLatin1String("ul"))
tag = QStringLiteral("ul");
else if (name == QLatin1String("ol"))
tag = QStringLiteral("ol");
else if (name == QLatin1String("li"))
tag = QStringLiteral("li");
else if (name == QLatin1String("table"))
tag = QStringLiteral("table border=\"1\" cellspacing=\"0\" cellpadding=\"4\"");
else if (name == QLatin1String("tr"))
tag = QStringLiteral("tr");
else if (name == QLatin1String("td") || name == QLatin1String("th"))
tag = QStringLiteral("td");
else if (name == QLatin1String("p") || name == QLatin1String("blockquote"))
tag = QStringLiteral("p");
else if (name == QLatin1String("h1") || name == QLatin1String("h2")
|| name == QLatin1String("h3") || name == QLatin1String("h4")
|| name == QLatin1String("h5") || name == QLatin1String("h6"))
tag = name;
// div/span/a/section/... -> trasparenti (tag vuoto)
if (!tag.isEmpty()) {
out += QLatin1Char('<') + tag + QLatin1Char('>');
stack.append(tag);
} else {
stack.append(QString());
}
} else if (t == QXmlStreamReader::EndElement) {
if (!stack.isEmpty()) {
const QString tag = stack.takeLast();
if (!tag.isEmpty()) {
const int sp = tag.indexOf(QLatin1Char(' '));
out += QStringLiteral("</") + (sp > 0 ? tag.left(sp) : tag) + QLatin1Char('>');
}
}
} else if (t == QXmlStreamReader::Characters) {
out += r.text();
}
}
// HTML non ben formato (parse fallito): fallback grezzo — testo con <br>
// sui newline e paragrafi sui doppi newline
if (r.hasError()) {
out.clear();
int i = 0;
const int n = scope.size();
while (i < n) {
const QChar c = scope.at(i);
if (c == QLatin1Char('<')) {
const int close = scope.indexOf(QLatin1Char('>'), i + 1);
i = (close < 0) ? n : close + 1;
continue;
}
if (c == QLatin1Char('&')) {
const int semi = scope.indexOf(QLatin1Char(';'), i + 1);
if (semi > i && semi - i <= 10) {
const QString ent = scope.mid(i + 1, semi - i - 1);
if (ent == QLatin1String("amp")) out += QLatin1Char('&');
else if (ent == QLatin1String("lt")) out += QLatin1Char('<');
else if (ent == QLatin1String("gt")) out += QLatin1Char('>');
else if (ent == QLatin1String("nbsp")) out += QLatin1Char(' ');
else if (ent.startsWith(QLatin1String("#"))) {
bool ok = false;
const int cp = ent.mid(1).toInt(&ok);
if (ok && cp > 0 && cp < 0x10FFFF)
out += QChar(cp);
} else {
out += c;
out += ent;
out += QLatin1Char(';');
}
i = semi + 1;
continue;
}
}
if (c == QLatin1Char('\n')) {
// doppio newline = paragrafo, singolo = a capo
if (i + 1 < n && scope.at(i + 1) == QLatin1Char('\n')) {
out += QStringLiteral("</p><p>");
++i;
} else {
out += QStringLiteral("<br>");
}
++i;
continue;
}
out += c;
++i;
}
}
return out;
}
} // 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_textLengths.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();
if (ok) {
// lunghezze testo LAZY: -1 = da calcolare al primo accesso. L'apertura
// resta veloce anche per libri grossi (niente parse di tutti i capitoli).
m_textLengths.clear();
m_textLengths.reserve(m_chapters.size());
for (int i = 0; i < m_chapters.size(); ++i)
m_textLengths.append(-1);
}
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.
// height:auto + overflow:visible forzano lo scroll anche quando il CSS del
// libro imposta height:100%/overflow:hidden (senza, il documento non scorre
// e ogni tap salta al capitolo successivo).
const QString style = QStringLiteral(
"<style id=\"cw-font\">html,body{font-size:%1px!important;"
"height:auto!important;overflow:visible!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::chapterRichText(int index) const
{
if (index < 0 || index >= m_chapters.size())
return QString();
QFile f(m_chapters.at(index).path);
if (!f.open(QIODevice::ReadOnly))
return QString();
const QString html = QString::fromUtf8(f.readAll());
return htmlToRichText(html);
}
// calcola (e cache) la lunghezza del capitolo index se non ancora nota
void EpubBook::ensureTextLength(int index) const
{
if (index < 0 || index >= m_textLengths.size() || m_textLengths.at(index) >= 0)
return;
const EpubChapter &c = m_chapters.at(index);
QFile f(c.path);
if (!f.open(QIODevice::ReadOnly)) {
m_textLengths[index] = 0;
return;
}
const QString html = QString::fromUtf8(f.readAll());
m_textLengths[index] = plainTextLength(html);
}
int EpubBook::chapterTextLength(int index) const
{
if (index < 0 || index >= m_textLengths.size())
return 0;
ensureTextLength(index);
return m_textLengths.at(index);
}
qint64 EpubBook::chapterStartChar(int index) const
{
qint64 start = 0;
const int end = qMin(index, m_textLengths.size());
for (int i = 0; i < end; ++i) {
ensureTextLength(i);
start += m_textLengths.at(i);
}
return start;
}
QString EpubBook::makeBookmarkKey(int chapterIndex, qint64 charOffset) const
{
const qint64 abs = chapterStartChar(chapterIndex) + qMax<qint64>(0, charOffset);
return QStringLiteral("cw:v2:%1").arg(abs);
}
QVariantMap EpubBook::parseBookmarkKey(const QString &key) const
{
QVariantMap result;
const QStringList parts = key.split(QLatin1Char(':'));
if (parts.size() < 3 || parts.at(0) != QLatin1String("cw"))
return result;
if (parts.at(1) == QLatin1String("v2")) {
bool ok = false;
const qint64 abs = parts.at(2).toLongLong(&ok);
if (!ok || abs < 0)
return result;
// trova il capitolo che contiene il carattere assoluto
int ch = 0;
qint64 start = 0;
for (int i = 0; i < m_textLengths.size(); ++i) {
if (start + m_textLengths.at(i) > abs) {
ch = i;
break;
}
start += m_textLengths.at(i);
ch = i + 1;
}
if (ch >= m_chapters.size())
ch = qMax(0, m_chapters.size() - 1);
result.insert(QStringLiteral("chapter"), ch);
result.insert(QStringLiteral("charOffset"),
qMax<qint64>(0, abs - chapterStartChar(ch)));
return result;
}
// v1 (vecchie chiavi, migrazione): "cw:v1:<cap>:<frac>"
if (parts.at(1) == QLatin1String("v1") && parts.size() == 4) {
bool okC = false, okF = false;
const int ch = parts.at(2).toInt(&okC);
const double fr = parts.at(3).toDouble(&okF);
if (!okC || !okF)
return result;
result.insert(QStringLiteral("chapter"), qMax(0, ch));
result.insert(QStringLiteral("charOffset"),
qRound64(fr * chapterTextLength(ch)));
return result;
}
return result;
}
-82
View File
@@ -1,82 +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;
// lunghezza in caratteri del testo VISIBILE del capitolo (entità decodificate,
// head/script/style esclusi) — stesso criterio del conteggio JS nel lettore
Q_INVOKABLE int chapterTextLength(int index) const;
// caratteri di testo complessivi prima dell'inizio del capitolo index
Q_INVOKABLE qint64 chapterStartChar(int index) 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;
// rich text Qt del capitolo (per Text QML: niente WebView, scroll affidabile).
// Converte i tag HTML di base, salta script/style/head/immagini.
Q_INVOKABLE QString chapterRichText(int index) const;
// chiave segno locale v2, es. "cw:v2:12345" (posizione = carattere assoluto
// nel libro, indipendente da font, margini e reflow)
Q_INVOKABLE QString makeBookmarkKey(int chapterIndex, qint64 charOffset) const;
// restituisce {chapter, charOffset} con charOffset NEL capitolo; accetta
// chiavi v2 ("cw:v2:<assoluto>") e v1 ("cw:v1:<cap>:<frac>", migrazione)
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;
// calcola (e cache) la lunghezza in caratteri del capitolo al primo accesso
void ensureTextLength(int index) const;
QString m_baseDir;
QString m_opfDir;
QString m_title;
QString m_author;
QList<EpubChapter> m_chapters;
// lunghezze testo per capitolo, calcolate LAZY al primo accesso
// (-1 = non ancora calcolata); mutable per la cache da metodi const
mutable QList<int> m_textLengths;
};
#endif // EPUB_H
-82
View File
@@ -1,82 +0,0 @@
#include <QDebug>
#include <QCoreApplication>
#include <QGuiApplication>
#include <QLocale>
#include <QQuickView>
#include <QQmlContext>
#include <QTranslator>
#include <sailfishapp.h>
#include "apiclient.h"
#include "downloader.h"
#include "epub.h"
#include "pagedtextitem.h"
#include "readerstate.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"));
// versione mostrata nelle impostazioni (tenere allineata allo spec)
app->setApplicationVersion(QStringLiteral("0.5.1"));
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.5.1 build" << __DATE__ << __TIME__;
qmlRegisterType<EpubBook>("harbour.calibreweb", 1, 0, "EpubBook");
qmlRegisterType<PagedTextItem>("harbour.calibreweb", 1, 0, "PagedTextItem");
ApiClient apiClient(&settings);
Downloader downloader(&settings);
// stato di lettura corrente (per la cover quando l'app va in background)
ReaderState readerState(&settings);
view->rootContext()->setContextProperty(QStringLiteral("appSettings"), &settings);
view->rootContext()->setContextProperty(QStringLiteral("apiClient"), &apiClient);
view->rootContext()->setContextProperty(QStringLiteral("downloader"), &downloader);
view->rootContext()->setContextProperty(QStringLiteral("readerState"), &readerState);
view->rootContext()->setContextProperty(QStringLiteral("appVersion"),
QCoreApplication::applicationVersion());
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
-246
View File
@@ -1,246 +0,0 @@
#include "pagedtextitem.h"
#include <QPainter>
#include <QAbstractTextDocumentLayout>
#include <QTextBlock>
#include <QTextLayout>
#include <QFont>
#include <QPointF>
#include <QRectF>
PagedTextItem::PagedTextItem(QQuickItem *parent)
: QQuickPaintedItem(parent)
, m_fontPixelSize(16)
, m_page(0)
, m_pendingPosition(-1)
, m_layoutPageHeight(1)
{
// nessun margine interno: i margini li impone la geometria dell'item in QML
m_doc.setDocumentMargin(0);
applyFont();
}
void PagedTextItem::applyFont()
{
QFont f = m_doc.defaultFont();
if (!m_fontFamily.isEmpty())
f.setFamily(m_fontFamily);
f.setPixelSize(qMax(4, m_fontPixelSize));
m_doc.setDefaultFont(f);
}
void PagedTextItem::setHtml(const QString &html)
{
if (m_html == html)
return;
m_html = html;
m_page = 0;
m_doc.setHtml(m_html);
if (!m_baseUrl.isEmpty())
m_doc.setBaseUrl(QUrl(m_baseUrl));
relayout(false);
emit htmlChanged();
}
void PagedTextItem::setBaseUrl(const QString &url)
{
if (m_baseUrl == url)
return;
m_baseUrl = url;
m_doc.setBaseUrl(QUrl(url));
emit baseUrlChanged();
update();
}
void PagedTextItem::setFontFamily(const QString &family)
{
if (m_fontFamily == family)
return;
// posizione letta PRIMA di toccare il font: il layout e' ancora coerente
const int keep = positionAtPage(m_page);
m_fontFamily = family;
applyFont();
relayoutKeeping(keep);
emit fontFamilyChanged();
}
void PagedTextItem::setFontPixelSize(int px)
{
px = qMax(4, px);
if (m_fontPixelSize == px)
return;
// posizione letta PRIMA del reflow (vedi setFontFamily)
const int keep = positionAtPage(m_page);
m_fontPixelSize = px;
applyFont();
// reflow: mantiene la posizione di lettura per carattere
relayoutKeeping(keep);
emit fontPixelSizeChanged();
}
void PagedTextItem::setTextColor(const QColor &color)
{
if (m_textColor == color)
return;
m_textColor = color;
emit textColorChanged();
update();
}
void PagedTextItem::setPage(int page)
{
const int last = pageCount() - 1;
page = qBound(0, page, qMax(0, last));
if (m_page == page)
return;
m_page = page;
emit pageChanged();
emit positionChanged();
update();
}
void PagedTextItem::nextPage()
{
setPage(m_page + 1);
}
void PagedTextItem::previousPage()
{
setPage(m_page - 1);
}
void PagedTextItem::goToPosition(int pos)
{
if (width() <= 0 || height() <= 0) {
// geometria non ancora risolta: applica al primo layout
m_pendingPosition = pos;
return;
}
setPage(pageForPosition(pos));
}
int PagedTextItem::pageCount() const
{
const int n = m_doc.pageCount();
return n > 0 ? n : 1;
}
int PagedTextItem::characterCount() const
{
return m_doc.characterCount();
}
int PagedTextItem::position() const
{
return positionAtPage(m_page);
}
qreal PagedTextItem::pageHeight() const
{
// altezza dell'ULTIMO layout: se la geometria e' appena cambiata ma il
// documento non e' ancora stato ri-impaginato, le letture restano coerenti
return m_layoutPageHeight > 0 ? m_layoutPageHeight : qMax(qreal(1), height());
}
int PagedTextItem::positionAtPage(int page) const
{
if (m_doc.isEmpty() || width() <= 0 || height() <= 0)
return 0;
QAbstractTextDocumentLayout *layout = m_doc.documentLayout();
if (!layout)
return 0;
const qreal ph = pageHeight();
// punto appena dentro la pagina: hitTest dà il carattere piu' vicino
const QPointF probe(qMin(qreal(4), width() / 2), page * ph + 1);
const int pos = layout->hitTest(probe, Qt::FuzzyHit);
if (pos < 0)
return 0;
return qBound(0, pos, qMax(0, m_doc.characterCount() - 1));
}
int PagedTextItem::pageForPosition(int pos) const
{
if (m_doc.isEmpty() || width() <= 0 || height() <= 0)
return 0;
const int len = m_doc.characterCount();
const int at = qBound(0, pos, qMax(0, len - 1));
// findBlock invece di QTextCursor: metodo const (niente cast sul documento)
const QTextBlock block = m_doc.findBlock(at);
QRectF rect;
if (QAbstractTextDocumentLayout *layout = m_doc.documentLayout())
rect = layout->blockBoundingRect(block);
qreal y = rect.top();
if (block.layout()) {
// y della riga che contiene il carattere, relativa al blocco: cosi'
// anche i paragrafi lunghi (spezzati su piu' pagine) sono precisi
const int inBlock = qBound(0, at - block.position(),
qMax(0, block.length() - 1));
const QTextLine line = block.layout()->lineForTextPosition(inBlock);
if (line.isValid())
y += line.y();
}
return qMax(0, int(y / pageHeight()));
}
void PagedTextItem::clampPage()
{
const int last = pageCount() - 1;
m_page = qBound(0, m_page, qMax(0, last));
}
void PagedTextItem::relayoutKeeping(int pos)
{
if (width() <= 0 || height() <= 0)
return;
m_doc.setPageSize(QSizeF(width(), height()));
m_layoutPageHeight = qMax(qreal(1), height());
if (m_pendingPosition >= 0) {
// posizione chiesta prima del layout (ripristino all'apertura)
m_page = pageForPosition(m_pendingPosition);
m_pendingPosition = -1;
} else if (pos >= 0) {
m_page = pageForPosition(pos);
}
clampPage();
emit layoutChanged();
emit positionChanged();
update();
}
void PagedTextItem::relayout(bool keepPosition)
{
relayoutKeeping(keepPosition ? positionAtPage(m_page) : -1);
}
void PagedTextItem::geometryChanged(const QRectF &newGeometry,
const QRectF &oldGeometry)
{
QQuickPaintedItem::geometryChanged(newGeometry, oldGeometry);
if (newGeometry.size() != oldGeometry.size())
relayout(true);
}
void PagedTextItem::renderPage(QPainter *painter) const
{
if (!painter || width() <= 0 || height() <= 0 || m_doc.isEmpty())
return;
const qreal ph = pageHeight();
painter->save();
painter->setRenderHint(QPainter::TextAntialiasing, true);
painter->setRenderHint(QPainter::Antialiasing, true);
painter->setClipRect(QRectF(0, 0, width(), ph));
painter->translate(0, -m_page * ph);
QAbstractTextDocumentLayout::PaintContext ctx;
// il clip del contesto e' in coordinate documento
ctx.clip = QRectF(0, m_page * ph, width(), ph);
if (m_textColor.isValid())
ctx.palette.setColor(QPalette::Text, m_textColor);
m_doc.documentLayout()->draw(painter, ctx);
painter->restore();
}
void PagedTextItem::paint(QPainter *painter)
{
renderPage(painter);
}
-105
View File
@@ -1,105 +0,0 @@
#ifndef PAGEDTEXTITEM_H
#define PAGEDTEXTITEM_H
// Motore di paginazione del lettore: pagina il rich text del capitolo in
// pagine ESATTE usando QTextDocument, e lo disegna con QPainter dentro un item
// QML (pattern QQuickPaintedItem, come harbour-books di Slava Monich).
//
// Perche': QTextDocument con pageSize imposta il "page breaking" del layout,
// quindi le pagine sono misurate dal layout stesso (niente stime, niente
// scroll, niente testo che sborda) e hitTest mappa pagina <-> carattere, che
// serve ai segnalibri e al ripristino della posizione dopo un reflow.
#include <QQuickPaintedItem>
#include <QTextDocument>
#include <QColor>
#include <QUrl>
class PagedTextItem : public QQuickPaintedItem
{
Q_OBJECT
Q_PROPERTY(QString html READ html WRITE setHtml NOTIFY htmlChanged)
Q_PROPERTY(QString baseUrl READ baseUrl WRITE setBaseUrl NOTIFY baseUrlChanged)
Q_PROPERTY(QString fontFamily READ fontFamily WRITE setFontFamily
NOTIFY fontFamilyChanged)
Q_PROPERTY(int fontPixelSize READ fontPixelSize WRITE setFontPixelSize
NOTIFY fontPixelSizeChanged)
Q_PROPERTY(QColor textColor READ textColor WRITE setTextColor
NOTIFY textColorChanged)
// pagina corrente (0-based) e numero di pagine del capitolo caricato
Q_PROPERTY(int page READ page WRITE setPage NOTIFY pageChanged)
Q_PROPERTY(int pageCount READ pageCount NOTIFY layoutChanged)
// primo carattere visibile nella pagina corrente (per i segnalibri)
Q_PROPERTY(int position READ position NOTIFY positionChanged)
Q_PROPERTY(int characterCount READ characterCount NOTIFY layoutChanged)
public:
explicit PagedTextItem(QQuickItem *parent = nullptr);
void paint(QPainter *painter) override;
// disegno della pagina corrente; pubblico per i test headless
void renderPage(QPainter *painter) const;
QString html() const { return m_html; }
void setHtml(const QString &html);
QString baseUrl() const { return m_baseUrl; }
void setBaseUrl(const QString &url);
QString fontFamily() const { return m_fontFamily; }
void setFontFamily(const QString &family);
int fontPixelSize() const { return m_fontPixelSize; }
void setFontPixelSize(int px);
QColor textColor() const { return m_textColor; }
void setTextColor(const QColor &color);
int page() const { return m_page; }
void setPage(int page);
int pageCount() const;
int position() const;
int characterCount() const;
Q_INVOKABLE void nextPage();
Q_INVOKABLE void previousPage();
// sposta la vista sul carattere indicato (ripristino posizione/reflow)
Q_INVOKABLE void goToPosition(int pos);
Q_INVOKABLE int positionAtPage(int page) const;
Q_INVOKABLE int pageForPosition(int pos) const;
signals:
void htmlChanged();
void baseUrlChanged();
void fontFamilyChanged();
void fontPixelSizeChanged();
void textColorChanged();
void pageChanged();
void positionChanged();
// il documento e' stato ri-impaginato (resize, font, nuovo capitolo)
void layoutChanged();
protected:
void geometryChanged(const QRectF &newGeometry,
const QRectF &oldGeometry) override;
private:
void applyFont();
// ri-impagina e (se pos >= 0) riporta la vista sul carattere indicato
void relayoutKeeping(int pos);
void relayout(bool keepPosition);
void clampPage();
qreal pageHeight() const;
QTextDocument m_doc;
QString m_html;
QString m_baseUrl;
QString m_fontFamily;
int m_fontPixelSize;
QColor m_textColor;
int m_page;
// posizione richiesta prima che l'item avesse una geometria valida:
// applicata al primo layout (senza, il ripristino del segnalibro
// atterrerebbe sempre a inizio capitolo)
int m_pendingPosition;
// altezza pagina dell'ultimo layout: le letture di posizione restano
// coerenti anche se la geometria dell'item e' appena cambiata
qreal m_layoutPageHeight;
};
#endif // PAGEDTEXTITEM_H
-27
View File
@@ -1,27 +0,0 @@
#include "readerstate.h"
ReaderState::ReaderState(QObject *parent)
: QObject(parent)
, m_active(false)
{
}
void ReaderState::setReading(const QString &title, const QString &subtitle)
{
if (m_active && m_title == title && m_subtitle == subtitle)
return;
m_active = true;
m_title = title;
m_subtitle = subtitle;
emit changed();
}
void ReaderState::clearReading()
{
if (!m_active)
return;
m_active = false;
m_title.clear();
m_subtitle.clear();
emit changed();
}
-38
View File
@@ -1,38 +0,0 @@
#ifndef READERSTATE_H
#define READERSTATE_H
#include <QObject>
#include <QString>
// Stato di lettura corrente, esposto come context property "readerState":
// la cover mostra il libro aperto (titolo + posizione) invece dell'icona fissa.
class ReaderState : public QObject
{
Q_OBJECT
Q_PROPERTY(bool active READ active NOTIFY changed)
Q_PROPERTY(QString title READ title NOTIFY changed)
Q_PROPERTY(QString subtitle READ subtitle NOTIFY changed)
public:
explicit ReaderState(QObject *parent = 0);
bool active() const { return m_active; }
QString title() const { return m_title; }
QString subtitle() const { return m_subtitle; }
// aggiorna lo stato quando il lettore mostra un libro (chiamata dal QML)
Q_INVOKABLE void setReading(const QString &title, const QString &subtitle);
// il libro è stato chiuso: la cover torna all'icona predefinita
Q_INVOKABLE void clearReading();
signals:
void changed();
private:
bool m_active;
QString m_title;
QString m_subtitle;
};
#endif // READERSTATE_H
-283
View File
@@ -1,283 +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")));
// margini copiati SOLO se espliciti nel sorgente: se mancano, niente
// chiave nel nuovo file -> vale il default del getter (24)
if (src.contains(QStringLiteral("readerMargin")))
m_settings.setValue(QStringLiteral("readerMargin"),
src.value(QStringLiteral("readerMargin")));
if (src.contains(QStringLiteral("readerMarginV")))
m_settings.setValue(QStringLiteral("readerMarginV"),
src.value(QStringLiteral("readerMarginV")));
// 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;
// niente sync a ogni tasto (flash wear): il flush avviene all'uscita
// dalle impostazioni o prima delle richieste di rete (vedi flush())
m_settings.setValue(QStringLiteral("serverUrl"), value);
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);
emit usernameChanged();
}
QString Settings::password() const
{
return m_settings.value(QStringLiteral("password")).toString();
}
void Settings::setPassword(const QString &value)
{
if (value == password())
return;
m_settings.setValue(QStringLiteral("password"), value);
emit passwordChanged();
}
void Settings::flush()
{
m_settings.sync();
openPermissions();
}
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
{
// default 24 come il laterale: margini alto/basso visibili anche senza
// configurazione (le build pre-0.4.12 usavano 0 come default)
return m_settings.value(QStringLiteral("readerMarginV"), 24).toInt();
}
void Settings::setReaderMarginV(int value)
{
if (value == readerMarginV())
return;
m_settings.setValue(QStringLiteral("readerMarginV"), value);
m_settings.sync();
openPermissions();
emit readerMarginVChanged();
}
double Settings::readerFontScale() const
{
return m_settings.value(QStringLiteral("readerFontScale"), 1.0).toDouble();
}
void Settings::setReaderFontScale(double value)
{
if (qFuzzyCompare(value, readerFontScale()))
return;
m_settings.setValue(QStringLiteral("readerFontScale"), value);
m_settings.sync();
openPermissions();
emit readerFontScaleChanged();
}
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);
}
-104
View File
@@ -1,104 +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)
// scala del font del lettore (1.0 = dimensione base), persistita
Q_PROPERTY(double readerFontScale READ readerFontScale WRITE setReaderFontScale NOTIFY readerFontScaleChanged)
// 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);
// scala del font del lettore (1.0 = base), persistita tra le sessioni
double readerFontScale() const;
void setReaderFontScale(double value);
// lingua dell'interfaccia: "system" (default) | "en" | "it"
QString language() const;
void setLanguage(const QString &value);
QString downloadDir() const;
// scrive subito su disco le modifiche in sospeso (i setter di testo non
// fanno sync a ogni tasto: chiamare questo all'uscita dalle impostazioni)
Q_INVOKABLE void flush();
// 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 readerFontScaleChanged();
void languageChanged();
void baseUrlChanged();
private:
// Path canonico per la sandbox SailJail (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 PERSISTENTE della sandbox (senza profilo la sandbox la nasconde).
static QString configPath();
// Importa i valori dal vecchio file per-utente (~/.config/harbour/...) se
// il file canonico e' vuoto: copre la configurazione fatta prima del fix.
void migrateIfNeeded();
// Apre i permessi della catena di directory del file di configurazione:
// l'avvio dal launcher e quello da SSH (defaultuser) devono poter
// leggere/scrivere lo stesso file. Efficace quando l'app gira come
// owner della home (dal launcher).
void openPermissions();
QSettings m_settings;
};
#endif // SETTINGS_H
-274
View File
@@ -1,274 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE TS>
<TS version="2.1" language="it_IT">
<context>
<name>BookDetailPage</name>
<message>
<location filename="../qml/pages/BookDetailPage.qml" line="38"/>
<source>Read (EPUB)</source>
<translation>Leggi (EPUB)</translation>
</message>
<message>
<location filename="../qml/pages/BookDetailPage.qml" line="62"/>
<source>Formats</source>
<translation>Formati</translation>
</message>
<message>
<location filename="../qml/pages/BookDetailPage.qml" line="114"/>
<source>Downloading…</source>
<translation>Download in corso…</translation>
</message>
<message>
<location filename="../qml/pages/BookDetailPage.qml" line="263"/>
<source>Saved to </source>
<translation>Salvato in </translation>
</message>
<message>
<location filename="../qml/pages/BookDetailPage.qml" line="268"/>
<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="22"/>
<source>Calibre Web</source>
<translation>Calibre Web</translation>
</message>
</context>
<context>
<name>FeedPage</name>
<message>
<location filename="../qml/pages/FeedPage.qml" line="93"/>
<source>Refresh</source>
<translation>Aggiorna</translation>
</message>
<message>
<location filename="../qml/pages/FeedPage.qml" line="97"/>
<source>Settings</source>
<translation>Impostazioni</translation>
</message>
<message>
<location filename="../qml/pages/FeedPage.qml" line="104"/>
<location filename="../qml/pages/FeedPage.qml" line="173"/>
<source>No results</source>
<translation>Nessun risultato</translation>
</message>
</context>
<context>
<name>MainPage</name>
<message>
<location filename="../qml/pages/MainPage.qml" line="15"/>
<source>Calibre Web</source>
<translation>Calibre Web</translation>
</message>
<message>
<location filename="../qml/pages/MainPage.qml" line="45"/>
<source>Search</source>
<translation>Cerca</translation>
</message>
<message>
<location filename="../qml/pages/MainPage.qml" line="49"/>
<source>Settings</source>
<translation>Impostazioni</translation>
</message>
<message>
<location filename="../qml/pages/MainPage.qml" line="53"/>
<source>Refresh</source>
<translation>Aggiorna</translation>
</message>
<message>
<location filename="../qml/pages/MainPage.qml" line="62"/>
<source>Open the settings and enter your Calibre Web address</source>
<translation>Apri le impostazioni e inserisci l&apos;indirizzo del tuo Calibre Web</translation>
</message>
<message>
<location filename="../qml/pages/MainPage.qml" line="79"/>
<source>Server not configured</source>
<translation>Server non configurato</translation>
</message>
<message>
<location filename="../qml/pages/MainPage.qml" line="102"/>
<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="68"/>
<source>Table of contents</source>
<translation>Indice capitoli</translation>
</message>
<message>
<location filename="../qml/pages/ReaderPage.qml" line="72"/>
<source>Text margins</source>
<translation>Margini del testo</translation>
</message>
<message>
<location filename="../qml/pages/ReaderPage.qml" line="87"/>
<source>Settings</source>
<translation>Impostazioni</translation>
</message>
<message>
<location filename="../qml/pages/ReaderPage.qml" line="91"/>
<source>Close book</source>
<translation>Chiudi libro</translation>
</message>
<message>
<location filename="../qml/pages/ReaderPage.qml" line="368"/>
<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="17"/>
<location filename="../qml/pages/SearchPage.qml" line="29"/>
<source>Search</source>
<translation>Cerca</translation>
</message>
<message>
<location filename="../qml/pages/SearchPage.qml" line="22"/>
<source>Title or author…</source>
<translation>Titolo o autore…</translation>
</message>
<message>
<location filename="../qml/pages/SearchPage.qml" line="37"/>
<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="52"/>
<source>Results: </source>
<translation>Risultati: </translation>
</message>
</context>
<context>
<name>SettingsPage</name>
<message>
<location filename="../qml/pages/SettingsPage.qml" line="22"/>
<source>Settings</source>
<translation>Impostazioni</translation>
</message>
<message>
<location filename="../qml/pages/SettingsPage.qml" line="24"/>
<source>Server</source>
<translation>Server</translation>
</message>
<message>
<location filename="../qml/pages/SettingsPage.qml" line="30"/>
<source>Calibre Web address</source>
<translation>Indirizzo Calibre Web</translation>
</message>
<message>
<location filename="../qml/pages/SettingsPage.qml" line="41"/>
<source>User (empty = anonymous access)</source>
<translation>Utente (vuoto = accesso anonimo)</translation>
</message>
<message>
<location filename="../qml/pages/SettingsPage.qml" line="50"/>
<source>Password</source>
<translation>Password</translation>
</message>
<message>
<location filename="../qml/pages/SettingsPage.qml" line="57"/>
<source>Ignore SSL certificate errors</source>
<translation>Ignora errori certificato SSL</translation>
</message>
<message>
<location filename="../qml/pages/SettingsPage.qml" line="58"/>
<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="63"/>
<source>Connection</source>
<translation>Connessione</translation>
</message>
<message>
<location filename="../qml/pages/SettingsPage.qml" line="67"/>
<source>Test connection</source>
<translation>Prova connessione</translation>
</message>
<message>
<location filename="../qml/pages/SettingsPage.qml" line="91"/>
<location filename="../qml/pages/SettingsPage.qml" line="96"/>
<source>Language</source>
<translation>Lingua</translation>
</message>
<message>
<location filename="../qml/pages/SettingsPage.qml" line="99"/>
<source>System (default)</source>
<translation>Sistema (predefinita)</translation>
</message>
<message>
<location filename="../qml/pages/SettingsPage.qml" line="107"/>
<source>Language will be applied after restarting the app</source>
<translation>La lingua verrà applicata al riavvio dell&apos;app</translation>
</message>
<message>
<location filename="../qml/pages/SettingsPage.qml" line="142"/>
<source>Enter the server address</source>
<translation>Inserisci l&apos;indirizzo del server</translation>
</message>
<message>
<location filename="../qml/pages/SettingsPage.qml" line="132"/>
<source>Version</source>
<translation>Versione</translation>
</message>
<message>
<location filename="../qml/pages/SettingsPage.qml" line="152"/>
<source>Testing…</source>
<translation>Verifica in corso…</translation>
</message>
<message>
<location filename="../qml/pages/SettingsPage.qml" line="169"/>
<source>Connection OK: %1 sections found</source>
<translation>Connessione OK: %1 sezioni trovate</translation>
</message>
</context>
</TS>