diora-web/static/js/app/55-library-tools.js

238 lines
12 KiB
JavaScript
Raw Normal View History

'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`;
},
});
});
}