238 lines
12 KiB
JavaScript
238 lines
12 KiB
JavaScript
|
|
'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, '&').replace(/</g, '<').replace(/>/g, '>')
|
|||
|
|
.replace(/"/g, '"').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`;
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
}
|