diora-web/static/js/app/56-cloud-import.js
mrwnslz 6d7fb4a849
All checks were successful
Build and push Docker image / build (push) Successful in 14s
Test / test (push) Successful in 9m6s
Test / e2e (push) Successful in 2m45s
Neue Navigations-Icons (Stil B), mehrere Bücher auf einmal, generiertes Cover pro Buch wählbar
- 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>
2026-10-16 21:21:10 +02:00

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;
}
}