- Icons: eigene SVGs, kräftig und geometrisch (2,4 px, eckige Enden), in nav.js für alle Leisten (DioraNav.icon, sections()/groups() .svg); der aktive Bereich gefüllt. - Hochladen: mehrere Dateien auf einmal (Auswahl oder auf die Bibliothek ziehen) und aus der Cloud per Häkchen („Alle auswählen“, „n importieren“); nacheinander mit Fortschritt, ein schon vorhandenes Buch (Titel und Autor) wird übersprungen. - Pro Buch: „Generiertes Cover verwenden“ statt des eigenen (im verschlüsselten Meta, cover: 'generated'). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
209 lines
6.8 KiB
JavaScript
209 lines
6.8 KiB
JavaScript
'use strict';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Cloud import — browse a user-configured WebDAV server and pull a book in.
|
|
//
|
|
// The download is proxied by the server because the remote host is a different
|
|
// origin and sends no CORS headers. It still lands here as raw bytes and goes
|
|
// through _importEbookBuffer like any local file, so what gets stored is
|
|
// ciphertext the server cannot read.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
let _cloudSources = [];
|
|
let _cloudSourceId = null;
|
|
let _cloudPath = '';
|
|
|
|
async function toggleCloudImport() {
|
|
const panel = $('cloud-browser');
|
|
if (!panel) return;
|
|
if (panel.style.display !== 'none') { closeCloudImport(); return; }
|
|
|
|
try {
|
|
const res = await fetch('/books/cloud/sources/');
|
|
_cloudSources = (await res.json()).sources || [];
|
|
} catch (e) {
|
|
await customAlert('Cloud-Verbindungen konnten nicht geladen werden: ' + e.message);
|
|
return;
|
|
}
|
|
|
|
if (!_cloudSources.length) {
|
|
await customAlert(
|
|
'Noch keine Cloud-Verbindung eingerichtet. Unter Einstellungen → '
|
|
+ '„Cloud-Verbindungen für Bücher“ kannst du eine hinzufügen.'
|
|
);
|
|
return;
|
|
}
|
|
|
|
const select = $('cloud-source-select');
|
|
if (select) {
|
|
select.innerHTML = '';
|
|
for (const source of _cloudSources) {
|
|
const option = document.createElement('option');
|
|
option.value = source.id;
|
|
option.textContent = source.label;
|
|
select.appendChild(option);
|
|
}
|
|
select.style.display = _cloudSources.length > 1 ? '' : 'none';
|
|
}
|
|
|
|
panel.style.display = '';
|
|
_cloudSourceId = _cloudSources[0].id;
|
|
_cloudBrowse('');
|
|
}
|
|
|
|
function closeCloudImport() {
|
|
const panel = $('cloud-browser');
|
|
if (panel) panel.style.display = 'none';
|
|
}
|
|
|
|
function _onCloudSourceChange(value) {
|
|
_cloudSourceId = parseInt(value, 10);
|
|
_cloudBrowse('');
|
|
}
|
|
|
|
function _cloudParentPath(path) {
|
|
const parts = path.split('/').filter(Boolean);
|
|
parts.pop();
|
|
return parts.join('/');
|
|
}
|
|
|
|
function _formatBytes(n) {
|
|
if (!n) return '';
|
|
if (n < 1024) return n + ' B';
|
|
if (n < 1024 * 1024) return Math.round(n / 1024) + ' KB';
|
|
return (n / 1024 / 1024).toFixed(1) + ' MB';
|
|
}
|
|
|
|
function _cloudEntryRow(label, onClick, sizeText) {
|
|
const li = document.createElement('li');
|
|
li.className = 'cloud-entry';
|
|
|
|
const button = document.createElement('button');
|
|
button.type = 'button';
|
|
button.className = 'cloud-entry-name';
|
|
button.textContent = label;
|
|
button.addEventListener('click', onClick);
|
|
li.appendChild(button);
|
|
|
|
if (sizeText) {
|
|
const size = document.createElement('span');
|
|
size.className = 'cloud-entry-size';
|
|
size.textContent = sizeText;
|
|
li.appendChild(size);
|
|
}
|
|
return li;
|
|
}
|
|
|
|
// Books chosen in this folder of the cloud (path -> entry), imported together.
|
|
let _cloudChosen = new Map();
|
|
|
|
function _cloudChosenBar() {
|
|
let bar = $('cloud-chosen');
|
|
const listEl = $('cloud-entries');
|
|
if (!bar && listEl) {
|
|
bar = document.createElement('div');
|
|
bar.id = 'cloud-chosen';
|
|
bar.className = 'cloud-chosen';
|
|
listEl.parentNode.insertBefore(bar, listEl);
|
|
}
|
|
if (!bar) return;
|
|
const books = Array.from(listEl.querySelectorAll('.cloud-check'));
|
|
const n = _cloudChosen.size;
|
|
bar.innerHTML = books.length ? `<label class="cloud-all"><input type="checkbox" ${n && n === books.length ? 'checked' : ''}> Alle ${books.length} auswählen</label>` +
|
|
`<button type="button" class="btn btn-primary"${n ? '' : ' disabled'}>${n ? n + ' importieren' : 'Bücher anhaken'}</button>` : '';
|
|
const all = bar.querySelector('.cloud-all input');
|
|
if (all) all.onchange = () => {
|
|
books.forEach(c => { c.checked = all.checked; c.onchange(); });
|
|
};
|
|
const go = bar.querySelector('button');
|
|
if (go) go.onclick = () => _cloudImportChosen();
|
|
}
|
|
|
|
async function _cloudImportChosen() {
|
|
const entries = Array.from(_cloudChosen.values());
|
|
if (!entries.length) return;
|
|
_cloudChosen = new Map();
|
|
const statusEl = $('cloud-status');
|
|
await _importMany(entries.map(entry => ({
|
|
name: entry.name,
|
|
size: entry.size,
|
|
buf: async () => {
|
|
const res = await fetch(`/books/cloud/${_cloudSourceId}/fetch/?path=${encodeURIComponent(entry.path)}`);
|
|
if (!res.ok) throw new Error('HTTP ' + res.status);
|
|
return res.arrayBuffer();
|
|
},
|
|
})), statusEl);
|
|
$('cloud-entries').querySelectorAll('.cloud-check').forEach(c => { c.checked = false; });
|
|
_cloudChosenBar();
|
|
}
|
|
|
|
async function _cloudBrowse(path) {
|
|
const listEl = $('cloud-entries');
|
|
const pathEl = $('cloud-path');
|
|
const statusEl = $('cloud-status');
|
|
if (!listEl) return;
|
|
|
|
if (statusEl) statusEl.textContent = 'Lade…';
|
|
listEl.innerHTML = '';
|
|
_cloudChosen = new Map();
|
|
_cloudChosenBar();
|
|
|
|
let data;
|
|
try {
|
|
const res = await fetch(`/books/cloud/${_cloudSourceId}/browse/?path=${encodeURIComponent(path)}`);
|
|
data = await res.json();
|
|
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
|
|
} catch (e) {
|
|
if (statusEl) statusEl.textContent = 'Fehler: ' + e.message;
|
|
return;
|
|
}
|
|
|
|
_cloudPath = data.path || '';
|
|
if (pathEl) pathEl.textContent = '/' + _cloudPath;
|
|
if (statusEl) statusEl.textContent = '';
|
|
|
|
if (_cloudPath) {
|
|
listEl.appendChild(_cloudEntryRow('⬑ ..', () => _cloudBrowse(_cloudParentPath(_cloudPath))));
|
|
}
|
|
|
|
const entries = (data.entries || []).filter(e => e.is_dir || e.is_book);
|
|
if (!entries.length && statusEl) {
|
|
statusEl.textContent = 'Keine Bücher oder Ordner in diesem Verzeichnis.';
|
|
}
|
|
|
|
for (const entry of entries) {
|
|
if (entry.is_dir) { listEl.appendChild(_cloudEntryRow('📁 ' + entry.name, () => _cloudBrowse(entry.path))); continue; }
|
|
// A book: its name imports it alone, its box chooses it for importing several.
|
|
const row = _cloudEntryRow('📖 ' + entry.name, () => _cloudImport(entry), _formatBytes(entry.size));
|
|
const box = document.createElement('input');
|
|
box.type = 'checkbox';
|
|
box.className = 'cloud-check';
|
|
box.setAttribute('aria-label', entry.name + ' auswählen');
|
|
box.onchange = () => {
|
|
if (box.checked) _cloudChosen.set(entry.path, entry); else _cloudChosen.delete(entry.path);
|
|
_cloudChosenBar();
|
|
};
|
|
row.prepend(box);
|
|
listEl.appendChild(row);
|
|
}
|
|
_cloudChosenBar();
|
|
}
|
|
|
|
async function _cloudImport(entry) {
|
|
const statusEl = $('cloud-status');
|
|
if (statusEl) statusEl.textContent = `Lade „${entry.name}“…`;
|
|
|
|
try {
|
|
const res = await fetch(`/books/cloud/${_cloudSourceId}/fetch/?path=${encodeURIComponent(entry.path)}`);
|
|
if (!res.ok) {
|
|
const err = await res.json().catch(() => ({}));
|
|
throw new Error(err.error || `HTTP ${res.status}`);
|
|
}
|
|
const buf = await res.arrayBuffer();
|
|
if (await _importEbookBuffer(buf, entry.name, statusEl) && statusEl) {
|
|
statusEl.textContent = `✓ „${entry.name}“ importiert`;
|
|
}
|
|
} catch (e) {
|
|
if (statusEl) statusEl.textContent = 'Import fehlgeschlagen: ' + e.message;
|
|
}
|
|
}
|