diora-web/static/js/app/55-library-tools.js
mrwnslz 0ba16a40ff
Some checks failed
Build and push Docker image / build (push) Successful in 15s
Test / test (push) Successful in 10m46s
Test / e2e (push) Failing after 1m27s
KI-Assistent (eigener Anthropic-Key, Ende-zu-Ende verschlüsselt, Aufrufe direkt vom Gerät) mit „Mit KI ordnen“ für Bücher; Bibliothek als XML exportieren; sw v148
- /accounts/vault/<name>/: kleine verschlüsselte Geräte-Einstellungen (UserVault)
- static/js/ai.js: DioraAI (SDK 0.133.0 gebündelt in static/vendor), Vorschläge zum Abhaken
- static/js/app/55-library-tools.js: ✦ Ordnen (Ordner, Serien, Titel/Autor, Zeitschrift) und ⇩ XML-Export
- docs/ai.md, docs/library.md

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 18:34:49 +02:00

237 lines
12 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use strict';
// ---------------------------------------------------------------------------
// Two tools in the library's head (docs/library.md):
// ⇩ the whole library as an XML file: every book's diora metadata (decrypted here),
// folders, series, the reading position, the magazines' articles — made on this
// device from the list already decrypted, nothing new goes to the server;
// ✦ "Ordnen" with Claude (docs/ai.md): folders, series, cleaned titles and authors,
// magazine marks suggested for the books shown; the user ticks what to take.
// They are added to the head 53-library.js draws (after each drawing).
// ---------------------------------------------------------------------------
function _libToolsAdd() {
const head = document.querySelector('#book-list .lib-head');
if (!head || head.querySelector('.lib-tools-x')) return;
const add = head.querySelector('.lib-add');
const mk = (label, title, act) => {
const b = document.createElement('button');
b.type = 'button';
b.className = 'lib-icon lib-tools-x';
b.textContent = label;
b.title = title;
b.setAttribute('aria-label', title);
b.addEventListener('click', e => { e.stopPropagation(); act(); });
head.insertBefore(b, add || null);
};
if (window.DioraAI && DioraAI.available()) mk('✦', 'Mit KI ordnen', libraryOrganize);
mk('⇩', 'Bibliothek als XML exportieren', libraryExportXml);
}
(function () {
const watch = () => {
const list = document.getElementById('book-list');
if (!list) return;
new MutationObserver(_libToolsAdd).observe(list, {childList: true});
_libToolsAdd();
};
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', watch);
else watch();
// ai.js is a module and comes after: add ✦ then.
window.addEventListener('diora-ai', () => {
const old = document.querySelector('#book-list .lib-head');
if (old) old.querySelectorAll('.lib-tools-x').forEach(b => b.remove());
_libToolsAdd();
});
})();
// ---- XML export -------------------------------------------------------------
function _xmlEsc(s) {
return String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F]/g, '');
}
function _xmlEl(name, value, attrs) {
const a = Object.entries(attrs || {}).filter(([, v]) => v !== undefined && v !== null && v !== '')
.map(([k, v]) => ` ${k}="${_xmlEsc(v)}"`).join('');
if (value === undefined || value === null || value === '') return `<${name}${a}/>`;
return `<${name}${a}>${_xmlEsc(value)}</${name}>`;
}
const _xmlTime = v => (v ? (typeof v === 'number' ? new Date(v).toISOString() : String(v)) : '');
function libraryExportXml() {
const books = (typeof _lastBookListData !== 'undefined' && _lastBookListData) || [];
if (!books.length) { if (typeof customAlert === 'function') customAlert('Noch keine Bücher geladen.'); return; }
const user = document.querySelector('meta[name="username"]')?.content || '';
const order = (typeof _lib !== 'undefined' && _lib && Array.isArray(_lib.order)) ? _lib.order : [];
const out = ['<?xml version="1.0" encoding="UTF-8"?>',
`<diora-library version="1" exported="${new Date().toISOString()}" account="${_xmlEsc(user)}" books="${books.length}">`];
if (order.length) {
out.push(' <folder-order>');
order.forEach(f => out.push(' ' + _xmlEl('folder', f)));
out.push(' </folder-order>');
}
for (const b of books) {
const m = (typeof bookMetaCache !== 'undefined' && bookMetaCache[b.id]) || {};
const kind = (typeof _isMagazine === 'function' && _isMagazine(b)) ? 'magazine' : 'book';
out.push(` <book id="${b.id}" kind="${kind}" type="${_xmlEsc(b.type || m.type || 'epub')}"${b.keyOk === false ? ' decrypted="false"' : ''}>`);
out.push(' ' + _xmlEl('title', b.title));
out.push(' ' + _xmlEl('author', b.author));
if (m.filename) out.push(' ' + _xmlEl('filename', m.filename));
if (b.isbn || m.isbn) out.push(' ' + _xmlEl('isbn', b.isbn || m.isbn));
if (b.pages || m.pages) out.push(' ' + _xmlEl('pages', b.pages || m.pages));
if (b.shelfTag || m.shelfTag) out.push(' ' + _xmlEl('shelf-category', b.shelfTag || m.shelfTag));
if (b.series) out.push(' ' + _xmlEl('series', b.series, {number: b.seriesIndex}));
if (b.work) out.push(' ' + _xmlEl('work', b.work));
if (b.magazine === true || b.magazine === false) out.push(' ' + _xmlEl('magazine-mark', String(b.magazine)));
if (b.cover) out.push(' ' + _xmlEl('cover', b.cover));
const folders = typeof _foldersOfMeta === 'function' ? _foldersOfMeta(b) : (b.folders || []);
if (folders.length) {
out.push(' <folders>');
folders.forEach(f => out.push(' ' + _xmlEl('folder', f)));
out.push(' </folders>');
}
out.push(' ' + _xmlEl('progress', '', {
percent: b.scroll_fraction != null ? (Math.round(b.scroll_fraction * 1000) / 10).toFixed(1) : '',
anchor: b.position_anchor,
read: String(!!b.is_read),
'read-at': _xmlTime(b.read_at),
'last-read': _xmlTime(b.last_read),
device: b.device,
'furthest-percent': b.furthest_fraction ? (Math.round(b.furthest_fraction * 1000) / 10).toFixed(1) : '',
'furthest-anchor': b.furthest_anchor,
}));
out.push(' ' + _xmlEl('uploaded', _xmlTime(b.uploaded_at)));
if (b.has_highlights) out.push(' ' + _xmlEl('highlights', 'true'));
const issue = b.issue || m.issue;
if (issue && Array.isArray(issue.a) && issue.a.length) {
const readState = b.issueRead || m.issueRead || {};
out.push(` <articles${(b.issueAt || m.issueAt) ? ` current="${_xmlEsc(b.issueAt || m.issueAt)}"` : ''}>`);
issue.a.forEach(x => out.push(' ' + _xmlEl('article', x.t, {
section: x.r, author: x.a, minutes: x.m, 'first-page': x.p, 'last-page': x.e,
state: readState[x.h] === 'r' ? 'read' : readState[x.h] === 's' ? 'skipped' : '', href: x.h,
})));
out.push(' </articles>');
}
out.push(' </book>');
}
out.push('</diora-library>', '');
const blob = new Blob([out.join('\n')], {type: 'application/xml;charset=utf-8'});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `diora-bibliothek-${new Date().toISOString().slice(0, 10)}.xml`;
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
// ---- ✦ Ordnen with Claude ------------------------------------------------------
const _LIB_AI_SYSTEM = `Du hilfst, eine private E-Book-Bibliothek zu ordnen. Du bekommst die Bücher als JSON
(id, Titel, Autor, Ordner, Serie, Zeitschrift) und die vorhandenen Ordner. Schlage nur Änderungen vor, die
wirklich helfen; ein Buch ohne sinnvolle Änderung lässt du weg.
- Ordner wirken wie Schlagwörter, ein Buch kann in mehreren sein. Nutze vorhandene Ordner bevorzugt.
Einen neuen Ordner nur, wenn mindestens drei Bücher hineinpassen; kurze deutsche Namen nach Thema oder
Gattung (z. B. „Philosophie“, „Geschichte“, „Romane“). Du schlägst nur Ordner zum Hinzufügen vor,
entfernst keine.
- Serie nur bei klar mehrbändigen Werken (Band, Teil, Volume …), mit Bandnummer, wenn erkennbar.
- Titel und Autor nur korrigieren, wenn sie offensichtlich kaputt sind: Dateinamen, Unterstriche,
„Unknown“, Autor im Titel, durchgehend Großbuchstaben, vertauschte Felder. Sonst null.
- zeitschrift: true nur für Hefte oder Ausgaben von Zeitschriften, false nur wenn etwas fälschlich als
Zeitschrift markiert ist, sonst null.
- warum: ein kurzer deutscher Satz.`;
const _LIB_AI_SCHEMA = {
type: 'object',
properties: {
vorschlaege: {
type: 'array',
items: {
type: 'object',
properties: {
id: {type: 'integer'},
ordner_hinzu: {type: ['array', 'null'], items: {type: 'string'}},
serie: {type: ['string', 'null']},
band: {type: ['number', 'null']},
titel: {type: ['string', 'null']},
autor: {type: ['string', 'null']},
zeitschrift: {type: ['boolean', 'null']},
warum: {type: 'string'},
},
required: ['id', 'ordner_hinzu', 'serie', 'band', 'titel', 'autor', 'zeitschrift', 'warum'],
additionalProperties: false,
},
},
},
required: ['vorschlaege'],
additionalProperties: false,
};
function libraryOrganize() {
DioraAI.withKey(async () => {
const all = (typeof _lastBookListData !== 'undefined' && _lastBookListData) || [];
const section = typeof _bookSectionFilter === 'function' ? _bookSectionFilter(all) : all;
const books = section.filter(b => b.keyOk !== false);
if (!books.length) return;
const folderOf = b => (typeof _foldersOfMeta === 'function' ? _foldersOfMeta(b) : (b.folders || []));
const existing = [...new Set(all.flatMap(folderOf))].sort((a, b) => a.localeCompare(b, 'de'));
const data = books.map(b => ({id: b.id, titel: b.title, autor: b.author, ordner: folderOf(b),
serie: b.series || null, band: b.seriesIndex || null, zeitschrift: typeof _isMagazine === 'function' ? _isMagazine(b) : !!b.magazine}));
DioraAI.progress('Mit KI ordnen', `Claude sieht sich ${books.length} ${books.length === 1 ? 'Buch' : 'Bücher'} an – das kann eine Minute dauern …`);
let answer;
try {
answer = await DioraAI.ask({
system: _LIB_AI_SYSTEM,
prompt: `Vorhandene Ordner: ${JSON.stringify(existing)}\n\nBücher:\n${JSON.stringify(data)}`,
schema: _LIB_AI_SCHEMA,
});
} catch (e) {
DioraAI.showError('Mit KI ordnen', e);
return;
}
const byId = new Map(books.map(b => [b.id, b]));
const items = [];
for (const s of answer.vorschlaege || []) {
const b = byId.get(s.id);
if (!b) continue;
const have = folderOf(b);
const addF = (s.ordner_hinzu || []).map(f => String(f).trim()).filter(f => f && !have.includes(f));
const change = {};
const lines = [];
if (addF.length) { change.folders = have.concat(addF); lines.push('Ordner + ' + addF.join(', ')); }
if (s.serie && s.serie !== b.series) {
change.series = s.serie;
if (s.band != null) change.seriesIndex = s.band;
lines.push('Serie: ' + s.serie + (s.band != null ? ` (Band ${s.band})` : ''));
}
if (s.titel && s.titel !== b.title) { change.title = s.titel; lines.push('Titel: ' + s.titel); }
if (s.autor && s.autor !== b.author) { change.author = s.autor; lines.push('Autor: ' + s.autor); }
if (s.zeitschrift === true || s.zeitschrift === false) {
const now = typeof _isMagazine === 'function' ? _isMagazine(b) : !!b.magazine;
if (s.zeitschrift !== now) { change.magazine = s.zeitschrift; lines.push(s.zeitschrift ? 'als Zeitschrift führen' : 'als Buch führen'); }
}
if (!lines.length) continue;
if (s.warum) lines.push('– ' + s.warum);
items.push({id: s.id, title: b.title + (b.author ? ' · ' + b.author : ''), lines, change});
}
DioraAI.review({
title: 'Mit KI ordnen',
intro: 'Vorschläge von Claude. Was angehakt ist, wird übernommen; vorhandene Ordner bleiben.',
items,
onApply: async ids => {
let n = 0;
for (const id of ids) {
const it = items.find(x => x.id === id);
if (it && await _updateBookMeta(id, it.change)) n++;
}
if (typeof renderBookList === 'function') renderBookList(_lastBookListData);
return `${n} ${n === 1 ? 'Buch' : 'Bücher'} geordnet`;
},
});
});
}