'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(/[\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)}`; } 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 = ['', ``]; if (order.length) { out.push(' '); order.forEach(f => out.push(' ' + _xmlEl('folder', f))); out.push(' '); } for (const b of books) { const m = (typeof bookMetaCache !== 'undefined' && bookMetaCache[b.id]) || {}; const kind = (typeof _isMagazine === 'function' && _isMagazine(b)) ? 'magazine' : 'book'; out.push(` `); 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.forEach(f => out.push(' ' + _xmlEl('folder', f))); out.push(' '); } 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(` `); 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(' '); } out.push(' '); } out.push('', ''); 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`; }, }); }); }