Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cb907bf4dc |
@@ -1,6 +0,0 @@
|
||||
build/
|
||||
build-*/
|
||||
*.rpm
|
||||
*.user
|
||||
Makefile
|
||||
.qmake.stash
|
||||
-105
@@ -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
|
||||
@@ -1,104 +1,2 @@
|
||||
# harbour-calibreweb
|
||||
|
||||
Client **OPDS** per Sailfish OS: naviga e scarica libri dalla tua istanza
|
||||
**Calibre Web** (o dal Content Server di Calibre).
|
||||
|
||||
Funziona con qualsiasi server OPDS, ma è pensato per Calibre Web:
|
||||
feed di navigazione, ricerca, copertine, download con progresso.
|
||||
|
||||
## Perché OPDS e non l'API REST
|
||||
|
||||
Le versioni recenti di calibre-web (upstream) **non hanno più l'API REST**:
|
||||
`/api/*` è stata rimossa e non esiste in nessuna release 0.6.9+ né in master.
|
||||
L'interfaccia stabile e documentata è **OPDS** (`/opds`, `/opds/new`,
|
||||
`/opds/search?query=...`), che usa HTTP Basic Auth ed espone i link di
|
||||
download per formato. OPDS è supportato anche dal Content Server di Calibre,
|
||||
quindi questa app copre entrambi i backend.
|
||||
|
||||
## Funzionalità
|
||||
|
||||
- Sezioni del catalogo (novità, autori, categorie, scaffali, ...) dall'indice `/opds`
|
||||
- Paginazione automatica (link `rel="next"`)
|
||||
- Ricerca testuale (`/opds/search`)
|
||||
- Copertine con cache locale
|
||||
- Dettaglio libro: autore, descrizione, formati con dimensione
|
||||
- Download con barra di progresso in `~/Downloads`
|
||||
- Autenticazione HTTP Basic (utente vuoto = browse anonimo)
|
||||
- Opzione "ignora errori SSL" per certificati autofirmati
|
||||
|
||||
## Build
|
||||
|
||||
Serve il [Sailfish SDK](https://sailfishos.org/wiki/Sailfish_SDK)
|
||||
(su Linux: `flatpak install flathub org.sailfishos.sdk` o tarball).
|
||||
|
||||
```sh
|
||||
git clone <questo repo> harbour-calibreweb
|
||||
cd harbour-calibreweb
|
||||
|
||||
# scegli un target, es.:
|
||||
sfdk target install SailfishOS-4.6.0.11-armv7hl
|
||||
sfdk target use SailfishOS-4.6.0.11-armv7hl
|
||||
|
||||
sfdk build # produce l'RPM in RPMS/
|
||||
sfdk deploy # installa sul dispositivo connesso
|
||||
```
|
||||
|
||||
In alternativa, installa l'RPM a mano:
|
||||
|
||||
```sh
|
||||
pkcon install-local harbour-calibreweb-0.1.0-1.armv7hl.rpm
|
||||
```
|
||||
|
||||
Target consigliati in base al dispositivo: `armv7hl` (Xperia X, Jolla C…),
|
||||
`aarch64` (Xperia 10 II/III, Jolla C2), `x86_64` (emulatore).
|
||||
Per il **Jolla Phone 2026** (MediaTek Dimensity, jp2601): `aarch64` con
|
||||
Sailfish OS 5.0, es. `SailfishOS-5.0.0.62-aarch64` (o l'ultimo 5.0.x
|
||||
disponibile nella tua versione dell'SDK).
|
||||
|
||||
## Configurazione
|
||||
|
||||
Apri l'app → menù → **Impostazioni**:
|
||||
|
||||
| Campo | Valore |
|
||||
|---|---|
|
||||
| Indirizzo Calibre Web | es. `https://calibre.example.com` (o con sottopath) |
|
||||
| Utente | vuoto se il browse anonimo è attivo su calibre-web |
|
||||
| Password | quella dell'account (Basic Auth) |
|
||||
| Ignora SSL | attivalo solo se il server ha un certificato autofirmato |
|
||||
|
||||
Poi **Prova connessione** e naviga.
|
||||
|
||||
## Struttura
|
||||
|
||||
```
|
||||
src/ C++: settings (QSettings), apiclient (feed+copertine),
|
||||
downloader (progressi), opdsparser (Atom/OPDS), main
|
||||
qml/pages/ MainPage (indice), FeedPage (liste+paginazione),
|
||||
BookDetailPage (dettaglio+download), SearchPage, SettingsPage
|
||||
qml/cover/ cover dell'app
|
||||
icons/ icone 86/108 px + script di generazione
|
||||
rpm/ spec e desktop file
|
||||
```
|
||||
|
||||
## Limiti noti
|
||||
|
||||
- La password è salvata in chiaro nelle impostazioni dell'app
|
||||
(`/home/nemo/.config/harbour/calibreweb.conf`) — accettabile per uso
|
||||
personale. Il file è fisso e condiviso tra launcher (nemo) e debug SSH
|
||||
(defaultuser): la config non dipende dall'utente che avvia l'app.
|
||||
- La ricerca usa il motore del server; risultati identici alla UI web.
|
||||
- Il core C++ (parsing, auth, download) è testato con Qt 5.15 contro feed
|
||||
OPDS reali e un mock con Basic Auth; la UI Silica richiede il SDK per
|
||||
la verifica su dispositivo.
|
||||
|
||||
## Test del core
|
||||
|
||||
```sh
|
||||
# mock server OPDS + test end-to-end (vedi repository /tmp in locale):
|
||||
python3 /tmp/mock_opds.py
|
||||
# compila e lancia /tmp/opds_test (Qt 5.15+)
|
||||
```
|
||||
|
||||
## Licenza
|
||||
|
||||
MIT
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
TEMPLATE = app
|
||||
TARGET = harbour-calibreweb
|
||||
|
||||
CONFIG += sailfishapp
|
||||
CONFIG += c++11
|
||||
|
||||
SOURCES += \
|
||||
src/main.cpp \
|
||||
src/settings.cpp \
|
||||
src/apiclient.cpp \
|
||||
src/downloader.cpp \
|
||||
src/opdsparser.cpp \
|
||||
src/epub.cpp
|
||||
|
||||
HEADERS += \
|
||||
src/settings.h \
|
||||
src/apiclient.h \
|
||||
src/downloader.h \
|
||||
src/opdsparser.h \
|
||||
src/epub.h
|
||||
|
||||
LIBS += -lz
|
||||
|
||||
OTHER_FILES += \
|
||||
qml/harbour-calibreweb.qml \
|
||||
qml/pages/*.qml \
|
||||
qml/cover/*.qml \
|
||||
rpm/harbour-calibreweb.spec \
|
||||
rpm/harbour-calibreweb.desktop \
|
||||
rpm/harbour-calibreweb.yaml \
|
||||
translations/harbour-calibreweb_it.ts
|
||||
|
||||
DISTFILES += $$OTHER_FILES
|
||||
|
||||
# Translations are NOT handled by the sailfishapp_i18n template on the 5.1 kit
|
||||
# (the .qm is never generated/installed reliably). The spec builds the .qm
|
||||
# with lrelease and installs it into /usr/share/translations directly;
|
||||
# libsailfishapp loads harbour-calibreweb_<locale>.qm automatically.
|
||||
# Source strings are ENGLISH (default language); harbour-calibreweb_it.ts
|
||||
# provides the Italian catalogue.
|
||||
|
||||
# Icone installate via make install (struttura standard Sailfish: icons/<size>/<name>.png)
|
||||
icons86.path = /usr/share/icons/hicolor/86x86/apps
|
||||
icons86.files = $$PWD/icons/86x86/harbour-calibreweb.png
|
||||
icons108.path = /usr/share/icons/hicolor/108x108/apps
|
||||
icons108.files = $$PWD/icons/108x108/harbour-calibreweb.png
|
||||
icons128.path = /usr/share/icons/hicolor/128x128/apps
|
||||
icons128.files = $$PWD/icons/128x128/harbour-calibreweb.png
|
||||
icons172.path = /usr/share/icons/hicolor/172x172/apps
|
||||
icons172.files = $$PWD/icons/172x172/harbour-calibreweb.png
|
||||
INSTALLS += icons86 icons108 icons128 icons172
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.4 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.6 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 2.3 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.1 KiB |
@@ -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()
|
||||
@@ -1,23 +0,0 @@
|
||||
import QtQuick 2.0
|
||||
import Sailfish.Silica 1.0
|
||||
|
||||
CoverBackground {
|
||||
Column {
|
||||
anchors.centerIn: parent
|
||||
spacing: Theme.paddingMedium
|
||||
|
||||
Image {
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
source: "/usr/share/icons/hicolor/86x86/apps/harbour-calibreweb.png"
|
||||
sourceSize.width: 86
|
||||
sourceSize.height: 86
|
||||
}
|
||||
|
||||
Label {
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
text: qsTr("Calibre Web")
|
||||
color: Theme.primaryColor
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import QtQuick 2.0
|
||||
import Sailfish.Silica 1.0
|
||||
import "pages"
|
||||
import "cover"
|
||||
|
||||
ApplicationWindow {
|
||||
id: app
|
||||
|
||||
initialPage: Component { MainPage {} }
|
||||
cover: Component { CoverPage {} }
|
||||
|
||||
allowedOrientations: Orientation.Portrait
|
||||
_defaultPageOrientations: Orientation.Portrait
|
||||
}
|
||||
@@ -1,270 +0,0 @@
|
||||
import QtQuick 2.0
|
||||
import Sailfish.Silica 1.0
|
||||
|
||||
Page {
|
||||
id: page
|
||||
|
||||
property var bookData: ({})
|
||||
property string coverSource: ""
|
||||
property bool downloading: false
|
||||
property string downloadError: ""
|
||||
property bool readingNow: false
|
||||
property string readingUrl: ""
|
||||
|
||||
SilicaFlickable {
|
||||
anchors.fill: parent
|
||||
contentHeight: column.height + Theme.paddingLarge
|
||||
|
||||
Column {
|
||||
id: column
|
||||
width: parent.width
|
||||
|
||||
PageHeader { title: bookData.title }
|
||||
|
||||
Image {
|
||||
id: cover
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
width: Math.min(parent.width * 0.45, 260)
|
||||
height: width * 1.5
|
||||
fillMode: Image.PreserveAspectFit
|
||||
source: page.coverSource
|
||||
visible: source.length > 0
|
||||
}
|
||||
|
||||
Button {
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
text: qsTr("Read (EPUB)")
|
||||
visible: page.hasEpubFormat()
|
||||
enabled: !page.downloading
|
||||
onClicked: startReading()
|
||||
}
|
||||
|
||||
Label {
|
||||
x: Theme.horizontalPageMargin
|
||||
width: parent.width - 2 * Theme.horizontalPageMargin
|
||||
text: bookData.authors
|
||||
color: Theme.secondaryColor
|
||||
wrapMode: Text.Wrap
|
||||
visible: text.length > 0
|
||||
}
|
||||
|
||||
Label {
|
||||
x: Theme.horizontalPageMargin
|
||||
width: parent.width - 2 * Theme.horizontalPageMargin
|
||||
text: bookData.summary
|
||||
wrapMode: Text.Wrap
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
visible: text.length > 0
|
||||
}
|
||||
|
||||
SectionHeader { text: qsTr("Formats") }
|
||||
|
||||
Repeater {
|
||||
model: bookData.formats
|
||||
delegate: ListItem {
|
||||
id: formatItem
|
||||
contentHeight: Theme.itemSizeMedium
|
||||
enabled: !page.downloading
|
||||
|
||||
Label {
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: Theme.horizontalPageMargin
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: modelData.format
|
||||
+ (modelData.size > 0 ? " (" + formatSize(modelData.size) + ")" : "")
|
||||
color: formatItem.highlighted ? Theme.highlightColor : Theme.primaryColor
|
||||
}
|
||||
|
||||
onClicked: startDownload(modelData)
|
||||
}
|
||||
}
|
||||
|
||||
Label {
|
||||
x: Theme.horizontalPageMargin
|
||||
width: parent.width - 2 * Theme.horizontalPageMargin
|
||||
text: page.downloadError
|
||||
color: Theme.errorColor
|
||||
wrapMode: Text.Wrap
|
||||
visible: text.length > 0
|
||||
}
|
||||
|
||||
Item { width: 1; height: Theme.paddingLarge }
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.bottom: parent.bottom
|
||||
visible: page.downloading
|
||||
height: Theme.itemSizeMedium
|
||||
|
||||
ProgressBar {
|
||||
id: progressBar
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
value: 0
|
||||
minimumValue: 0
|
||||
maximumValue: 1
|
||||
}
|
||||
Label {
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
text: qsTr("Downloading…")
|
||||
color: Theme.secondaryColor
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
}
|
||||
}
|
||||
|
||||
// banner di conferma download (Notices non è istanziabile in questa Silica)
|
||||
property string downloadNotice: ""
|
||||
|
||||
Label {
|
||||
x: Theme.horizontalPageMargin
|
||||
width: parent.width - 2 * Theme.horizontalPageMargin
|
||||
text: page.downloadNotice
|
||||
color: Theme.highlightColor
|
||||
wrapMode: Text.Wrap
|
||||
visible: text.length > 0
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: hideNoticeTimer
|
||||
interval: 4000
|
||||
onTriggered: page.downloadNotice = ""
|
||||
}
|
||||
|
||||
function formatSize(bytes) {
|
||||
if (bytes <= 0)
|
||||
return ""
|
||||
if (bytes < 1048576)
|
||||
return Math.round(bytes / 1024) + " KB"
|
||||
return (bytes / 1048576).toFixed(1) + " MB"
|
||||
}
|
||||
|
||||
function resolveUrl(u) {
|
||||
if (u.indexOf("http://") === 0 || u.indexOf("https://") === 0)
|
||||
return u
|
||||
var base = appSettings.baseUrl
|
||||
// il server dietro sottopath (es. /calibre) emette link che lo includono già:
|
||||
// evita di raddoppiarlo (solo operazioni su stringhe, niente regex)
|
||||
var schemeEnd = base.indexOf("://")
|
||||
var slash = base.indexOf("/", schemeEnd + 3)
|
||||
var basePath = slash >= 0 ? base.substring(slash) : ""
|
||||
if (basePath.length > 1 && u.indexOf(basePath) === 0)
|
||||
return base + u.substring(basePath.length)
|
||||
return base + (u.charAt(0) === "/" ? u : "/" + u)
|
||||
}
|
||||
|
||||
function sanitizeFileName(s) {
|
||||
var out = ""
|
||||
for (var i = 0; i < s.length; i++) {
|
||||
var c = s.charAt(i)
|
||||
if (c === '/' || c === ':' || c === '*' || c === '?' || c === '"' || c === '<' || c === '>' || c === '|' || c.charCodeAt(0) === 92)
|
||||
out += '_'
|
||||
else
|
||||
out += c
|
||||
}
|
||||
return out.trim()
|
||||
}
|
||||
|
||||
function hasEpubFormat() {
|
||||
var fmts = bookData.formats
|
||||
if (!fmts)
|
||||
return false
|
||||
for (var i = 0; i < fmts.length; i++)
|
||||
if (String(fmts[i].format).toLowerCase() === "epub")
|
||||
return true
|
||||
return false
|
||||
}
|
||||
|
||||
function firstEpubFormat() {
|
||||
var fmts = bookData.formats
|
||||
if (!fmts)
|
||||
return null
|
||||
for (var i = 0; i < fmts.length; i++)
|
||||
if (String(fmts[i].format).toLowerCase() === "epub")
|
||||
return fmts[i]
|
||||
return null
|
||||
}
|
||||
|
||||
// estrae l'id numerico calibre da un url di download: /opds/download/<id>/<formato>/
|
||||
function extractBookId(u) {
|
||||
var parts = u.split("/")
|
||||
for (var i = 0; i < parts.length - 1; i++)
|
||||
if (parts[i] === "download" && parts[i + 1].length > 0
|
||||
&& !isNaN(parseInt(parts[i + 1])))
|
||||
return parseInt(parts[i + 1])
|
||||
return 0
|
||||
}
|
||||
|
||||
function startReading() {
|
||||
var f = page.firstEpubFormat()
|
||||
if (!f)
|
||||
return
|
||||
var id = page.extractBookId(f.url)
|
||||
if (id <= 0)
|
||||
return
|
||||
page.readingNow = true
|
||||
page.readingUrl = f.url
|
||||
page.downloading = true
|
||||
page.downloadError = ""
|
||||
downloader.download(resolveUrl(f.url), appSettings.bookFilePath(id, "epub"))
|
||||
}
|
||||
|
||||
function startDownload(format) {
|
||||
if (page.downloading)
|
||||
return
|
||||
var ext = format.format.toLowerCase()
|
||||
var dest = appSettings.downloadDir + "/"
|
||||
+ sanitizeFileName(bookData.title) + "." + ext
|
||||
page.downloading = true
|
||||
page.downloadError = ""
|
||||
progressBar.value = 0
|
||||
progressBar.maximumValue = format.size > 0 ? format.size : 1
|
||||
downloader.download(resolveUrl(format.url), dest)
|
||||
}
|
||||
|
||||
Component.onCompleted: {
|
||||
if (bookData.coverUrl && bookData.coverUrl.length > 0)
|
||||
apiClient.fetchImage(bookData.coverUrl, "detailcover")
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: apiClient
|
||||
// nota: parametro rinominato (imageId) — "id" è parola chiave QML e rompe il confronto
|
||||
onImageReady: function(imageId, localPath) {
|
||||
if (imageId === "detailcover")
|
||||
page.coverSource = localPath
|
||||
}
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: downloader
|
||||
onProgress: {
|
||||
if (!page.downloading)
|
||||
return
|
||||
progressBar.value = received
|
||||
if (total > 0)
|
||||
progressBar.maximumValue = total
|
||||
}
|
||||
onFinished: {
|
||||
page.downloading = false
|
||||
if (ok) {
|
||||
if (page.readingNow) {
|
||||
page.readingNow = false
|
||||
var id = page.extractBookId(page.readingUrl)
|
||||
pageStack.push(Qt.resolvedUrl("ReaderPage.qml"),
|
||||
{ epubPath: path, bookId: id, bookFormat: "epub" })
|
||||
return
|
||||
}
|
||||
page.downloadError = ""
|
||||
page.downloadNotice = qsTr("Saved to ") + path
|
||||
hideNoticeTimer.restart()
|
||||
} else {
|
||||
page.downloadNotice = ""
|
||||
page.readingNow = false
|
||||
page.downloadError = qsTr("Error: ") + error
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,187 +0,0 @@
|
||||
import QtQuick 2.0
|
||||
import Sailfish.Silica 1.0
|
||||
|
||||
Page {
|
||||
id: page
|
||||
|
||||
property string feedUrl
|
||||
property string pageTitle
|
||||
property string nextUrl: ""
|
||||
property bool loading: false
|
||||
property bool loadingMore: false
|
||||
property string errorMessage: ""
|
||||
|
||||
SilicaListView {
|
||||
id: listView
|
||||
anchors.fill: parent
|
||||
header: PageHeader { title: page.pageTitle }
|
||||
model: feedModel
|
||||
clip: true
|
||||
|
||||
delegate: ListItem {
|
||||
id: item
|
||||
contentHeight: Theme.itemSizeMedium
|
||||
|
||||
Image {
|
||||
id: coverThumb
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: Theme.horizontalPageMargin
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: Theme.itemSizeMedium * 0.68
|
||||
height: Theme.itemSizeMedium * 0.92
|
||||
fillMode: Image.PreserveAspectFit
|
||||
visible: source.length > 0 && !model.isNav
|
||||
source: model.coverSource
|
||||
}
|
||||
|
||||
Column {
|
||||
anchors.left: model.isNav ? parent.left : coverThumb.right
|
||||
anchors.leftMargin: Theme.horizontalPageMargin
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: Theme.horizontalPageMargin
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
|
||||
Label {
|
||||
width: parent.width
|
||||
text: model.entryTitle
|
||||
color: item.highlighted ? Theme.highlightColor : Theme.primaryColor
|
||||
truncationMode: TruncationMode.Elide
|
||||
}
|
||||
Label {
|
||||
width: parent.width
|
||||
text: model.entryAuthors
|
||||
color: Theme.secondaryColor
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
truncationMode: TruncationMode.Elide
|
||||
visible: text.length > 0 && !model.isNav
|
||||
}
|
||||
}
|
||||
|
||||
Icon {
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: Theme.horizontalPageMargin
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
source: "image://theme/icon-m-right"
|
||||
visible: model.isNav
|
||||
}
|
||||
|
||||
onClicked: {
|
||||
if (model.isNav) {
|
||||
pageStack.push(Qt.resolvedUrl("FeedPage.qml"),
|
||||
{ feedUrl: model.entryLink, pageTitle: model.entryTitle })
|
||||
} else {
|
||||
pageStack.push(Qt.resolvedUrl("BookDetailPage.qml"),
|
||||
{ bookData: model.entryData })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
footer: Item {
|
||||
width: parent.width
|
||||
height: page.nextUrl.length > 0 ? Theme.itemSizeSmall : 0
|
||||
BusyIndicator {
|
||||
anchors.centerIn: parent
|
||||
running: page.loadingMore
|
||||
size: BusyIndicatorSize.Small
|
||||
}
|
||||
}
|
||||
|
||||
PullDownMenu {
|
||||
MenuItem {
|
||||
text: qsTr("Refresh")
|
||||
onClicked: reload()
|
||||
}
|
||||
MenuItem {
|
||||
text: qsTr("Settings")
|
||||
onClicked: pageStack.push(Qt.resolvedUrl("SettingsPage.qml"))
|
||||
}
|
||||
}
|
||||
|
||||
ViewPlaceholder {
|
||||
enabled: feedModel.count === 0 && !page.loading
|
||||
text: page.errorMessage.length > 0 ? page.errorMessage : qsTr("No results")
|
||||
}
|
||||
|
||||
BusyIndicator {
|
||||
anchors.centerIn: parent
|
||||
running: page.loading
|
||||
size: BusyIndicatorSize.Large
|
||||
}
|
||||
|
||||
onContentYChanged: tryLoadMore()
|
||||
onCountChanged: tryLoadMore()
|
||||
}
|
||||
|
||||
ListModel {
|
||||
id: feedModel
|
||||
}
|
||||
|
||||
function reload() {
|
||||
feedModel.clear()
|
||||
page.nextUrl = ""
|
||||
loadFeed(page.feedUrl)
|
||||
}
|
||||
|
||||
function loadFeed(url) {
|
||||
page.loading = true
|
||||
page.errorMessage = ""
|
||||
apiClient.getFeed(url)
|
||||
}
|
||||
|
||||
function loadMore() {
|
||||
page.loadingMore = true
|
||||
apiClient.getFeed(page.nextUrl)
|
||||
}
|
||||
|
||||
function tryLoadMore() {
|
||||
if (page.nextUrl.length === 0 || page.loadingMore || page.loading)
|
||||
return
|
||||
if (listView.contentY + listView.height >= listView.contentHeight - Theme.itemSizeMedium * 2)
|
||||
loadMore()
|
||||
}
|
||||
|
||||
onNextUrlChanged: tryLoadMore()
|
||||
|
||||
Component.onCompleted: loadFeed(feedUrl)
|
||||
|
||||
Connections {
|
||||
target: apiClient
|
||||
onFeedReady: {
|
||||
if (pageStack.currentPage !== page)
|
||||
return
|
||||
page.loading = false
|
||||
page.loadingMore = false
|
||||
for (var i = 0; i < entries.length; i++) {
|
||||
var e = entries[i]
|
||||
var isNav = e.isNavigation
|
||||
feedModel.append({
|
||||
entryTitle: e.title,
|
||||
entryAuthors: e.authors,
|
||||
entryLink: e.linkUrl,
|
||||
isNav: isNav,
|
||||
entryData: e,
|
||||
coverSource: "",
|
||||
coverId: e.id
|
||||
})
|
||||
if (!isNav && e.coverUrl.length > 0)
|
||||
apiClient.fetchImage(e.coverUrl, e.id)
|
||||
}
|
||||
page.nextUrl = nextUrl
|
||||
if (feedModel.count === 0 && page.errorMessage.length === 0)
|
||||
page.errorMessage = qsTr("No results")
|
||||
}
|
||||
onFeedError: {
|
||||
page.loading = false
|
||||
page.loadingMore = false
|
||||
page.errorMessage = message
|
||||
}
|
||||
onImageReady: function(imageId, localPath) {
|
||||
for (var i = 0; i < feedModel.count; i++) {
|
||||
if (feedModel.get(i).coverId === imageId) {
|
||||
feedModel.setProperty(i, "coverSource", localPath)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
import QtQuick 2.0
|
||||
import Sailfish.Silica 1.0
|
||||
|
||||
Page {
|
||||
id: page
|
||||
|
||||
property bool loading: false
|
||||
property string errorMessage: ""
|
||||
|
||||
SilicaListView {
|
||||
id: listView
|
||||
anchors.fill: parent
|
||||
header: PageHeader { title: qsTr("Calibre Web") }
|
||||
model: sectionModel
|
||||
|
||||
delegate: ListItem {
|
||||
id: sectionItem
|
||||
contentHeight: Theme.itemSizeMedium
|
||||
|
||||
Label {
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: Theme.horizontalPageMargin
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: model.title
|
||||
color: sectionItem.highlighted ? Theme.highlightColor : Theme.primaryColor
|
||||
}
|
||||
|
||||
Icon {
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: Theme.horizontalPageMargin
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
source: "image://theme/icon-m-right"
|
||||
}
|
||||
|
||||
onClicked: {
|
||||
pageStack.push(Qt.resolvedUrl("FeedPage.qml"),
|
||||
{ feedUrl: model.linkUrl, pageTitle: model.title })
|
||||
}
|
||||
}
|
||||
|
||||
PullDownMenu {
|
||||
MenuItem {
|
||||
text: qsTr("Search")
|
||||
onClicked: pageStack.push(Qt.resolvedUrl("SearchPage.qml"))
|
||||
}
|
||||
MenuItem {
|
||||
text: qsTr("Settings")
|
||||
onClicked: pageStack.push(Qt.resolvedUrl("SettingsPage.qml"))
|
||||
}
|
||||
MenuItem {
|
||||
text: qsTr("Refresh")
|
||||
onClicked: loadIndex()
|
||||
}
|
||||
}
|
||||
|
||||
ViewPlaceholder {
|
||||
enabled: sectionModel.count === 0 && !page.loading
|
||||
text: page.errorMessage.length > 0
|
||||
? page.errorMessage
|
||||
: qsTr("Open the settings and enter your Calibre Web address")
|
||||
}
|
||||
|
||||
BusyIndicator {
|
||||
anchors.centerIn: parent
|
||||
running: page.loading
|
||||
size: BusyIndicatorSize.Large
|
||||
}
|
||||
}
|
||||
|
||||
ListModel {
|
||||
id: sectionModel
|
||||
}
|
||||
|
||||
function loadIndex() {
|
||||
var base = appSettings.baseUrl
|
||||
if (base.length === 0) {
|
||||
page.errorMessage = qsTr("Server not configured")
|
||||
return
|
||||
}
|
||||
page.loading = true
|
||||
page.errorMessage = ""
|
||||
apiClient.getFeed(base + "/opds")
|
||||
}
|
||||
|
||||
Component.onCompleted: loadIndex()
|
||||
|
||||
Connections {
|
||||
target: apiClient
|
||||
onFeedReady: {
|
||||
if (pageStack.currentPage !== page)
|
||||
return
|
||||
page.loading = false
|
||||
sectionModel.clear()
|
||||
for (var i = 0; i < entries.length; i++) {
|
||||
var e = entries[i]
|
||||
if (e.isNavigation)
|
||||
sectionModel.append({ title: e.title, linkUrl: e.linkUrl })
|
||||
}
|
||||
if (sectionModel.count === 0)
|
||||
page.errorMessage = qsTr("No sections found in the OPDS feed")
|
||||
}
|
||||
onFeedError: {
|
||||
page.loading = false
|
||||
page.errorMessage = message
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
import QtQuick 2.0
|
||||
import Sailfish.Silica 1.0
|
||||
|
||||
// Regola i margini del testo nel lettore senza uscire dal libro.
|
||||
// Anteprima LIVE: ogni variazione degli slider viene inoltrata al lettore via
|
||||
// previewHandler (runJavaScript sul documento aperto), quindi il testo si
|
||||
// adatta subito. I valori vengono persistiti solo all'accettazione.
|
||||
Dialog {
|
||||
id: dialog
|
||||
|
||||
property int marginLr: 24
|
||||
property int marginTb: 0
|
||||
// funzione chiamata a ogni variazione: previewHandler(marginLr, marginTb)
|
||||
property var previewHandler: null
|
||||
|
||||
function preview() {
|
||||
if (dialog.previewHandler)
|
||||
dialog.previewHandler(dialog.marginLr, dialog.marginTb)
|
||||
}
|
||||
|
||||
Column {
|
||||
width: parent.width
|
||||
|
||||
DialogHeader {
|
||||
title: qsTr("Text margins")
|
||||
acceptText: qsTr("Apply")
|
||||
}
|
||||
|
||||
Slider {
|
||||
id: lrSlider
|
||||
width: parent.width
|
||||
label: qsTr("Side margins")
|
||||
minimumValue: 0
|
||||
maximumValue: 80
|
||||
stepSize: 4
|
||||
value: dialog.marginLr
|
||||
valueText: value + " px"
|
||||
onValueChanged: {
|
||||
dialog.marginLr = value
|
||||
dialog.preview()
|
||||
}
|
||||
}
|
||||
|
||||
Slider {
|
||||
id: tbSlider
|
||||
width: parent.width
|
||||
label: qsTr("Top and bottom")
|
||||
minimumValue: 0
|
||||
maximumValue: 80
|
||||
stepSize: 4
|
||||
value: dialog.marginTb
|
||||
valueText: value + " px"
|
||||
onValueChanged: {
|
||||
dialog.marginTb = value
|
||||
dialog.preview()
|
||||
}
|
||||
}
|
||||
|
||||
Label {
|
||||
x: Theme.horizontalPageMargin
|
||||
width: parent.width - 2 * Theme.horizontalPageMargin
|
||||
text: qsTr("Changes apply immediately. Confirm to save them for all books.")
|
||||
color: Theme.secondaryColor
|
||||
wrapMode: Text.Wrap
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
}
|
||||
}
|
||||
|
||||
onAccepted: {
|
||||
appSettings.readerMargin = dialog.marginLr
|
||||
appSettings.readerMarginV = dialog.marginTb
|
||||
}
|
||||
}
|
||||
@@ -1,382 +0,0 @@
|
||||
import QtQuick 2.0
|
||||
import Sailfish.Silica 1.0
|
||||
import Sailfish.WebView 1.0
|
||||
import harbour.calibreweb 1.0
|
||||
|
||||
Page {
|
||||
id: page
|
||||
|
||||
property string epubPath: ""
|
||||
property int bookId: 0
|
||||
property string bookFormat: "epub"
|
||||
|
||||
property int currentChapter: 0
|
||||
property double chapterFraction: 0.0
|
||||
property bool ready: false
|
||||
property bool restoring: false
|
||||
property int saveCounter: 0
|
||||
property string errorMessage: ""
|
||||
// reflow: scala il font (il testo si ri-avvolge), non lo zoom grafico
|
||||
property double fontScale: 1.0
|
||||
|
||||
allowedOrientations: Orientation.All
|
||||
|
||||
EpubBook {
|
||||
id: book
|
||||
}
|
||||
|
||||
SilicaFlickable {
|
||||
anchors.fill: parent
|
||||
contentHeight: parent.height
|
||||
|
||||
// il PullDownMenu va DENTRO una vista Silica, non a livello di Page
|
||||
PullDownMenu {
|
||||
MenuItem {
|
||||
text: qsTr("Table of contents")
|
||||
onClicked: showChapterList()
|
||||
}
|
||||
MenuItem {
|
||||
text: qsTr("Text margins")
|
||||
onClicked: {
|
||||
var dlg = pageStack.push(Qt.resolvedUrl("MarginDialog.qml"), {
|
||||
marginLr: appSettings.readerMargin,
|
||||
marginTb: appSettings.readerMarginV,
|
||||
previewHandler: page.previewMargins
|
||||
})
|
||||
dlg.accepted.connect(function() {
|
||||
// i valori sono già persistiti da onAccepted; rinfresca subito
|
||||
applyMarginsJS()
|
||||
})
|
||||
}
|
||||
}
|
||||
MenuItem {
|
||||
text: qsTr("Settings")
|
||||
onClicked: pageStack.push(Qt.resolvedUrl("SettingsPage.qml"))
|
||||
}
|
||||
MenuItem {
|
||||
text: qsTr("Close book")
|
||||
onClicked: {
|
||||
page.maybeSave(true)
|
||||
// differito: pop() subito dall'handler della pagina che viene
|
||||
// distrutta crasha; pop(null) = torna alla prima pagina
|
||||
closeBookTimer.start()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// controlli del lettore: un solo trascinamento in alto li apre
|
||||
PushUpMenu {
|
||||
id: readerMenu
|
||||
|
||||
Label {
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
width: parent.width - 2 * Theme.horizontalPageMargin
|
||||
text: (book.chapterCount > 0)
|
||||
? (page.currentChapter + 1) + "/" + book.chapterCount
|
||||
+ " · " + book.chapterTitle(page.currentChapter)
|
||||
+ " · " + Math.round(page.overallProgress() * 100) + "%"
|
||||
: "…"
|
||||
truncationMode: TruncationMode.Elide
|
||||
color: Theme.primaryColor
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
}
|
||||
|
||||
Row {
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
spacing: Theme.paddingSmall
|
||||
IconButton {
|
||||
icon.source: "image://theme/icon-m-left"
|
||||
enabled: page.currentChapter > 0
|
||||
onClicked: gotoChapter(page.currentChapter - 1)
|
||||
}
|
||||
Button {
|
||||
text: "A−"
|
||||
width: Theme.itemSizeSmall
|
||||
height: Theme.itemSizeMedium
|
||||
onClicked: page.changeFontScale(-0.15)
|
||||
}
|
||||
Button {
|
||||
text: "A+"
|
||||
width: Theme.itemSizeSmall
|
||||
height: Theme.itemSizeMedium
|
||||
onClicked: page.changeFontScale(0.15)
|
||||
}
|
||||
IconButton {
|
||||
icon.source: "image://theme/icon-m-right"
|
||||
enabled: page.currentChapter < book.chapterCount - 1
|
||||
onClicked: gotoChapter(page.currentChapter + 1)
|
||||
}
|
||||
}
|
||||
|
||||
ProgressBar {
|
||||
width: parent.width
|
||||
value: page.overallProgress()
|
||||
minimumValue: 0
|
||||
maximumValue: 1
|
||||
}
|
||||
}
|
||||
|
||||
WebView {
|
||||
id: web
|
||||
anchors.fill: parent
|
||||
active: true
|
||||
onLoadedChanged: {
|
||||
if (!loaded)
|
||||
return
|
||||
loadWatchdog.stop()
|
||||
page.ready = true
|
||||
// ripristina la posizione in frazione sull'altezza ricalcolata
|
||||
if (page.chapterFraction > 0.01) {
|
||||
web.runJavaScript(
|
||||
"window.scrollTo(0," + page.chapterFraction
|
||||
+ "*Math.max(1,document.documentElement.scrollHeight-window.innerHeight));true",
|
||||
function() { })
|
||||
}
|
||||
page.restoring = false
|
||||
}
|
||||
}
|
||||
|
||||
// Paginazione a tap: niente scroll libero. La meta' destra va avanti di
|
||||
// una pagina, la sinistra indietro. Le fasce superiore e inferiore sono
|
||||
// lasciate ai menu (PullDown in alto, PushUp in basso).
|
||||
MouseArea {
|
||||
id: tapZone
|
||||
anchors.fill: parent
|
||||
anchors.topMargin: Theme.itemSizeLarge
|
||||
anchors.bottomMargin: Theme.itemSizeLarge + Theme.paddingLarge
|
||||
onClicked: {
|
||||
if (mouse.x > width / 2)
|
||||
page.nextPage()
|
||||
else
|
||||
page.prevPage()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
BusyIndicator {
|
||||
anchors.centerIn: parent
|
||||
running: !page.ready
|
||||
size: BusyIndicatorSize.Large
|
||||
}
|
||||
|
||||
// watchdog: se il capitolo non si carica, niente spinner infinito
|
||||
Timer {
|
||||
id: loadWatchdog
|
||||
interval: 15000
|
||||
onTriggered: {
|
||||
if (!web.loaded && !page.ready) {
|
||||
page.errorMessage = qsTr("The reader is not responding (WebView). The EPUB file may be damaged.")
|
||||
page.ready = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ritardo di apertura: evita la race tra onCompleted e l'init del WebView
|
||||
Timer {
|
||||
id: openTimer
|
||||
interval: 100
|
||||
onTriggered: openChapter()
|
||||
}
|
||||
|
||||
// chiusura libro differita (pop dalla MainPage, non dall'handler del ReaderPage)
|
||||
Timer {
|
||||
id: closeBookTimer
|
||||
interval: 50
|
||||
onTriggered: pageStack.pop(null)
|
||||
}
|
||||
|
||||
Label {
|
||||
anchors.centerIn: parent
|
||||
text: page.errorMessage
|
||||
color: Theme.errorColor
|
||||
visible: page.errorMessage.length > 0
|
||||
}
|
||||
|
||||
// ---- controlli spostati nel PushUpMenu (trascina in alto) ----
|
||||
|
||||
// ---- timer di tracciamento ----
|
||||
Timer {
|
||||
id: trackTimer
|
||||
interval: 2000
|
||||
running: page.ready
|
||||
repeat: true
|
||||
onTriggered: readScrollPosition()
|
||||
}
|
||||
|
||||
function overallProgress() {
|
||||
if (book.chapterCount <= 0)
|
||||
return 0
|
||||
return (page.currentChapter + page.chapterFraction) / book.chapterCount
|
||||
}
|
||||
|
||||
function readScrollPosition() {
|
||||
if (!web.loaded)
|
||||
return
|
||||
web.runJavaScript(
|
||||
"window.scrollY / Math.max(1, document.documentElement.scrollHeight - window.innerHeight)",
|
||||
function(f) {
|
||||
var v = Math.max(0, Math.min(1, f))
|
||||
if (Math.abs(v - page.chapterFraction) > 0.005) {
|
||||
page.chapterFraction = v
|
||||
maybeSave(true)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function maybeSave(force) {
|
||||
if (bookId <= 0)
|
||||
return
|
||||
page.saveCounter++
|
||||
// salva in locale ogni ~10 s (o subito su forzatura)
|
||||
if (force || page.saveCounter % 5 === 0) {
|
||||
appSettings.setBookmarkKey(page.bookId, page.bookFormat,
|
||||
book.makeBookmarkKey(page.currentChapter, page.chapterFraction))
|
||||
}
|
||||
}
|
||||
|
||||
function gotoChapter(index) {
|
||||
if (index < 0 || index >= book.chapterCount)
|
||||
return
|
||||
if (index !== page.currentChapter) {
|
||||
maybeSave(true)
|
||||
page.chapterFraction = 0
|
||||
}
|
||||
page.currentChapter = index
|
||||
loadChapter()
|
||||
}
|
||||
|
||||
function changeFontScale(delta) {
|
||||
page.fontScale = Math.max(0.7, Math.min(2.5, page.fontScale + delta))
|
||||
// ricarica il capitolo con la nuova dimensione: il reflow avviene al load
|
||||
loadChapter()
|
||||
}
|
||||
|
||||
// pagina successiva: se l'ultima pagina del capitolo, passa al capitolo dopo
|
||||
function nextPage() {
|
||||
if (!web.loaded || !page.ready)
|
||||
return
|
||||
web.runJavaScript(
|
||||
"var vh=window.innerHeight;" +
|
||||
"var cur=Math.floor(window.scrollY/vh);" +
|
||||
"var max=Math.max(0,document.documentElement.scrollHeight-window.innerHeight);" +
|
||||
"var target=(cur+1)*vh;" +
|
||||
"if(target<=max){window.scrollTo(0,target);true}else{false}",
|
||||
function(hasNext) {
|
||||
if (hasNext)
|
||||
readScrollPosition()
|
||||
else
|
||||
gotoChapter(page.currentChapter + 1)
|
||||
})
|
||||
}
|
||||
|
||||
// pagina precedente: se la prima del capitolo, torna al capitolo prima
|
||||
function prevPage() {
|
||||
if (!web.loaded || !page.ready)
|
||||
return
|
||||
web.runJavaScript(
|
||||
"var vh=window.innerHeight;" +
|
||||
"var cur=Math.floor(window.scrollY/vh);" +
|
||||
"if(cur>0){window.scrollTo(0,(cur-1)*vh);true}else{false}",
|
||||
function(hasPrev) {
|
||||
if (hasPrev)
|
||||
readScrollPosition()
|
||||
else
|
||||
gotoChapter(page.currentChapter - 1)
|
||||
})
|
||||
}
|
||||
|
||||
function fontPx() {
|
||||
return Math.round(16 * page.fontScale * 100) / 100
|
||||
}
|
||||
|
||||
// carica il capitolo corrente via loadHtml: lo stile di reflow (font-size
|
||||
// e margini) è iniettato nell'HTML dal parser, quindi niente JS post-load
|
||||
function loadChapter() {
|
||||
var html = book.chapterHtml(page.currentChapter, page.fontPx(),
|
||||
appSettings.readerMargin, appSettings.readerMarginV)
|
||||
if (html.length === 0) {
|
||||
page.errorMessage = qsTr("Chapter unreadable")
|
||||
page.ready = true
|
||||
return
|
||||
}
|
||||
page.ready = false
|
||||
page.restoring = true
|
||||
loadWatchdog.restart()
|
||||
web.loadHtml(html, book.chapterBaseUrl(page.currentChapter))
|
||||
}
|
||||
|
||||
function showChapterList() {
|
||||
var dialog = pageStack.push(Qt.resolvedUrl("ChapterListDialog.qml"),
|
||||
{ model: book.chapterList() })
|
||||
dialog.accepted.connect(function() {
|
||||
gotoChapter(dialog.selectedIndex)
|
||||
})
|
||||
}
|
||||
|
||||
// JS che sovrascrive i margini sul documento aperto: stile inline con
|
||||
// 'important' batte lo stylesheet iniettato. Effetto immediato, niente reload.
|
||||
function marginJs(lr, tb) {
|
||||
return "var h=document.documentElement,b=document.body;"
|
||||
+ "h.style.setProperty('margin-left','" + lr + "px','important');"
|
||||
+ "h.style.setProperty('margin-right','" + lr + "px','important');"
|
||||
+ "b.style.setProperty('margin-left','" + lr + "px','important');"
|
||||
+ "b.style.setProperty('margin-right','" + lr + "px','important');"
|
||||
+ "b.style.setProperty('margin-top','" + tb + "px','important');"
|
||||
+ "b.style.setProperty('margin-bottom','" + tb + "px','important');"
|
||||
+ "true"
|
||||
}
|
||||
|
||||
// riapplica i margini salvati nelle impostazioni (senza ricaricare il libro)
|
||||
function applyMarginsJS() {
|
||||
if (!web.loaded || !page.ready)
|
||||
return
|
||||
web.runJavaScript(marginJs(appSettings.readerMargin, appSettings.readerMarginV),
|
||||
function() { })
|
||||
}
|
||||
|
||||
// anteprima live dal dialogo margini: valori non ancora persistiti
|
||||
function previewMargins(lr, tb) {
|
||||
if (!web.loaded || !page.ready)
|
||||
return
|
||||
web.runJavaScript(marginJs(lr, tb), function() { })
|
||||
}
|
||||
|
||||
Component.onCompleted: {
|
||||
page.ready = false
|
||||
if (!book.open(page.epubPath)) {
|
||||
page.errorMessage = qsTr("Unable to open the EPUB file")
|
||||
page.ready = true
|
||||
return
|
||||
}
|
||||
openTimer.start()
|
||||
}
|
||||
|
||||
function openChapter() {
|
||||
// ripristina il segno locale
|
||||
var key = appSettings.bookmarkKey(page.bookId, page.bookFormat)
|
||||
var ch = 0
|
||||
var fr = 0.0
|
||||
if (key.length > 0) {
|
||||
var r = book.parseBookmarkKey(key)
|
||||
if (r && r.hasOwnProperty("chapter")) {
|
||||
ch = Math.max(0, Math.min(r.chapter, book.chapterCount - 1))
|
||||
fr = r.fraction
|
||||
}
|
||||
}
|
||||
page.currentChapter = ch
|
||||
page.chapterFraction = fr
|
||||
loadChapter()
|
||||
}
|
||||
|
||||
onStatusChanged: {
|
||||
if (status === PageStatus.Inactive) {
|
||||
maybeSave(true)
|
||||
} else if (status === PageStatus.Active) {
|
||||
// riapplica i margini se cambiati nelle impostazioni:
|
||||
// via JS sul documento aperto, senza ricaricare il capitolo
|
||||
if (page.ready && book.chapterCount() > 0)
|
||||
applyMarginsJS()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
import QtQuick 2.0
|
||||
import Sailfish.Silica 1.0
|
||||
|
||||
Page {
|
||||
id: page
|
||||
|
||||
SilicaFlickable {
|
||||
anchors.fill: parent
|
||||
contentHeight: column.height
|
||||
|
||||
Column {
|
||||
id: column
|
||||
width: parent.width
|
||||
|
||||
PageHeader { title: qsTr("Search") }
|
||||
|
||||
SearchField {
|
||||
id: searchField
|
||||
width: parent.width
|
||||
placeholderText: qsTr("Title or author…")
|
||||
EnterKey.enabled: text.length > 0
|
||||
EnterKey.onClicked: search()
|
||||
}
|
||||
|
||||
Button {
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
text: qsTr("Search")
|
||||
enabled: searchField.text.length > 0
|
||||
onClicked: search()
|
||||
}
|
||||
|
||||
Label {
|
||||
x: Theme.horizontalPageMargin
|
||||
width: parent.width - 2 * Theme.horizontalPageMargin
|
||||
text: qsTr("Search uses the server engine (OPDS search).")
|
||||
color: Theme.secondaryColor
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
wrapMode: Text.Wrap
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function search() {
|
||||
var base = appSettings.baseUrl
|
||||
var query = searchField.text.trim()
|
||||
if (base.length === 0 || query.length === 0)
|
||||
return
|
||||
var url = base + "/opds/search?query=" + encodeURIComponent(query)
|
||||
pageStack.push(Qt.resolvedUrl("FeedPage.qml"),
|
||||
{ feedUrl: url, pageTitle: qsTr("Results: ") + query })
|
||||
}
|
||||
}
|
||||
@@ -1,189 +0,0 @@
|
||||
import QtQuick 2.0
|
||||
import Sailfish.Silica 1.0
|
||||
|
||||
Page {
|
||||
id: page
|
||||
|
||||
property string testResult: ""
|
||||
property bool testOk: false
|
||||
property bool testing: false
|
||||
property string languageNotice: ""
|
||||
|
||||
SilicaFlickable {
|
||||
anchors.fill: parent
|
||||
contentHeight: column.height
|
||||
|
||||
Column {
|
||||
id: column
|
||||
width: parent.width
|
||||
|
||||
PageHeader { title: qsTr("Settings") }
|
||||
|
||||
SectionHeader { text: qsTr("Server") }
|
||||
|
||||
TextField {
|
||||
id: serverField
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
label: qsTr("Calibre Web address")
|
||||
placeholderText: "https://calibre.example.com"
|
||||
text: appSettings.serverUrl
|
||||
inputMethodHints: Qt.ImhUrlCharactersOnly
|
||||
onTextChanged: appSettings.serverUrl = text
|
||||
}
|
||||
|
||||
TextField {
|
||||
id: userField
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
label: qsTr("User (empty = anonymous access)")
|
||||
text: appSettings.username
|
||||
onTextChanged: appSettings.username = text
|
||||
}
|
||||
|
||||
TextField {
|
||||
id: passField
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
label: qsTr("Password")
|
||||
text: appSettings.password
|
||||
echoMode: TextInput.Password
|
||||
onTextChanged: appSettings.password = text
|
||||
}
|
||||
|
||||
TextSwitch {
|
||||
text: qsTr("Ignore SSL certificate errors")
|
||||
description: qsTr("Enable if the server uses a self-signed certificate")
|
||||
checked: appSettings.ignoreSslErrors
|
||||
onCheckedChanged: appSettings.ignoreSslErrors = checked
|
||||
}
|
||||
|
||||
SectionHeader { text: qsTr("Reader") }
|
||||
|
||||
Slider {
|
||||
id: marginSlider
|
||||
width: parent.width
|
||||
label: qsTr("Side margins")
|
||||
minimumValue: 0
|
||||
maximumValue: 80
|
||||
stepSize: 4
|
||||
value: appSettings.readerMargin
|
||||
valueText: value + " px"
|
||||
onValueChanged: appSettings.readerMargin = value
|
||||
}
|
||||
|
||||
Slider {
|
||||
id: marginVSlider
|
||||
width: parent.width
|
||||
label: qsTr("Top and bottom margin")
|
||||
minimumValue: 0
|
||||
maximumValue: 80
|
||||
stepSize: 4
|
||||
value: appSettings.readerMarginV
|
||||
valueText: value + " px"
|
||||
onValueChanged: appSettings.readerMarginV = value
|
||||
}
|
||||
|
||||
SectionHeader { text: qsTr("Connection") }
|
||||
|
||||
Button {
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
text: qsTr("Test connection")
|
||||
enabled: !page.testing
|
||||
onClicked: testConnection()
|
||||
}
|
||||
|
||||
Item {
|
||||
width: parent.width
|
||||
height: page.testing ? Theme.itemSizeSmall : 0
|
||||
BusyIndicator {
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
running: page.testing
|
||||
size: BusyIndicatorSize.Small
|
||||
}
|
||||
}
|
||||
|
||||
Label {
|
||||
x: Theme.horizontalPageMargin
|
||||
width: parent.width - 2 * Theme.horizontalPageMargin
|
||||
text: page.testResult
|
||||
color: page.testOk ? Theme.secondaryHighlightColor : Theme.errorColor
|
||||
wrapMode: Text.Wrap
|
||||
visible: text.length > 0
|
||||
}
|
||||
|
||||
SectionHeader { text: qsTr("Language") }
|
||||
|
||||
ComboBox {
|
||||
id: langBox
|
||||
width: parent.width
|
||||
label: qsTr("Language")
|
||||
currentIndex: appSettings.language === "it" ? 2 : (appSettings.language === "en" ? 1 : 0)
|
||||
menu: ContextMenu {
|
||||
MenuItem { text: qsTr("System (default)") }
|
||||
MenuItem { text: "English" }
|
||||
MenuItem { text: "Italiano" }
|
||||
}
|
||||
onCurrentIndexChanged: {
|
||||
var v = currentIndex === 2 ? "it" : (currentIndex === 1 ? "en" : "system")
|
||||
if (v !== appSettings.language) {
|
||||
appSettings.language = v
|
||||
page.languageNotice = qsTr("Language will be applied after restarting the app")
|
||||
langNoticeTimer.restart()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Label {
|
||||
x: Theme.horizontalPageMargin
|
||||
width: parent.width - 2 * Theme.horizontalPageMargin
|
||||
text: page.languageNotice
|
||||
color: Theme.highlightColor
|
||||
wrapMode: Text.Wrap
|
||||
visible: text.length > 0
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: langNoticeTimer
|
||||
interval: 4000
|
||||
onTriggered: page.languageNotice = ""
|
||||
}
|
||||
|
||||
Item { width: 1; height: Theme.paddingLarge }
|
||||
}
|
||||
}
|
||||
|
||||
function testConnection() {
|
||||
var base = appSettings.serverUrl.trim()
|
||||
if (base.length === 0) {
|
||||
page.testResult = qsTr("Enter the server address")
|
||||
page.testOk = false
|
||||
return
|
||||
}
|
||||
// normalizza: senza schema aggiunge https:// e lo salva nel campo
|
||||
if (base.indexOf("http://") !== 0 && base.indexOf("https://") !== 0)
|
||||
base = "https://" + base
|
||||
appSettings.serverUrl = base
|
||||
page.testing = true
|
||||
page.testResult = qsTr("Testing…")
|
||||
apiClient.getFeed(base + "/opds")
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: apiClient
|
||||
onFeedReady: {
|
||||
if (pageStack.currentPage !== page)
|
||||
return
|
||||
page.testing = false
|
||||
page.testOk = true
|
||||
page.testResult = qsTr("Connection OK: %1 sections found").arg(entries.length)
|
||||
}
|
||||
onFeedError: {
|
||||
if (pageStack.currentPage !== page)
|
||||
return
|
||||
page.testing = false
|
||||
page.testOk = false
|
||||
page.testResult = message
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -1,106 +0,0 @@
|
||||
Name: harbour-calibreweb
|
||||
Summary: Client OPDS per Calibre Web
|
||||
Version: 0.2.0
|
||||
Release: 1
|
||||
Group: Qt/Qt
|
||||
License: MIT
|
||||
URL: https://github.com/kaneda/harbour-calibreweb
|
||||
Source0: %{name}-%{version}.tar.gz
|
||||
Source1: %{name}.desktop
|
||||
|
||||
Requires: sailfishsilica-qt5 >= 0.10.9
|
||||
BuildRequires: pkgconfig(sailfishapp) >= 1.0.2
|
||||
BuildRequires: pkgconfig(Qt5Core)
|
||||
BuildRequires: pkgconfig(Qt5Qml)
|
||||
BuildRequires: pkgconfig(Qt5Quick)
|
||||
BuildRequires: desktop-file-utils
|
||||
|
||||
%description
|
||||
Client per navigare e scaricare libri da un'istanza Calibre Web
|
||||
(o dal Content Server di Calibre) tramite il protocollo OPDS.
|
||||
Supporta autenticazione di base, ricerca, copertine, download
|
||||
in tutti i formati disponibili e lettura EPUB integrata con
|
||||
reflow del testo, margini configurabili e posizione salvata
|
||||
in locale.
|
||||
|
||||
%changelog
|
||||
* Wed Aug 19 2026 Carlo Baratto <carlo@carlobaratto.it> - 0.2.0-1
|
||||
- Sandbox SailJail (Sailfish 4.4+/5.x): profilo X-Sailjail esplicito nel desktop
|
||||
file (Internet;Downloads, org harbour/app calibreweb) e config in
|
||||
AppConfigLocation (~/.config/harbour/calibreweb/calibreweb.conf, l'unica zona
|
||||
persistente della sandbox). Senza profilo la sandbox nascondeva ~/.config/harbour
|
||||
e le impostazioni dal launcher sparivano (config solo funzionante da SSH).
|
||||
Migrazione automatica dal vecchio file per-utente.
|
||||
- Fix crash 'Close book': pop(null) differito con Timer (distruggere la pagina
|
||||
dall'handler del suo MenuItem crashava)
|
||||
- Config condivisa nemo/defaultuser: permessi aperti su /home/nemo/.config/harbour
|
||||
e sul file a ogni scrittura (il launcher gira come nemo, il debug SSH come
|
||||
defaultuser: prima le impostazioni salvate da un lato sparivano dall'altro)
|
||||
- Selettore lingua nelle impostazioni (System/English/Italiano, applicata al
|
||||
riavvio; QTranslator in main.cpp, pattern quill). Catalogo .qm nel data dir
|
||||
dell'app (/usr/share/harbour-calibreweb/translations, il validatore RPM
|
||||
rifiuta /usr/share/translations)
|
||||
- Localizzazione: lingua di default inglese (qsTr ovunque), catalogo
|
||||
italiano harbour-calibreweb_it.ts/.qm nel data dir dell'app
|
||||
- Fix configurazione persa: file di configurazione unico e fisso
|
||||
(/home/nemo/.config/harbour/calibreweb.conf) indipendente dall'utente
|
||||
che avvia l'app (launcher nemo / debug SSH defaultuser), con migrazione
|
||||
automatica dei valori gia' salvati
|
||||
- Config auto-adattiva: la home grafica su Jolla Phone 2026 (Sailfish 5.1)
|
||||
e' /home/rootitanium (non nemo): candidate rootitanium -> nemo -> home
|
||||
dell'utente corrente, file esistente prima, poi prima home scrivibile;
|
||||
permessi aperti sulla home scelta a ogni scrittura
|
||||
- Lettore EPUB nativo (WebView): capitoli, reflow A-/A+, margini
|
||||
configurabili, posizione salvata in locale (QSettings) e ripristinata
|
||||
all'apertura
|
||||
- Parser EPUB in C++ (ZIP minimale + container/OPF/spine, TOC EPUB2+3)
|
||||
- Rimossa la sync server dell'avanzamento (route web con login di
|
||||
sessione): resta solo il segno locale
|
||||
- URL risolti RFC 3986 (sottopath /calibre non piu raddoppiato),
|
||||
copertine validate prima del cache, pagine corrette per Silica 5.1
|
||||
- Icone in struttura standard hicolor
|
||||
|
||||
%prep
|
||||
%setup -q -n %{name}-%{version}
|
||||
|
||||
%build
|
||||
%qmake5
|
||||
make %{?_smp_mflags}
|
||||
|
||||
# Translation catalogue: generated explicitly with lrelease (the sailfishapp_i18n
|
||||
# template does not produce the .qm reliably on the 5.1 kit). The .ts lives in
|
||||
# the source tree; sfdk uses the sources live (shadow build), so locate it
|
||||
# across the possible layouts.
|
||||
if [ -f %{_builddir}/../%{name}-%{version}/translations/%{name}_it.ts ]; then
|
||||
TS=%{_builddir}/../%{name}-%{version}/translations/%{name}_it.ts
|
||||
elif [ -f %{_builddir}/%{name}-%{version}/translations/%{name}_it.ts ]; then
|
||||
TS=%{_builddir}/%{name}-%{version}/translations/%{name}_it.ts
|
||||
elif [ -f %{_builddir}/translations/%{name}_it.ts ]; then
|
||||
TS=%{_builddir}/translations/%{name}_it.ts
|
||||
else
|
||||
echo "ERROR: %{name}_it.ts not found" >&2
|
||||
exit 1
|
||||
fi
|
||||
lrelease "$TS" -qm %{_builddir}/%{name}_it.qm
|
||||
|
||||
%install
|
||||
make install INSTALL_ROOT=%{buildroot}
|
||||
mkdir -p %{buildroot}%{_datadir}/applications
|
||||
install -m 0644 %{SOURCE1} %{buildroot}%{_datadir}/applications/%{name}.desktop
|
||||
desktop-file-validate %{buildroot}%{_datadir}/applications/%{name}.desktop || true
|
||||
|
||||
# Italian translation: installed in the app data dir (the RPM validator
|
||||
# rejects /usr/share/translations); main.cpp loads it from there.
|
||||
mkdir -p %{buildroot}%{_datadir}/%{name}/translations
|
||||
install -m 0644 %{_builddir}/%{name}_it.qm %{buildroot}%{_datadir}/%{name}/translations/
|
||||
|
||||
%files
|
||||
%defattr(-,root,root,-)
|
||||
%{_bindir}/%{name}
|
||||
%{_datadir}/applications/%{name}.desktop
|
||||
%{_datadir}/icons/hicolor/86x86/apps/%{name}.png
|
||||
%{_datadir}/icons/hicolor/108x108/apps/%{name}.png
|
||||
%{_datadir}/icons/hicolor/128x128/apps/%{name}.png
|
||||
%{_datadir}/icons/hicolor/172x172/apps/%{name}.png
|
||||
%{_datadir}/%{name}/qml
|
||||
%{_datadir}/%{name}/translations/%{name}*.qm
|
||||
@@ -1,15 +0,0 @@
|
||||
Name: harbour-calibreweb
|
||||
Summary: Client OPDS per Calibre Web
|
||||
Version: 0.1.0
|
||||
Release: 1
|
||||
Group: Qt/Qt
|
||||
License: MIT
|
||||
URL: https://github.com/kaneda/harbour-calibreweb
|
||||
Description: |
|
||||
Client per navigare e scaricare libri da un'istanza Calibre Web
|
||||
(o dal Content Server di Calibre) tramite il protocollo OPDS.
|
||||
Supporta autenticazione di base, ricerca, copertine, lettore EPUB
|
||||
e download in tutti i formati disponibili.
|
||||
Sources:
|
||||
- '%{name}-%{version}.tar.gz'
|
||||
Builder: qmake5
|
||||
@@ -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());
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
#ifndef DOWNLOADER_H
|
||||
#define DOWNLOADER_H
|
||||
|
||||
#include <QHash>
|
||||
#include <QNetworkAccessManager>
|
||||
#include <QObject>
|
||||
|
||||
class QFile;
|
||||
class QNetworkReply;
|
||||
class Settings;
|
||||
|
||||
// Download di file (libri) con segnali di progresso.
|
||||
// Supporta più download concorrenti.
|
||||
class Downloader : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit Downloader(Settings *settings, QObject *parent = 0);
|
||||
|
||||
Q_INVOKABLE void download(const QString &url, const QString &destPath);
|
||||
|
||||
signals:
|
||||
void progress(qint64 received, qint64 total, const QString &path);
|
||||
void finished(const QString &path, bool ok, const QString &error);
|
||||
|
||||
private:
|
||||
Settings *m_settings;
|
||||
QNetworkAccessManager m_nam;
|
||||
QHash<QNetworkReply *, QFile *> m_files;
|
||||
QHash<QNetworkReply *, QString> m_paths;
|
||||
};
|
||||
|
||||
#endif // DOWNLOADER_H
|
||||
-464
@@ -1,464 +0,0 @@
|
||||
#include "epub.h"
|
||||
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QFileInfo>
|
||||
#include <QHash>
|
||||
#include <QStringList>
|
||||
#include <QUrl>
|
||||
#include <QXmlStreamReader>
|
||||
|
||||
#include <zlib.h>
|
||||
#include <cstring>
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lettore ZIP minimale (solo STORE e DEFLATE, niente crittografia)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
namespace {
|
||||
|
||||
struct ZipEntry
|
||||
{
|
||||
QString name;
|
||||
quint16 method;
|
||||
quint32 compSize;
|
||||
quint32 uncompSize;
|
||||
quint32 localOffset;
|
||||
};
|
||||
|
||||
quint16 le16(const QByteArray &d, int off)
|
||||
{
|
||||
return (quint8)d.at(off) | ((quint8)d.at(off + 1) << 8);
|
||||
}
|
||||
|
||||
quint32 le32(const QByteArray &d, int off)
|
||||
{
|
||||
return (quint32)(quint8)d.at(off)
|
||||
| ((quint32)(quint8)d.at(off + 1) << 8)
|
||||
| ((quint32)(quint8)d.at(off + 2) << 16)
|
||||
| ((quint32)(quint8)d.at(off + 3) << 24);
|
||||
}
|
||||
|
||||
bool readZipEntries(const QByteArray &data, QList<ZipEntry> *entries)
|
||||
{
|
||||
// EOCD: cerca la firma PK\x05\x06 negli ultimi 64 KB + commento
|
||||
const int eocdMax = qMin(data.size(), 65557);
|
||||
int eocd = -1;
|
||||
for (int i = data.size() - eocdMax; i <= data.size() - 22; ++i) {
|
||||
if (data.at(i) == 'P' && data.at(i + 1) == 'K'
|
||||
&& data.at(i + 2) == '\x05' && data.at(i + 3) == '\x06') {
|
||||
eocd = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (eocd < 0)
|
||||
return false;
|
||||
|
||||
const quint32 cdOffset = le32(data, eocd + 16);
|
||||
const quint16 cdCount = le16(data, eocd + 10);
|
||||
|
||||
int off = cdOffset;
|
||||
for (int i = 0; i < cdCount; ++i) {
|
||||
if (off + 46 > data.size()
|
||||
|| data.at(off) != 'P' || data.at(off + 1) != 'K'
|
||||
|| data.at(off + 2) != '\x01' || data.at(off + 3) != '\x02')
|
||||
return false;
|
||||
ZipEntry e;
|
||||
e.method = le16(data, off + 10);
|
||||
e.compSize = le32(data, off + 20);
|
||||
e.uncompSize = le32(data, off + 24);
|
||||
const quint16 nameLen = le16(data, off + 28);
|
||||
const quint16 extraLen = le16(data, off + 30);
|
||||
const quint16 commentLen = le16(data, off + 32);
|
||||
e.localOffset = le32(data, off + 42);
|
||||
e.name = QString::fromUtf8(data.mid(off + 46, nameLen));
|
||||
entries->append(e);
|
||||
off += 46 + nameLen + extraLen + commentLen;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
QByteArray extractEntryData(const QByteArray &data, const ZipEntry &e)
|
||||
{
|
||||
if ((int)e.localOffset + 30 > data.size())
|
||||
return QByteArray();
|
||||
const int lo = e.localOffset;
|
||||
if (data.at(lo) != 'P' || data.at(lo + 1) != 'K'
|
||||
|| data.at(lo + 2) != '\x03' || data.at(lo + 3) != '\x04')
|
||||
return QByteArray();
|
||||
const quint16 nameLen = le16(data, lo + 26);
|
||||
const quint16 extraLen = le16(data, lo + 28);
|
||||
const int dataOff = lo + 30 + nameLen + extraLen;
|
||||
if (dataOff + (int)e.compSize > data.size())
|
||||
return QByteArray();
|
||||
const QByteArray raw = data.mid(dataOff, e.compSize);
|
||||
|
||||
if (e.method == 0) // STORE
|
||||
return raw;
|
||||
|
||||
if (e.method == 8) { // DEFLATE (raw, senza header zlib)
|
||||
QByteArray out;
|
||||
out.resize(e.uncompSize);
|
||||
z_stream zs;
|
||||
std::memset(&zs, 0, sizeof(zs));
|
||||
if (inflateInit2(&zs, -15) != Z_OK)
|
||||
return QByteArray();
|
||||
zs.next_in = reinterpret_cast<Bytef *>(const_cast<char *>(raw.constData()));
|
||||
zs.avail_in = raw.size();
|
||||
zs.next_out = reinterpret_cast<Bytef *>(out.data());
|
||||
zs.avail_out = out.size();
|
||||
const int r = inflate(&zs, Z_FINISH);
|
||||
inflateEnd(&zs);
|
||||
if (r != Z_STREAM_END)
|
||||
return QByteArray();
|
||||
out.resize(zs.total_out);
|
||||
return out;
|
||||
}
|
||||
return QByteArray(); // metodo non supportato
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// EpubBook
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
EpubBook::EpubBook(QObject *parent)
|
||||
: QObject(parent)
|
||||
{
|
||||
}
|
||||
|
||||
EpubBook::~EpubBook()
|
||||
{
|
||||
close();
|
||||
}
|
||||
|
||||
void EpubBook::close()
|
||||
{
|
||||
if (!m_baseDir.isEmpty()) {
|
||||
QDir(m_baseDir).removeRecursively();
|
||||
m_baseDir.clear();
|
||||
}
|
||||
m_chapters.clear();
|
||||
m_title.clear();
|
||||
m_author.clear();
|
||||
}
|
||||
|
||||
bool EpubBook::open(const QString &epubPath)
|
||||
{
|
||||
close();
|
||||
m_opfDir.clear();
|
||||
|
||||
QFile f(epubPath);
|
||||
if (!f.open(QIODevice::ReadOnly))
|
||||
return false;
|
||||
const QByteArray data = f.readAll();
|
||||
f.close();
|
||||
|
||||
QList<ZipEntry> entries;
|
||||
if (!readZipEntries(data, &entries))
|
||||
return false;
|
||||
|
||||
m_baseDir = QDir::tempPath() + QStringLiteral("/harbour-calibreweb/books/")
|
||||
+ QString::number(qHash(epubPath), 16);
|
||||
if (!QDir().mkpath(m_baseDir))
|
||||
return false;
|
||||
|
||||
// estrai tutto (skip directory e path traversal)
|
||||
for (const ZipEntry &e : entries) {
|
||||
if (e.name.endsWith(QLatin1Char('/')) || e.name.contains(QStringLiteral("..")))
|
||||
continue;
|
||||
const QByteArray content = extractEntryData(data, e);
|
||||
if (content.isNull())
|
||||
continue;
|
||||
const QString dest = m_baseDir + QLatin1Char('/') + e.name;
|
||||
QDir().mkpath(QFileInfo(dest).absolutePath());
|
||||
QFile out(dest);
|
||||
if (out.open(QIODevice::WriteOnly)) {
|
||||
out.write(content);
|
||||
out.close();
|
||||
}
|
||||
}
|
||||
|
||||
if (!parseContainer())
|
||||
return false;
|
||||
if (!parseOpf())
|
||||
return false;
|
||||
const bool ok = !m_chapters.isEmpty();
|
||||
emit changed();
|
||||
return ok;
|
||||
}
|
||||
|
||||
bool EpubBook::parseContainer()
|
||||
{
|
||||
QFile f(m_baseDir + QStringLiteral("/META-INF/container.xml"));
|
||||
if (!f.open(QIODevice::ReadOnly))
|
||||
return false;
|
||||
QXmlStreamReader r(&f);
|
||||
while (!r.atEnd() && !r.hasError()) {
|
||||
if (r.readNextStartElement() && r.name() == QLatin1String("rootfile")) {
|
||||
const QString fullPath = r.attributes().value(QLatin1String("full-path")).toString();
|
||||
if (!fullPath.isEmpty()) {
|
||||
// path RELATIVO alla root epub, non assoluto rispetto alla CWD
|
||||
m_opfDir = QFileInfo(fullPath).path();
|
||||
if (m_opfDir == QStringLiteral("."))
|
||||
m_opfDir.clear();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
QString EpubBook::absolutePath(const QString &relativeToOpfDir) const
|
||||
{
|
||||
QString rel = relativeToOpfDir;
|
||||
rel.replace(QLatin1Char('\\'), QLatin1Char('/'));
|
||||
if (rel.contains(QLatin1String("..")))
|
||||
return QString();
|
||||
if (!m_opfDir.isEmpty())
|
||||
rel = m_opfDir + QLatin1Char('/') + rel;
|
||||
return QDir(m_baseDir).filePath(rel);
|
||||
}
|
||||
|
||||
bool EpubBook::parseOpf()
|
||||
{
|
||||
QFile f(absolutePath(QStringLiteral("content.opf")));
|
||||
if (!f.exists())
|
||||
f.setFileName(absolutePath(QStringLiteral("package.opf")));
|
||||
if (!f.open(QIODevice::ReadOnly))
|
||||
return false;
|
||||
|
||||
QXmlStreamReader r(&f);
|
||||
|
||||
// manifest: id -> {href, media-type, properties}
|
||||
QHash<QString, QString> manifestHref;
|
||||
QHash<QString, QString> manifestType;
|
||||
QHash<QString, QString> manifestProps;
|
||||
// spine: idrefs in ordine
|
||||
QStringList spine;
|
||||
QString navItemId;
|
||||
QString ncxItemId;
|
||||
|
||||
while (!r.atEnd() && !r.hasError()) {
|
||||
const QXmlStreamReader::TokenType t = r.readNext();
|
||||
if (t != QXmlStreamReader::StartElement)
|
||||
continue;
|
||||
const QStringRef name = r.name();
|
||||
const QXmlStreamAttributes a = r.attributes();
|
||||
|
||||
if (name == QLatin1String("title") && m_title.isEmpty()) {
|
||||
m_title = r.readElementText().trimmed();
|
||||
} else if (name == QLatin1String("creator") && m_author.isEmpty()) {
|
||||
m_author = r.readElementText().trimmed();
|
||||
} else if (name == QLatin1String("item")) {
|
||||
const QString id = a.value(QLatin1String("id")).toString();
|
||||
manifestHref.insert(id, a.value(QLatin1String("href")).toString());
|
||||
manifestType.insert(id, a.value(QLatin1String("media-type")).toString());
|
||||
const QString props = a.value(QLatin1String("properties")).toString();
|
||||
manifestProps.insert(id, props);
|
||||
if (props.contains(QLatin1String("nav")))
|
||||
navItemId = id;
|
||||
if (a.value(QLatin1String("media-type")).toString()
|
||||
== QLatin1String("application/x-dtbncx+xml"))
|
||||
ncxItemId = id;
|
||||
} else if (name == QLatin1String("itemref")) {
|
||||
spine.append(a.value(QLatin1String("idref")).toString());
|
||||
}
|
||||
}
|
||||
|
||||
if (spine.isEmpty() || manifestHref.isEmpty())
|
||||
return false;
|
||||
|
||||
// TOC: EPUB3 nav oppure EPUB2 NCX
|
||||
if (!navItemId.isEmpty())
|
||||
parseTocEpub3(absolutePath(manifestHref.value(navItemId)));
|
||||
else if (!ncxItemId.isEmpty())
|
||||
parseTocEpub2(absolutePath(manifestHref.value(ncxItemId)));
|
||||
|
||||
// mappa path assoluto -> titolo dal TOC
|
||||
QHash<QString, QString> titleByPath;
|
||||
for (const EpubChapter &c : m_chapters)
|
||||
titleByPath.insert(c.path, c.title);
|
||||
m_chapters.clear();
|
||||
|
||||
// costruisci i capitoli nell'ordine dello spine
|
||||
int n = 0;
|
||||
for (const QString &idref : spine) {
|
||||
const QString href = manifestHref.value(idref);
|
||||
if (href.isEmpty())
|
||||
continue;
|
||||
const QString abs = absolutePath(href);
|
||||
if (abs.isEmpty() || !QFile::exists(abs))
|
||||
continue;
|
||||
EpubChapter c;
|
||||
c.id = idref;
|
||||
c.path = abs;
|
||||
c.title = titleByPath.value(abs);
|
||||
if (c.title.isEmpty())
|
||||
c.title = QStringLiteral("Capitolo ") + QString::number(n + 1);
|
||||
m_chapters.append(c);
|
||||
++n;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void EpubBook::parseTocEpub3(const QString &navPath)
|
||||
{
|
||||
QFile f(navPath);
|
||||
if (!f.open(QIODevice::ReadOnly))
|
||||
return;
|
||||
QXmlStreamReader r(&f);
|
||||
bool inTocNav = false;
|
||||
int depth = 0;
|
||||
while (!r.atEnd() && !r.hasError()) {
|
||||
const QXmlStreamReader::TokenType t = r.readNext();
|
||||
if (t == QXmlStreamReader::StartElement) {
|
||||
const QStringRef name = r.name();
|
||||
if (name == QLatin1String("nav")) {
|
||||
if (!inTocNav) {
|
||||
const QString type = r.attributes()
|
||||
.value(QLatin1String("http://www.idpf.org/2007/ops"),
|
||||
QLatin1String("type")).toString();
|
||||
if (type != QLatin1String("toc"))
|
||||
continue;
|
||||
inTocNav = true;
|
||||
}
|
||||
++depth;
|
||||
continue;
|
||||
}
|
||||
if (!inTocNav || depth == 0)
|
||||
continue;
|
||||
if (name == QLatin1String("a")) {
|
||||
const QString href = r.attributes().value(QLatin1String("href")).toString();
|
||||
const QString text = r.readElementText().trimmed();
|
||||
if (!href.isEmpty() && !text.isEmpty()) {
|
||||
EpubChapter c;
|
||||
c.path = absolutePath(href);
|
||||
c.title = text;
|
||||
m_chapters.append(c);
|
||||
}
|
||||
}
|
||||
} else if (t == QXmlStreamReader::EndElement && r.name() == QLatin1String("nav")) {
|
||||
if (inTocNav && --depth <= 0)
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void EpubBook::parseTocEpub2(const QString &ncxPath)
|
||||
{
|
||||
QFile f(ncxPath);
|
||||
if (!f.open(QIODevice::ReadOnly))
|
||||
return;
|
||||
QXmlStreamReader r(&f);
|
||||
EpubChapter pending;
|
||||
bool havePending = false;
|
||||
while (!r.atEnd() && !r.hasError()) {
|
||||
const QXmlStreamReader::TokenType t = r.readNext();
|
||||
if (t == QXmlStreamReader::StartElement) {
|
||||
const QStringRef name = r.name();
|
||||
if (name == QLatin1String("navPoint")) {
|
||||
// nel NCX il <text> (titolo) viene PRIMA del <content> (src):
|
||||
// accumula in pending e chiudi alla fine del navPoint
|
||||
pending = EpubChapter();
|
||||
havePending = true;
|
||||
} else if (name == QLatin1String("text")) {
|
||||
if (havePending)
|
||||
pending.title = r.readElementText().trimmed();
|
||||
} else if (name == QLatin1String("content")) {
|
||||
const QString src = r.attributes().value(QLatin1String("src")).toString();
|
||||
if (havePending)
|
||||
pending.path = absolutePath(src);
|
||||
}
|
||||
} else if (t == QXmlStreamReader::EndElement
|
||||
&& r.name() == QLatin1String("navPoint")) {
|
||||
if (havePending && !pending.path.isEmpty())
|
||||
m_chapters.append(pending);
|
||||
havePending = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
QString EpubBook::chapterFilePath(int index) const
|
||||
{
|
||||
if (index < 0 || index >= m_chapters.size())
|
||||
return QString();
|
||||
return m_chapters.at(index).path;
|
||||
}
|
||||
|
||||
QString EpubBook::chapterTitle(int index) const
|
||||
{
|
||||
if (index < 0 || index >= m_chapters.size())
|
||||
return QString();
|
||||
return m_chapters.at(index).title;
|
||||
}
|
||||
|
||||
QVariantList EpubBook::chapterList() const
|
||||
{
|
||||
QVariantList list;
|
||||
for (int i = 0; i < m_chapters.size(); ++i) {
|
||||
QVariantMap m;
|
||||
m.insert(QStringLiteral("index"), i);
|
||||
m.insert(QStringLiteral("title"), m_chapters.at(i).title);
|
||||
list.append(m);
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
QString EpubBook::chapterHtml(int index, int fontSizePx, int marginPx, int marginVerticalPx) const
|
||||
{
|
||||
if (index < 0 || index >= m_chapters.size())
|
||||
return QString();
|
||||
QFile f(m_chapters.at(index).path);
|
||||
if (!f.open(QIODevice::ReadOnly))
|
||||
return QString();
|
||||
QString html = QString::fromUtf8(f.readAll());
|
||||
|
||||
// stile di reflow iniettato nel documento: !important vince sui CSS del libro.
|
||||
// I margini si possono aggiornare a caldo via runJavaScript (setProperty con
|
||||
// 'important' inline), senza ricaricare il capitolo.
|
||||
const QString style = QStringLiteral(
|
||||
"<style id=\"cw-font\">html,body{font-size:%1px!important;"
|
||||
"margin-left:%2px!important;margin-right:%2px!important;"
|
||||
"margin-top:%3px!important;margin-bottom:%3px!important}"
|
||||
"p,div,li,td,blockquote,section{font-size:1em!important}</style>")
|
||||
.arg(fontSizePx).arg(marginPx).arg(marginVerticalPx);
|
||||
const int headEnd = html.indexOf(QStringLiteral("</head>"), 0, Qt::CaseInsensitive);
|
||||
if (headEnd >= 0)
|
||||
html.insert(headEnd, style);
|
||||
else
|
||||
html.prepend(style);
|
||||
return html;
|
||||
}
|
||||
|
||||
QString EpubBook::chapterBaseUrl(int index) const
|
||||
{
|
||||
if (index < 0 || index >= m_chapters.size())
|
||||
return QString();
|
||||
return QUrl::fromLocalFile(
|
||||
QFileInfo(m_chapters.at(index).path).absolutePath() + QStringLiteral("/"))
|
||||
.toString();
|
||||
}
|
||||
|
||||
QString EpubBook::makeBookmarkKey(int chapterIndex, double fraction) const
|
||||
{
|
||||
return QStringLiteral("cw:v1:%1:%2").arg(chapterIndex).arg(fraction, 0, 'f', 4);
|
||||
}
|
||||
|
||||
QVariantMap EpubBook::parseBookmarkKey(const QString &key) const
|
||||
{
|
||||
QVariantMap result;
|
||||
const QStringList parts = key.split(QLatin1Char(':'));
|
||||
if (parts.size() != 4 || parts.at(0) != QLatin1String("cw")
|
||||
|| parts.at(1) != QLatin1String("v1"))
|
||||
return result;
|
||||
bool okChapter = false, okFrac = false;
|
||||
const int ch = parts.at(2).toInt(&okChapter);
|
||||
const double fr = parts.at(3).toDouble(&okFrac);
|
||||
if (!okChapter || !okFrac)
|
||||
return result;
|
||||
result.insert(QStringLiteral("chapter"), ch);
|
||||
result.insert(QStringLiteral("fraction"), fr);
|
||||
return result;
|
||||
}
|
||||
-67
@@ -1,67 +0,0 @@
|
||||
#ifndef EPUB_H
|
||||
#define EPUB_H
|
||||
|
||||
#include <QList>
|
||||
#include <QObject>
|
||||
#include <QString>
|
||||
#include <QVariantList>
|
||||
|
||||
struct EpubChapter
|
||||
{
|
||||
QString id;
|
||||
QString path; // path assoluto del file HTML estratto
|
||||
QString title;
|
||||
};
|
||||
|
||||
class EpubBook : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
Q_PROPERTY(QString title READ title NOTIFY changed)
|
||||
Q_PROPERTY(QString author READ author NOTIFY changed)
|
||||
Q_PROPERTY(int chapterCount READ chapterCount NOTIFY changed)
|
||||
|
||||
public:
|
||||
explicit EpubBook(QObject *parent = 0);
|
||||
~EpubBook();
|
||||
|
||||
Q_INVOKABLE bool open(const QString &epubPath);
|
||||
Q_INVOKABLE void close();
|
||||
|
||||
QString title() const { return m_title; }
|
||||
QString author() const { return m_author; }
|
||||
int chapterCount() const { return m_chapters.size(); }
|
||||
|
||||
Q_INVOKABLE QString chapterFilePath(int index) const;
|
||||
Q_INVOKABLE QString chapterTitle(int index) const;
|
||||
// elenco [{index, title}] per la lista capitoli
|
||||
Q_INVOKABLE QVariantList chapterList() const;
|
||||
// HTML del capitolo con lo stile di reflow iniettato (font-size e margini in px)
|
||||
Q_INVOKABLE QString chapterHtml(int index, int fontSizePx, int marginPx, int marginVerticalPx) const;
|
||||
// base URL (file://) per risolvere immagini/CSS relativi del capitolo
|
||||
Q_INVOKABLE QString chapterBaseUrl(int index) const;
|
||||
|
||||
// chiave segno locale, es. "cw:v1:3:0.42" (capitolo 3, 42% del capitolo)
|
||||
Q_INVOKABLE QString makeBookmarkKey(int chapterIndex, double fraction) const;
|
||||
// restituisce {chapter, fraction} oppure mappa vuota se la chiave non è valida
|
||||
Q_INVOKABLE QVariantMap parseBookmarkKey(const QString &key) const;
|
||||
|
||||
signals:
|
||||
void changed();
|
||||
|
||||
private:
|
||||
bool extractZip(const QString &epubPath);
|
||||
bool parseContainer();
|
||||
bool parseOpf();
|
||||
void parseTocEpub3(const QString &navPath);
|
||||
void parseTocEpub2(const QString &ncxPath);
|
||||
QString absolutePath(const QString &relativeToOpfDir) const;
|
||||
|
||||
QString m_baseDir;
|
||||
QString m_opfDir;
|
||||
QString m_title;
|
||||
QString m_author;
|
||||
QList<EpubChapter> m_chapters;
|
||||
};
|
||||
|
||||
#endif // EPUB_H
|
||||
@@ -1,71 +0,0 @@
|
||||
#include <QDebug>
|
||||
#include <QGuiApplication>
|
||||
#include <QLocale>
|
||||
#include <QQuickView>
|
||||
#include <QQmlContext>
|
||||
#include <QTranslator>
|
||||
#include <sailfishapp.h>
|
||||
|
||||
#include "apiclient.h"
|
||||
#include "downloader.h"
|
||||
#include "epub.h"
|
||||
#include "settings.h"
|
||||
|
||||
Q_DECL_EXPORT int main(int argc, char *argv[])
|
||||
{
|
||||
QGuiApplication *app = SailfishApp::application(argc, argv);
|
||||
|
||||
// Identita' applicazione allineata al profilo X-Sailjail del desktop file:
|
||||
// QStandardPaths::AppConfigLocation diventa ~/.config/harbour/calibreweb,
|
||||
// l'unica zona config persistente della sandbox di Sailfish 5.1.
|
||||
app->setOrganizationName(QStringLiteral("harbour"));
|
||||
app->setApplicationName(QStringLiteral("calibreweb"));
|
||||
|
||||
Settings settings;
|
||||
|
||||
// User-chosen language (Settings -> Language). "system" = follow the
|
||||
// Sailfish UI language. "en" = force English. "it" = force Italian.
|
||||
// The .qm lives in the app data dir (the RPM validator rejects
|
||||
// /usr/share/translations), so libsailfishapp's auto-load cannot be
|
||||
// used: resolve the language here and install our own translator.
|
||||
QString lang = settings.language();
|
||||
if (lang == "system") {
|
||||
const QString sys = QLocale::system().name();
|
||||
lang = sys.startsWith(QLatin1String("it")) ? QStringLiteral("it")
|
||||
: QStringLiteral("en");
|
||||
}
|
||||
if (lang == "en" || lang == "it") {
|
||||
const QList<QTranslator *> trs = app->findChildren<QTranslator *>();
|
||||
foreach (QTranslator *t, trs) {
|
||||
app->removeTranslator(t);
|
||||
t->deleteLater();
|
||||
}
|
||||
if (lang == "it") {
|
||||
QTranslator *translator = new QTranslator(app);
|
||||
if (translator->load(QStringLiteral("harbour-calibreweb_it"),
|
||||
QStringLiteral("/usr/share/harbour-calibreweb/translations"))) {
|
||||
app->installTranslator(translator);
|
||||
} else {
|
||||
qWarning() << "harbour-calibreweb: cannot load harbour-calibreweb_it.qm";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
QQuickView *view = SailfishApp::createView();
|
||||
|
||||
qDebug() << "harbour-calibreweb v0.2.0 build" << __DATE__ << __TIME__;
|
||||
|
||||
qmlRegisterType<EpubBook>("harbour.calibreweb", 1, 0, "EpubBook");
|
||||
|
||||
ApiClient apiClient(&settings);
|
||||
Downloader downloader(&settings);
|
||||
|
||||
view->rootContext()->setContextProperty(QStringLiteral("appSettings"), &settings);
|
||||
view->rootContext()->setContextProperty(QStringLiteral("apiClient"), &apiClient);
|
||||
view->rootContext()->setContextProperty(QStringLiteral("downloader"), &downloader);
|
||||
|
||||
view->setSource(SailfishApp::pathTo(QStringLiteral("qml/harbour-calibreweb.qml")));
|
||||
view->show();
|
||||
|
||||
return app->exec();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
@@ -1,260 +0,0 @@
|
||||
#include "settings.h"
|
||||
|
||||
#include <QDebug>
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QFileInfo>
|
||||
#include <QStandardPaths>
|
||||
|
||||
QString Settings::configPath()
|
||||
{
|
||||
// Path canonico per la sandbox SailJail di Sailfish 4.4+/5.x: con
|
||||
// org/app impostati (harbour/calibreweb, vedi main.cpp e X-Sailjail nel
|
||||
// desktop file), AppConfigLocation = ~/.config/harbour/calibreweb, l'unica
|
||||
// zona config che la sandbox rende PERSISTENTE tra gli avvii. Senza
|
||||
// profilo esplicito la sandbox nasconde ~/.config/harbour (config persa).
|
||||
const QString dir = QStandardPaths::writableLocation(QStandardPaths::AppConfigLocation);
|
||||
QDir().mkpath(dir);
|
||||
return dir + QStringLiteral("/calibreweb.conf");
|
||||
}
|
||||
|
||||
Settings::Settings(QObject *parent)
|
||||
: QObject(parent)
|
||||
, m_settings(configPath(), QSettings::IniFormat)
|
||||
{
|
||||
migrateIfNeeded();
|
||||
openPermissions();
|
||||
// diagnostica: path reale del file di configurazione e valori letti all'avvio
|
||||
qDebug() << "Settings file:" << m_settings.fileName();
|
||||
if (m_settings.status() != QSettings::NoError)
|
||||
qDebug() << "Settings WARNING: file di configurazione non accessibile (permessi?)";
|
||||
qDebug() << "Settings read: serverUrl=[" << serverUrl() << "] user=[" << username()
|
||||
<< "] ssl=" << ignoreSslErrors() << "margin=" << readerMargin()
|
||||
<< "marginV=" << readerMarginV();
|
||||
}
|
||||
|
||||
void Settings::migrateIfNeeded()
|
||||
{
|
||||
if (!serverUrl().isEmpty())
|
||||
return; // il file condiviso ha gia' una configurazione
|
||||
// Sorgenti candidate per la migrazione: il vecchio file per-utente
|
||||
// (~/.config/harbour/calibreweb.conf) e i path usati dalle build precedenti.
|
||||
// La prima con valori vince.
|
||||
const QStringList candidates = QStringList()
|
||||
<< QDir::homePath() + QStringLiteral("/.config/harbour/calibreweb.conf")
|
||||
<< QStringLiteral("/home/rootitanium/.config/harbour/calibreweb.conf")
|
||||
<< QStringLiteral("/home/nemo/.config/harbour/calibreweb.conf")
|
||||
<< QStringLiteral("/home/defaultuser/.config/harbour/calibreweb.conf");
|
||||
for (const QString &cand : candidates) {
|
||||
QSettings src(cand, QSettings::IniFormat);
|
||||
const QString url = src.value(QStringLiteral("serverUrl")).toString();
|
||||
if (url.isEmpty())
|
||||
continue;
|
||||
m_settings.setValue(QStringLiteral("serverUrl"), url);
|
||||
m_settings.setValue(QStringLiteral("username"), src.value(QStringLiteral("username")));
|
||||
m_settings.setValue(QStringLiteral("password"), src.value(QStringLiteral("password")));
|
||||
m_settings.setValue(QStringLiteral("ignoreSslErrors"),
|
||||
src.value(QStringLiteral("ignoreSslErrors")));
|
||||
m_settings.setValue(QStringLiteral("readerMargin"),
|
||||
src.value(QStringLiteral("readerMargin"), 24));
|
||||
m_settings.setValue(QStringLiteral("readerMarginV"),
|
||||
src.value(QStringLiteral("readerMarginV"), 0));
|
||||
// segnalibri di lettura per libro+formato
|
||||
src.beginGroup(QStringLiteral("bookmarks"));
|
||||
const QStringList groups = src.childGroups();
|
||||
for (const QString &g : groups) {
|
||||
src.beginGroup(g);
|
||||
const QStringList keys = src.childKeys();
|
||||
for (const QString &k : keys)
|
||||
m_settings.setValue(QStringLiteral("bookmarks/%1/%2").arg(g).arg(k),
|
||||
src.value(k));
|
||||
src.endGroup();
|
||||
}
|
||||
src.endGroup();
|
||||
m_settings.sync();
|
||||
openPermissions();
|
||||
qDebug() << "Settings: importati i valori da" << cand;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
void Settings::openPermissions()
|
||||
{
|
||||
// Apre la catena di directory e il file di configurazione della home scelta
|
||||
// (utente grafico, home spesso 700): il debug SSH (defaultuser) deve poter
|
||||
// leggere/scrivere lo stesso file. Funziona quando l'app gira come owner
|
||||
// della home (dal launcher); da SSH i permessi restano quelli gia' aperti.
|
||||
const QString file = m_settings.fileName(); // .../calibreweb.conf
|
||||
const QString dir = QFileInfo(file).absolutePath(); // .../.config/harbour
|
||||
const QString configDir = QFileInfo(dir).absolutePath(); // .../.config
|
||||
const QString home = QFileInfo(configDir).absolutePath(); // /home/<utente>
|
||||
QFile::setPermissions(home,
|
||||
QFile::ReadOwner | QFile::WriteOwner | QFile::ExeOwner
|
||||
| QFile::ReadGroup | QFile::ExeGroup
|
||||
| QFile::ReadOther | QFile::ExeOther);
|
||||
QFile::setPermissions(configDir,
|
||||
QFile::ReadOwner | QFile::WriteOwner | QFile::ExeOwner
|
||||
| QFile::ReadGroup | QFile::ExeGroup
|
||||
| QFile::ReadOther | QFile::ExeOther);
|
||||
QFile::setPermissions(dir,
|
||||
QFile::ReadOwner | QFile::WriteOwner | QFile::ExeOwner
|
||||
| QFile::ReadGroup | QFile::WriteGroup | QFile::ExeGroup
|
||||
| QFile::ReadOther | QFile::WriteOther | QFile::ExeOther);
|
||||
QFile::setPermissions(file,
|
||||
QFile::ReadOwner | QFile::WriteOwner
|
||||
| QFile::ReadGroup | QFile::WriteGroup
|
||||
| QFile::ReadOther | QFile::WriteOther);
|
||||
}
|
||||
|
||||
QString Settings::serverUrl() const
|
||||
{
|
||||
return m_settings.value(QStringLiteral("serverUrl")).toString();
|
||||
}
|
||||
|
||||
void Settings::setServerUrl(const QString &value)
|
||||
{
|
||||
if (value == serverUrl())
|
||||
return;
|
||||
m_settings.setValue(QStringLiteral("serverUrl"), value);
|
||||
m_settings.sync(); // scrivi subito su disco: l'app può crashare alla chiusura (WebView)
|
||||
openPermissions();
|
||||
emit serverUrlChanged();
|
||||
emit baseUrlChanged();
|
||||
}
|
||||
|
||||
QString Settings::username() const
|
||||
{
|
||||
return m_settings.value(QStringLiteral("username")).toString();
|
||||
}
|
||||
|
||||
void Settings::setUsername(const QString &value)
|
||||
{
|
||||
if (value == username())
|
||||
return;
|
||||
m_settings.setValue(QStringLiteral("username"), value);
|
||||
m_settings.sync();
|
||||
openPermissions();
|
||||
emit usernameChanged();
|
||||
}
|
||||
|
||||
QString Settings::password() const
|
||||
{
|
||||
return m_settings.value(QStringLiteral("password")).toString();
|
||||
}
|
||||
|
||||
void Settings::setPassword(const QString &value)
|
||||
{
|
||||
if (value == password())
|
||||
return;
|
||||
m_settings.setValue(QStringLiteral("password"), value);
|
||||
m_settings.sync();
|
||||
openPermissions();
|
||||
emit passwordChanged();
|
||||
}
|
||||
|
||||
bool Settings::ignoreSslErrors() const
|
||||
{
|
||||
return m_settings.value(QStringLiteral("ignoreSslErrors"), false).toBool();
|
||||
}
|
||||
|
||||
void Settings::setIgnoreSslErrors(bool value)
|
||||
{
|
||||
if (value == ignoreSslErrors())
|
||||
return;
|
||||
m_settings.setValue(QStringLiteral("ignoreSslErrors"), value);
|
||||
m_settings.sync();
|
||||
openPermissions();
|
||||
emit ignoreSslErrorsChanged();
|
||||
}
|
||||
|
||||
int Settings::readerMargin() const
|
||||
{
|
||||
return m_settings.value(QStringLiteral("readerMargin"), 24).toInt();
|
||||
}
|
||||
|
||||
void Settings::setReaderMargin(int value)
|
||||
{
|
||||
if (value == readerMargin())
|
||||
return;
|
||||
m_settings.setValue(QStringLiteral("readerMargin"), value);
|
||||
m_settings.sync();
|
||||
openPermissions();
|
||||
emit readerMarginChanged();
|
||||
}
|
||||
|
||||
int Settings::readerMarginV() const
|
||||
{
|
||||
return m_settings.value(QStringLiteral("readerMarginV"), 0).toInt();
|
||||
}
|
||||
|
||||
void Settings::setReaderMarginV(int value)
|
||||
{
|
||||
if (value == readerMarginV())
|
||||
return;
|
||||
m_settings.setValue(QStringLiteral("readerMarginV"), value);
|
||||
m_settings.sync();
|
||||
openPermissions();
|
||||
emit readerMarginVChanged();
|
||||
}
|
||||
|
||||
QString Settings::language() const
|
||||
{
|
||||
return m_settings.value(QStringLiteral("language"), QStringLiteral("system")).toString();
|
||||
}
|
||||
|
||||
void Settings::setLanguage(const QString &value)
|
||||
{
|
||||
if (value == language())
|
||||
return;
|
||||
m_settings.setValue(QStringLiteral("language"), value);
|
||||
m_settings.sync();
|
||||
openPermissions();
|
||||
emit languageChanged();
|
||||
}
|
||||
|
||||
QString Settings::downloadDir() const
|
||||
{
|
||||
QString dir = QStandardPaths::writableLocation(QStandardPaths::DownloadLocation);
|
||||
if (dir.isEmpty())
|
||||
dir = QStringLiteral("/home/nemo/Downloads");
|
||||
QDir().mkpath(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
QString Settings::authHeader() const
|
||||
{
|
||||
const QString user = username();
|
||||
if (user.isEmpty())
|
||||
return QString();
|
||||
const QString credentials = user + QLatin1Char(':') + password();
|
||||
return QStringLiteral("Basic ") + QString::fromLatin1(credentials.toUtf8().toBase64());
|
||||
}
|
||||
|
||||
QString Settings::baseUrl() const
|
||||
{
|
||||
QString url = serverUrl().trimmed();
|
||||
while (url.endsWith(QLatin1Char('/')))
|
||||
url.chop(1);
|
||||
return url;
|
||||
}
|
||||
|
||||
QString Settings::bookmarkKey(int bookId, const QString &format) const
|
||||
{
|
||||
return m_settings.value(QStringLiteral("bookmarks/%1/%2").arg(bookId).arg(format))
|
||||
.toString();
|
||||
}
|
||||
|
||||
void Settings::setBookmarkKey(int bookId, const QString &format, const QString &key)
|
||||
{
|
||||
m_settings.setValue(QStringLiteral("bookmarks/%1/%2").arg(bookId).arg(format), key);
|
||||
m_settings.sync();
|
||||
openPermissions();
|
||||
}
|
||||
|
||||
QString Settings::bookFilePath(int bookId, const QString &format) const
|
||||
{
|
||||
const QString dir = QStandardPaths::writableLocation(QStandardPaths::CacheLocation)
|
||||
+ QStringLiteral("/books");
|
||||
QDir().mkpath(dir);
|
||||
return dir + QStringLiteral("/%1.%2").arg(bookId).arg(format);
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
#ifndef SETTINGS_H
|
||||
#define SETTINGS_H
|
||||
|
||||
#include <QObject>
|
||||
#include <QSettings>
|
||||
|
||||
class Settings : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
Q_PROPERTY(QString serverUrl READ serverUrl WRITE setServerUrl NOTIFY serverUrlChanged)
|
||||
Q_PROPERTY(QString username READ username WRITE setUsername NOTIFY usernameChanged)
|
||||
Q_PROPERTY(QString password READ password WRITE setPassword NOTIFY passwordChanged)
|
||||
Q_PROPERTY(bool ignoreSslErrors READ ignoreSslErrors WRITE setIgnoreSslErrors NOTIFY ignoreSslErrorsChanged)
|
||||
Q_PROPERTY(int readerMargin READ readerMargin WRITE setReaderMargin NOTIFY readerMarginChanged)
|
||||
Q_PROPERTY(int readerMarginV READ readerMarginV WRITE setReaderMarginV NOTIFY readerMarginVChanged)
|
||||
// lingua scelta dall'utente: "system" (predefinita, lingua UI Sailfish), "en", "it"
|
||||
Q_PROPERTY(QString language READ language WRITE setLanguage NOTIFY languageChanged)
|
||||
Q_PROPERTY(QString downloadDir READ downloadDir CONSTANT)
|
||||
// derivata da serverUrl (senza slash finale): accesso QML come appSettings.baseUrl
|
||||
Q_PROPERTY(QString baseUrl READ baseUrl NOTIFY baseUrlChanged)
|
||||
|
||||
public:
|
||||
explicit Settings(QObject *parent = 0);
|
||||
|
||||
QString serverUrl() const;
|
||||
void setServerUrl(const QString &value);
|
||||
|
||||
QString username() const;
|
||||
void setUsername(const QString &value);
|
||||
|
||||
QString password() const;
|
||||
void setPassword(const QString &value);
|
||||
|
||||
bool ignoreSslErrors() const;
|
||||
void setIgnoreSslErrors(bool value);
|
||||
|
||||
// margine laterale del testo nel lettore, in px
|
||||
int readerMargin() const;
|
||||
void setReaderMargin(int value);
|
||||
|
||||
// margine verticale (alto e basso, simmetrico) del testo nel lettore, in px
|
||||
int readerMarginV() const;
|
||||
void setReaderMarginV(int value);
|
||||
|
||||
// lingua dell'interfaccia: "system" (default) | "en" | "it"
|
||||
QString language() const;
|
||||
void setLanguage(const QString &value);
|
||||
|
||||
QString downloadDir() const;
|
||||
|
||||
// Header "Authorization: Basic ..." pronto da usare, vuoto se utente non impostato
|
||||
QString authHeader() const;
|
||||
|
||||
// URL del server senza slash finale
|
||||
QString baseUrl() const;
|
||||
|
||||
// --- segnalibri (posizione di lettura, per libro+formato) ---
|
||||
Q_INVOKABLE QString bookmarkKey(int bookId, const QString &format) const;
|
||||
Q_INVOKABLE void setBookmarkKey(int bookId, const QString &format, const QString &key);
|
||||
|
||||
// path del file libro scaricato in cache (per il lettore)
|
||||
Q_INVOKABLE QString bookFilePath(int bookId, const QString &format) const;
|
||||
|
||||
signals:
|
||||
void serverUrlChanged();
|
||||
void usernameChanged();
|
||||
void passwordChanged();
|
||||
void ignoreSslErrorsChanged();
|
||||
void readerMarginChanged();
|
||||
void readerMarginVChanged();
|
||||
void languageChanged();
|
||||
void baseUrlChanged();
|
||||
|
||||
private:
|
||||
// Path fisso condiviso: l'app deve leggere/scrivere SEMPRE lo stesso file,
|
||||
// sia avviata dal launcher (utente nemo) sia da SSH di debug (defaultuser).
|
||||
// Con QSettings("harbour","calibreweb") il file dipende dalla HOME
|
||||
// dell'utente che avvia -> configurazione invisibile dall'altro utente.
|
||||
static QString configPath();
|
||||
// Importa i valori dalla vecchia config per-utente (~/.config/harbour/...) se
|
||||
// il file condiviso e' vuoto: copre la configurazione fatta prima del fix.
|
||||
void migrateIfNeeded();
|
||||
// Apre i permessi della catena /home/nemo/.config/harbour e del file:
|
||||
// il launcher gira come nemo (home 700), il debug SSH come defaultuser —
|
||||
// entrambi devono poter leggere/scrivere lo stesso file di configurazione.
|
||||
void openPermissions();
|
||||
|
||||
QSettings m_settings;
|
||||
};
|
||||
|
||||
#endif // SETTINGS_H
|
||||
@@ -1,293 +0,0 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<TS version="2.1" language="it_IT">
|
||||
<context>
|
||||
<name>BookDetailPage</name>
|
||||
<message>
|
||||
<location filename="../qml/pages/BookDetailPage.qml" line="36" />
|
||||
<source>Read (EPUB)</source>
|
||||
<translation>Leggi (EPUB)</translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../qml/pages/BookDetailPage.qml" line="60" />
|
||||
<source>Formats</source>
|
||||
<translation>Formati</translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../qml/pages/BookDetailPage.qml" line="112" />
|
||||
<source>Downloading…</source>
|
||||
<translation>Download in corso…</translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../qml/pages/BookDetailPage.qml" line="261" />
|
||||
<source>Saved to </source>
|
||||
<translation>Salvato in </translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../qml/pages/BookDetailPage.qml" line="266" />
|
||||
<source>Error: </source>
|
||||
<translation>Errore: </translation>
|
||||
</message>
|
||||
</context>
|
||||
<context>
|
||||
<name>ChapterListDialog</name>
|
||||
<message>
|
||||
<location filename="../qml/pages/ChapterListDialog.qml" line="12" />
|
||||
<source>Table of contents</source>
|
||||
<translation>Indice capitoli</translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../qml/pages/ChapterListDialog.qml" line="38" />
|
||||
<source>No chapters</source>
|
||||
<translation>Nessun capitolo</translation>
|
||||
</message>
|
||||
</context>
|
||||
<context>
|
||||
<name>CoverPage</name>
|
||||
<message>
|
||||
<location filename="../qml/cover/CoverPage.qml" line="18" />
|
||||
<source>Calibre Web</source>
|
||||
<translation>Calibre Web</translation>
|
||||
</message>
|
||||
</context>
|
||||
<context>
|
||||
<name>FeedPage</name>
|
||||
<message>
|
||||
<location filename="../qml/pages/FeedPage.qml" line="91" />
|
||||
<source>Refresh</source>
|
||||
<translation>Aggiorna</translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../qml/pages/FeedPage.qml" line="95" />
|
||||
<source>Settings</source>
|
||||
<translation>Impostazioni</translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../qml/pages/FeedPage.qml" line="102" />
|
||||
<location filename="../qml/pages/FeedPage.qml" line="171" />
|
||||
<source>No results</source>
|
||||
<translation>Nessun risultato</translation>
|
||||
</message>
|
||||
</context>
|
||||
<context>
|
||||
<name>MainPage</name>
|
||||
<message>
|
||||
<location filename="../qml/pages/MainPage.qml" line="13" />
|
||||
<source>Calibre Web</source>
|
||||
<translation>Calibre Web</translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../qml/pages/MainPage.qml" line="43" />
|
||||
<source>Search</source>
|
||||
<translation>Cerca</translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../qml/pages/MainPage.qml" line="47" />
|
||||
<source>Settings</source>
|
||||
<translation>Impostazioni</translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../qml/pages/MainPage.qml" line="51" />
|
||||
<source>Refresh</source>
|
||||
<translation>Aggiorna</translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../qml/pages/MainPage.qml" line="60" />
|
||||
<source>Open the settings and enter your Calibre Web address</source>
|
||||
<translation>Apri le impostazioni e inserisci l'indirizzo del tuo Calibre Web</translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../qml/pages/MainPage.qml" line="77" />
|
||||
<source>Server not configured</source>
|
||||
<translation>Server non configurato</translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../qml/pages/MainPage.qml" line="100" />
|
||||
<source>No sections found in the OPDS feed</source>
|
||||
<translation>Nessuna sezione trovata nel feed OPDS</translation>
|
||||
</message>
|
||||
</context>
|
||||
<context>
|
||||
<name>MarginDialog</name>
|
||||
<message>
|
||||
<location filename="../qml/pages/MarginDialog.qml" line="25" />
|
||||
<source>Text margins</source>
|
||||
<translation>Margini del testo</translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../qml/pages/MarginDialog.qml" line="26" />
|
||||
<source>Apply</source>
|
||||
<translation>Applica</translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../qml/pages/MarginDialog.qml" line="32" />
|
||||
<source>Side margins</source>
|
||||
<translation>Margini laterali</translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../qml/pages/MarginDialog.qml" line="47" />
|
||||
<source>Top and bottom</source>
|
||||
<translation>Alto e basso</translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../qml/pages/MarginDialog.qml" line="62" />
|
||||
<source>Changes apply immediately. Confirm to save them for all books.</source>
|
||||
<translation>Le modifiche si applicano subito al testo. Conferma per salvarle per tutti i libri.</translation>
|
||||
</message>
|
||||
</context>
|
||||
<context>
|
||||
<name>ReaderPage</name>
|
||||
<message>
|
||||
<location filename="../qml/pages/ReaderPage.qml" line="35" />
|
||||
<source>Table of contents</source>
|
||||
<translation>Indice capitoli</translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../qml/pages/ReaderPage.qml" line="39" />
|
||||
<source>Text margins</source>
|
||||
<translation>Margini del testo</translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../qml/pages/ReaderPage.qml" line="53" />
|
||||
<source>Settings</source>
|
||||
<translation>Impostazioni</translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../qml/pages/ReaderPage.qml" line="57" />
|
||||
<source>Close book</source>
|
||||
<translation>Chiudi libro</translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../qml/pages/ReaderPage.qml" line="170" />
|
||||
<source>The reader is not responding (WebView). The EPUB file may be damaged.</source>
|
||||
<translation>Il lettore non risponde (WebView). Il file EPUB potrebbe essere danneggiato.</translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../qml/pages/ReaderPage.qml" line="293" />
|
||||
<source>Chapter unreadable</source>
|
||||
<translation>Capitolo non leggibile</translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../qml/pages/ReaderPage.qml" line="342" />
|
||||
<source>Unable to open the EPUB file</source>
|
||||
<translation>Impossibile aprire il libro EPUB</translation>
|
||||
</message>
|
||||
</context>
|
||||
<context>
|
||||
<name>SearchPage</name>
|
||||
<message>
|
||||
<location filename="../qml/pages/SearchPage.qml" line="15" />
|
||||
<location filename="../qml/pages/SearchPage.qml" line="27" />
|
||||
<source>Search</source>
|
||||
<translation>Cerca</translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../qml/pages/SearchPage.qml" line="20" />
|
||||
<source>Title or author…</source>
|
||||
<translation>Titolo o autore…</translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../qml/pages/SearchPage.qml" line="35" />
|
||||
<source>Search uses the server engine (OPDS search).</source>
|
||||
<translation>La ricerca usa il motore del server (OPDS search).</translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../qml/pages/SearchPage.qml" line="50" />
|
||||
<source>Results: </source>
|
||||
<translation>Risultati: </translation>
|
||||
</message>
|
||||
</context>
|
||||
<context>
|
||||
<name>SettingsPage</name>
|
||||
<message>
|
||||
<location filename="../qml/pages/SettingsPage.qml" line="20" />
|
||||
<source>Settings</source>
|
||||
<translation>Impostazioni</translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../qml/pages/SettingsPage.qml" line="22" />
|
||||
<source>Server</source>
|
||||
<translation>Server</translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../qml/pages/SettingsPage.qml" line="28" />
|
||||
<source>Calibre Web address</source>
|
||||
<translation>Indirizzo Calibre Web</translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../qml/pages/SettingsPage.qml" line="39" />
|
||||
<source>User (empty = anonymous access)</source>
|
||||
<translation>Utente (vuoto = accesso anonimo)</translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../qml/pages/SettingsPage.qml" line="48" />
|
||||
<source>Password</source>
|
||||
<translation>Password</translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../qml/pages/SettingsPage.qml" line="55" />
|
||||
<source>Ignore SSL certificate errors</source>
|
||||
<translation>Ignora errori certificato SSL</translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../qml/pages/SettingsPage.qml" line="56" />
|
||||
<source>Enable if the server uses a self-signed certificate</source>
|
||||
<translation>Da attivare se il server usa un certificato autofirmato</translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../qml/pages/SettingsPage.qml" line="61" />
|
||||
<source>Reader</source>
|
||||
<translation>Lettore</translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../qml/pages/SettingsPage.qml" line="66" />
|
||||
<source>Side margins</source>
|
||||
<translation>Margini laterali</translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../qml/pages/SettingsPage.qml" line="78" />
|
||||
<source>Top and bottom margin</source>
|
||||
<translation>Margine alto e basso</translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../qml/pages/SettingsPage.qml" line="87" />
|
||||
<source>Connection</source>
|
||||
<translation>Connessione</translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../qml/pages/SettingsPage.qml" line="91" />
|
||||
<source>Test connection</source>
|
||||
<translation>Prova connessione</translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../qml/pages/SettingsPage.qml" line="115" />
|
||||
<location filename="../qml/pages/SettingsPage.qml" line="120" />
|
||||
<source>Language</source>
|
||||
<translation type="finished">Lingua</translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../qml/pages/SettingsPage.qml" line="123" />
|
||||
<source>System (default)</source>
|
||||
<translation type="finished">Sistema (predefinita)</translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../qml/pages/SettingsPage.qml" line="131" />
|
||||
<source>Language will be applied after restarting the app</source>
|
||||
<translation type="finished">La lingua verrà applicata al riavvio dell'app</translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../qml/pages/SettingsPage.qml" line="159" />
|
||||
<source>Enter the server address</source>
|
||||
<translation>Inserisci l'indirizzo del server</translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../qml/pages/SettingsPage.qml" line="168" />
|
||||
<source>Testing…</source>
|
||||
<translation>Verifica in corso…</translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../qml/pages/SettingsPage.qml" line="179" />
|
||||
<source>Connection OK: %1 sections found</source>
|
||||
<translation>Connessione OK: %1 sezioni trovate</translation>
|
||||
</message>
|
||||
</context>
|
||||
</TS>
|
||||
Reference in New Issue
Block a user