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

- /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>
This commit is contained in:
mrwnslz 2026-10-24 18:34:49 +02:00
parent a73ade5422
commit 0ba16a40ff
13 changed files with 714 additions and 2 deletions

View file

@ -0,0 +1,28 @@
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0006_webdav_inbox'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='UserVault',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=40)),
('ct', models.TextField()),
('iv', models.CharField(max_length=64)),
('updated_at', models.DateTimeField(auto_now=True)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='vault', to=settings.AUTH_USER_MODEL)),
],
options={
'unique_together': {('user', 'name')},
},
),
]

View file

@ -108,6 +108,20 @@ class WebDAVSeen(models.Model):
unique_together = ('source', 'path')
class UserVault(models.Model):
"""Small private settings of the devices, end-to-end encrypted with the book key
(docs/ai.md: the Anthropic API key of the "KI" features). The server keeps the blob,
one per name, and never sees what is in it."""
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='vault')
name = models.CharField(max_length=40)
ct = models.TextField()
iv = models.CharField(max_length=64)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
unique_together = ('user', 'name')
def _generate_token():
return secrets.token_hex(32)

View file

@ -656,3 +656,26 @@ class CloudInboxTests(TestCase):
self.assertEqual(r.status_code, 302)
self.src.refresh_from_db()
self.assertFalse(self.src.inbox)
class VaultTests(TestCase):
"""The vault keeps what the devices encrypted, per name; only for its owner."""
def test_keep_replace_remove(self):
u = User.objects.create_user('v', password='pw12345678')
self.client.force_login(u)
post = lambda body: self.client.post('/accounts/vault/ai/', json.dumps(body), content_type='application/json')
self.assertEqual(self.client.get('/accounts/vault/ai/').json(), {'ct': '', 'iv': ''})
self.assertEqual(post({'ct': 'abc', 'iv': 'i1'}).status_code, 200)
self.assertEqual(post({'ct': 'def', 'iv': 'i2'}).status_code, 200)
self.assertEqual(self.client.get('/accounts/vault/ai/').json(), {'ct': 'def', 'iv': 'i2'})
self.assertEqual(post({'iv': 'x'}).status_code, 400)
other = User.objects.create_user('w', password='pw12345678')
self.client.force_login(other)
self.assertEqual(self.client.get('/accounts/vault/ai/').json()['ct'], '')
self.client.force_login(u)
self.assertEqual(post({'ct': ''}).status_code, 200)
self.assertEqual(self.client.get('/accounts/vault/ai/').json()['ct'], '')
self.client.logout()
self.assertEqual(self.client.get('/accounts/vault/ai/').status_code, 401)

View file

@ -20,4 +20,5 @@ urlpatterns = [
path('webdav/<int:pk>/edit/', views.webdav_edit, name='webdav_edit'),
path('webdav/<int:pk>/test/', views.webdav_test, name='webdav_test'),
path('webdav/<int:pk>/delete/', views.webdav_delete, name='webdav_delete'),
path('vault/<slug:name>/', views.vault, name='vault'),
]

View file

@ -12,7 +12,7 @@ from django.views.decorators.http import require_http_methods
from radio import lastfm as lastfm_module
from .models import ApiToken, WebDAVSource
from .models import ApiToken, UserVault, WebDAVSource
from .webdav import WebDAVError, list_directory
User = get_user_model()
@ -341,3 +341,38 @@ def webdav_delete(request, pk):
if deleted:
messages.success(request, 'Verbindung entfernt.')
return redirect('settings')
# ---------------------------------------------------------------------------
# Vault: small device settings, end-to-end encrypted (docs/ai.md)
# ---------------------------------------------------------------------------
VAULT_MAX = 64 * 1024
@csrf_exempt
@require_http_methods(['GET', 'POST'])
def vault(request, name):
"""GET {ct, iv} ('' while there is none); POST {ct, iv} replaces it, {ct: ''} removes it.
The blob is encrypted on the devices with the book key; the server only keeps it."""
if not request.user.is_authenticated:
return JsonResponse({'error': 'authentication required'}, status=401)
if request.method == 'GET':
v = UserVault.objects.filter(user=request.user, name=name).first()
return JsonResponse({'ct': v.ct if v else '', 'iv': v.iv if v else ''})
if len(request.body) > VAULT_MAX:
return JsonResponse({'error': 'too large'}, status=413)
try:
body = json.loads(request.body or b'{}')
except (json.JSONDecodeError, ValueError):
return JsonResponse({'error': 'invalid JSON'}, status=400)
ct = body.get('ct') if isinstance(body, dict) else None
iv = body.get('iv') if isinstance(body, dict) else None
if ct == '':
UserVault.objects.filter(user=request.user, name=name).delete()
return JsonResponse({'ok': True})
if not isinstance(ct, str) or not isinstance(iv, str) or not iv or len(iv) > 64:
return JsonResponse({'error': 'ct and iv required'}, status=400)
UserVault.objects.update_or_create(user=request.user, name=name, defaults={'ct': ct, 'iv': iv})
return JsonResponse({'ok': True})

46
docs/ai.md Normal file
View file

@ -0,0 +1,46 @@
# KI: Claude schlägt vor, der Nutzer entscheidet
Since 2026-10-24. Organisation help with Claude (Anthropic) across diora: books and magazines
first; podcasts, news/blogs and projects follow (same building blocks).
## The key: the user's own, end-to-end encrypted, only on the devices
Decided with the user: the Anthropic API key is entered in the app ("KI-Assistent", the ✦ of a
feature or the app menu) and stored like the books — encrypted with the book key on the device,
kept by the server as a blob it can't read:
- `GET/POST /accounts/vault/<name>/` (`accounts.UserVault`, name `ai`): `{ct, iv}`; `{ct: ''}`
removes it. Its own store, not the library prefs (`/books/prefs/` is rewritten whole when
the folder order is saved).
- The devices decrypt it into memory only and call Claude **directly** from the page
(browser, the apps' WebView, the desktop). The diora server sees neither the key nor what is
sent; Anthropic sees what a feature needs (titles, authors, folder names …). Anthropic bills
the user.
- Without the book key on a device (or after a password change, which changes the book key)
the key can't be read: the settings ask for it again. Not on diora Reader (e-readers).
- Server-side jobs (in the background, without an app open) were left out on purpose: they
would need the key on the server. The project Radar has its own server key (docs/projects.md).
## Building blocks (`static/js/ai.js`, an ES module)
- The official SDK, `@anthropic-ai/sdk` 0.133.0, bundled for the browser with esbuild 0.28.2:
`static/vendor/anthropic-sdk-0.133.0.mjs` (licence beside it). `dangerouslyAllowBrowser`:
the key is the user's own. To update: `npm install @anthropic-ai/sdk@<v> esbuild` in an empty
folder, `esbuild entry.mjs --bundle --format=esm --platform=browser --target=es2020 --minify`
with `export { default } from '@anthropic-ai/sdk'`, and change the import in ai.js.
- `DioraAI.ask({system, prompt, schema, effort})`: model `claude-opus-5-5`, structured output
(`output_config.format` json_schema), effort `low` by default, streamed, `fallbacks: "default"`
(beta `server-side-fallback-2026-07-01`) for declined requests; refusals and over-long
answers become plain German messages.
- `DioraAI.review({title, intro, items, onApply})`: the suggestions with checkboxes — nothing
changes until the user ticks and presses "Übernehmen".
- `DioraAI.settings()`, `withKey(fn)`, `progress()`, `showError()`, `toast()`; the sheets close
with Android's back key; ⚙ in each sheet opens the settings.
## Features
| area | where | what Claude gets | what it suggests |
|---|---|---|---|
| Bücher, Zeitschriften | ✦ in the library's head (`static/js/app/55-library-tools.js`) | per book: id, title, author, folders, series, magazine mark; the existing folders | folders to add (existing first; new ones only for ≥ 3 books), series with volume, cleaned title/author where broken, magazine mark |
Applied through `_updateBookMeta` (the encrypted meta, as when editing by hand).

View file

@ -112,6 +112,18 @@ sakral (Religion & Mythologie), spiel (Kinder & Jugend), code (Informatik),
typo, wellen, botanik, berge, zellen — and bauhaus when nothing matches. A hash
of title and author picks palette, layout and every detail.
### Export as XML (since 2026-10-24)
⇩ in the library's head (`static/js/app/55-library-tools.js`) saves `diora-bibliothek-<date>.xml`:
made on the device from the list it has decrypted (nothing new goes to the server), every book
and magazine issue with its diora metadata — title, author, file name, ISBN, pages, shelf
category, series and volume, work (editions), magazine mark, cover choice, folders —, the
reading position (`<progress percent anchor read read-at last-read device furthest-percent
furthest-anchor>`), when it was uploaded, whether it has highlights, and an issue's articles with
their state. The folder order heads the file. Root `<diora-library version="1">`.
✦ beside it: ordering with Claude (docs/ai.md).
### Eingang: books from a cloud folder by themselves (since 2026-10-24)
A cloud source (Einstellungen → Cloud-Verbindungen, WebDAV: `accounts.WebDAVSource`,

284
static/js/ai.js Normal file
View file

@ -0,0 +1,284 @@
// ---------------------------------------------------------------------------
// The "KI" features (docs/ai.md): Claude suggests, the user decides.
//
// The Anthropic API key is the user's own. It lives end-to-end encrypted with the book key
// (POST /accounts/vault/ai/, like the books: the server keeps a blob it can't read) and is
// decrypted on the device into memory only. The device calls Claude directly — the diora
// server sees neither the key nor what is sent; Anthropic sees what a feature needs (titles,
// authors, folder names …). Nothing is changed without the user ticking it in review().
//
// window.DioraAI = {available(), hasKey(), settings(), ask({system, prompt, schema}), review(…)}
// ---------------------------------------------------------------------------
import Anthropic from '/static/vendor/anthropic-sdk-0.133.0.mjs';
const MODEL = 'claude-opus-5-5';
const VAULT = '/accounts/vault/ai/';
const KEY_HELP = 'https://console.anthropic.com/settings/keys';
let apiKey = null; // decrypted, in memory only
let loaded = false;
const html = document.documentElement;
const esc = s => String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
// The book key of this device: never make one (getOrCreateEncKey would), the vault needs the real one.
function bookKeyThere() {
try { return !!localStorage.getItem(`diora_enc_key_${window.USER_ID || 0}`); } catch (e) { return false; }
}
function available() {
return typeof IS_AUTHENTICATED !== 'undefined' && !!IS_AUTHENTICATED && !html.classList.contains('nui-reader-app') &&
bookKeyThere() && typeof getOrCreateEncKey === 'function';
}
async function loadKey(fresh) {
if (loaded && !fresh) return apiKey;
if (!available()) return null;
try {
const r = await fetch(VAULT, {cache: 'no-store'});
if (!r.ok) return apiKey;
const d = await r.json();
apiKey = null;
if (d.ct) {
const plain = await decryptBytes(await getOrCreateEncKey(), d.iv, d.ct);
apiKey = JSON.parse(new TextDecoder().decode(plain)).anthropic_key || null;
}
loaded = true;
} catch (e) {
// Offline, or encrypted with a key this device doesn't have (the password changed): ask again.
loaded = false;
}
return apiKey;
}
async function saveKey(key) {
const enc = await encryptBytes(await getOrCreateEncKey(), new TextEncoder().encode(JSON.stringify({anthropic_key: key})));
const r = await fetch(VAULT, {method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ct: enc.ciphertext, iv: enc.iv})});
if (!r.ok) throw new Error('Speichern ging nicht (' + r.status + ')');
apiKey = key;
loaded = true;
}
async function removeKey() {
await fetch(VAULT, {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ct: ''})});
apiKey = null;
loaded = true;
}
function client(key) {
// In the page (browser, the apps' WebView): the key is the user's own and stays on the device.
return new Anthropic({apiKey: key, dangerouslyAllowBrowser: true});
}
// A plain German message for what went wrong.
function explain(e) {
if (e && e.diora) return e.message;
if (e instanceof Anthropic.AuthenticationError || e instanceof Anthropic.PermissionDeniedError) {
return 'Der API-Key wird nicht angenommen – in den KI-Einstellungen prüfen.';
}
if (e instanceof Anthropic.RateLimitError) return 'Gerade zu viele Anfragen – bitte gleich noch einmal.';
if (e instanceof Anthropic.APIConnectionError) return navigator.onLine ? 'Claude war nicht erreichbar.' : 'Keine Verbindung.';
if (e instanceof Anthropic.APIError) return 'Claude antwortete mit einem Fehler (' + (e.status || '?') + ').';
return (e && e.message) || 'Unbekannter Fehler.';
}
function fail(message) {
const e = new Error(message);
e.diora = true;
return e;
}
// One request with a JSON answer of `schema` (structured outputs). Long lists stream.
async function ask({system, prompt, schema, effort = 'low', maxTokens = 64000}) {
const key = await loadKey();
if (!key) throw fail('Kein API-Key hinterlegt.');
const stream = client(key).beta.messages.stream({
model: MODEL,
max_tokens: maxTokens,
betas: ['server-side-fallback-2026-07-01'],
fallbacks: 'default',
system,
messages: [{role: 'user', content: prompt}],
output_config: {effort, format: {type: 'json_schema', schema}},
});
const msg = await stream.finalMessage();
if (msg.stop_reason === 'refusal') throw fail('Claude hat diese Anfrage abgelehnt.');
if (msg.stop_reason === 'max_tokens') throw fail('Die Antwort wurde zu lang – bitte einen kleineren Teil ordnen.');
const text = msg.content.filter(b => b.type === 'text').map(b => b.text).join('');
try {
return JSON.parse(text);
} catch (e) {
throw fail('Die Antwort ließ sich nicht lesen.');
}
}
// ---- a sheet of our own (settings, progress, review) --------------------------------------
function sheet(title, noGear) {
close();
const wrap = document.createElement('div');
wrap.id = 'dai-sheet';
wrap.innerHTML = `<div class="dai-panel" role="dialog" aria-label="${esc(title)}">
<div class="dai-top"><strong>${esc(title)}</strong><span>${noGear ? '' : '<button type="button" class="dai-x dai-gear" aria-label="KI-Einstellungen" title="KI-Einstellungen">⚙</button>'}<button type="button" class="dai-x dai-close" aria-label="Schließen">✕</button></span></div>
<div class="dai-body"></div></div>`;
wrap.addEventListener('click', e => {
if (e.target.closest('.dai-gear')) { settings(); return; }
if (e.target === wrap || e.target.closest('.dai-close')) close();
});
document.body.append(wrap);
return wrap.querySelector('.dai-body');
}
function close() {
const s = document.getElementById('dai-sheet');
if (s) s.remove();
return !!s;
}
async function settings(after) {
const body = sheet('KI-Assistent', true);
const has = !!(await loadKey(true));
body.innerHTML = `
<p>diora kann mit Claude (Anthropic) Vorschläge zum Ordnen machen – Bücher, Podcasts, Nachrichten, Projekte.
Du entscheidest bei jedem Vorschlag, ob er übernommen wird.</p>
<p class="dai-muted">Dein eigener API-Key: Er wird mit deinem Buchschlüssel verschlüsselt gespeichert – der diora-Server
kann ihn nicht lesen. Deine Geräte fragen Claude direkt; Anthropic sieht dabei, was eine Funktion braucht
(z. B. Titel und Autoren), der diora-Server nichts davon. Die Kosten rechnet Anthropic mit dir ab.
Einen Key legst du unter <a href="${KEY_HELP}" target="_blank" rel="noopener">console.anthropic.com</a> an.</p>
<label class="dai-field"><span>${has ? 'Key ersetzen' : 'API-Key'}</span>
<input type="password" class="dai-key" autocomplete="off" placeholder="${has ? '•••• hinterlegt' : 'sk-ant-…'}"></label>
<div class="dai-row">
<button type="button" class="btn btn-primary dai-save">Speichern</button>
${has ? '<button type="button" class="btn dai-remove">Key entfernen</button>' : ''}
<span class="dai-status dai-muted"></span>
</div>`;
const status = body.querySelector('.dai-status');
body.querySelector('.dai-save').addEventListener('click', async () => {
const key = body.querySelector('.dai-key').value.trim();
if (!key) { status.textContent = 'Bitte einen Key eingeben.'; return; }
status.textContent = 'Wird geprüft …';
try {
await client(key).models.retrieve(MODEL); // checks the key, costs nothing
} catch (e) {
status.textContent = explain(e);
return;
}
try {
await saveKey(key);
} catch (e) {
status.textContent = e.message;
return;
}
status.textContent = '✓ Gespeichert';
setTimeout(() => { close(); if (after) after(); }, 600);
});
const rm = body.querySelector('.dai-remove');
if (rm) rm.addEventListener('click', async () => { await removeKey(); close(); });
}
// The key first; then fn.
async function withKey(fn) {
if (!available()) return;
if (await loadKey()) fn();
else settings(fn);
}
function progress(title, text) {
const body = sheet(title);
body.innerHTML = `<p class="dai-wait"><span class="dai-spin"></span> ${esc(text)}</p>`;
return body;
}
function showError(title, e) {
const body = sheet(title);
body.innerHTML = `<p>${esc(explain(e))}</p><div class="dai-row"><button type="button" class="btn dai-keys">KI-Einstellungen</button></div>`;
body.querySelector('.dai-keys').addEventListener('click', () => settings());
}
// Suggestions to tick: items [{id, title, lines: [text], checked}], groups optional (item.group).
// onApply(ids) does the work and returns a short message.
function review({title, intro, items, applyLabel = 'Übernehmen', onApply}) {
const body = sheet(title);
if (!items.length) {
body.innerHTML = `<p>${esc(intro || '')}</p><p>Claude hat nichts gefunden, das sich zu ändern lohnt.</p>`;
return;
}
let h = intro ? `<p class="dai-muted">${esc(intro)}</p>` : '';
h += `<div class="dai-row dai-all"><label><input type="checkbox" class="dai-toggle" checked> Alle</label>
<span class="dai-muted">${items.length} Vorschläge</span></div><div class="dai-list">`;
let group = null;
items.forEach((it, i) => {
if (it.group && it.group !== group) { group = it.group; h += `<div class="dai-group">${esc(group)}</div>`; }
h += `<label class="dai-item"><input type="checkbox" data-i="${i}"${it.checked === false ? '' : ' checked'}>
<span><strong>${esc(it.title)}</strong>${(it.lines || []).map(l => `<span class="dai-line">${esc(l)}</span>`).join('')}</span></label>`;
});
h += `</div><div class="dai-row dai-foot"><button type="button" class="btn btn-primary dai-apply">${esc(applyLabel)}</button>
<span class="dai-status dai-muted"></span></div>`;
body.innerHTML = h;
const boxes = () => [...body.querySelectorAll('.dai-item input')];
body.querySelector('.dai-toggle').addEventListener('change', e => boxes().forEach(b => { b.checked = e.target.checked; }));
body.querySelector('.dai-apply').addEventListener('click', async e => {
const ids = boxes().filter(b => b.checked).map(b => items[Number(b.dataset.i)].id);
if (!ids.length) { close(); return; }
e.target.disabled = true;
const status = body.querySelector('.dai-status');
status.textContent = 'Wird übernommen …';
try {
const msg = await onApply(ids);
close();
if (msg && window.DioraAI.toast) window.DioraAI.toast(msg);
} catch (err) {
status.textContent = explain(err);
e.target.disabled = false;
}
});
}
function toast(text) {
let t = document.getElementById('dai-toast');
if (!t) { t = document.createElement('div'); t.id = 'dai-toast'; document.body.append(t); }
t.textContent = text;
t.classList.add('on');
clearTimeout(t._h);
t._h = setTimeout(() => t.classList.remove('on'), 3500);
}
const style = document.createElement('style');
style.textContent = [
'#dai-sheet { position: fixed; inset: 0; z-index: 3200; background: rgba(0,0,0,.45); display: flex; align-items: flex-end; justify-content: center; }',
'@media (min-width: 720px) { #dai-sheet { align-items: center; } }',
'#dai-sheet .dai-panel { background: var(--panel, #111); color: var(--fg, #eee); width: min(640px, 100%); max-height: 88vh; display: flex; flex-direction: column; border-radius: 12px 12px 0 0; box-shadow: 0 -4px 24px rgba(0,0,0,.4); }',
'@media (min-width: 720px) { #dai-sheet .dai-panel { border-radius: 12px; } }',
'#dai-sheet .dai-top { display: flex; align-items: center; justify-content: space-between; padding: 14px 16px 6px; }',
'#dai-sheet .dai-x { background: none; border: none; color: inherit; font-size: 18px; min-width: 40px; min-height: 40px; cursor: pointer; }',
'#dai-sheet .dai-body { padding: 0 16px 16px; overflow-y: auto; line-height: 1.45; }',
'#dai-sheet .dai-muted { color: var(--muted, #999); font-size: 13px; }',
'#dai-sheet .dai-field { display: flex; flex-direction: column; gap: 4px; margin: 12px 0; }',
'#dai-sheet .dai-field input { padding: 9px 10px; border-radius: 6px; border: 1px solid var(--border-strong, #444); background: var(--raised, #1a1a1a); color: inherit; font: inherit; }',
'#dai-sheet .dai-row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin: 10px 0; }',
'#dai-sheet .dai-list { display: flex; flex-direction: column; }',
'#dai-sheet .dai-item { display: flex; gap: 10px; align-items: flex-start; padding: 8px 0; border-bottom: 1px solid var(--border, #2a2a2a); cursor: pointer; }',
'#dai-sheet .dai-item input { margin-top: 3px; flex: none; }',
'#dai-sheet .dai-line { display: block; font-size: 13px; color: var(--muted, #999); }',
'#dai-sheet .dai-group { margin: 14px 0 2px; font-size: 12px; text-transform: uppercase; letter-spacing: .08em; color: var(--muted, #999); }',
'#dai-sheet .dai-foot { position: sticky; bottom: 0; background: var(--panel, #111); padding: 10px 0 0; margin: 0; }',
'#dai-sheet .dai-spin { display: inline-block; width: 14px; height: 14px; border: 2px solid var(--muted, #999); border-top-color: transparent; border-radius: 50%; animation: dai-spin 1s linear infinite; vertical-align: -2px; margin-right: 6px; }',
'@keyframes dai-spin { to { transform: rotate(360deg); } }',
'#dai-toast { position: fixed; left: 50%; bottom: calc(var(--nui-dock-h, 0px) + 80px); transform: translateX(-50%); z-index: 3300; background: var(--raised, #222); color: var(--fg, #eee); padding: 10px 16px; border-radius: 8px; font-size: 14px; opacity: 0; pointer-events: none; transition: opacity .2s; max-width: calc(100vw - 32px); }',
'#dai-toast.on { opacity: 1; }',
'.dai-btn { font-size: 16px; }',
].join('\n');
document.head.append(style);
// Android's back key closes the sheet first.
const back = window.__dioraBack;
window.__dioraBack = function () {
if (close()) return true;
return back ? back.apply(this, arguments) : false;
};
window.DioraAI = {available, hasKey: async () => !!(await loadKey()), settings, withKey, ask, review, progress, showError,
close, toast, explain};
window.dispatchEvent(new Event('diora-ai'));

View file

@ -0,0 +1,237 @@
'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`;
},
});
});
}

View file

@ -2,7 +2,7 @@
* diora service worker — caches the app shell for offline use.
*/
const CACHE = 'diora-v147';
const CACHE = 'diora-v148';
const SHELL = [
'/static/css/app.css',
'/static/js/app/01-core.js',
@ -25,6 +25,7 @@ const SHELL = [
'/static/js/app/52-book-list.js',
'/static/js/app/53-library.js',
'/static/js/app/54-issues.js',
'/static/js/app/55-library-tools.js',
'/static/js/app/56-cloud-import.js',
'/static/js/app/57-cloud-inbox.js',
'/static/js/app/58-pdf.js',

20
static/vendor/anthropic-sdk-0.133.0.mjs vendored Normal file

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,8 @@
Copyright 2023 Anthropic, PBC.
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

View file

@ -380,6 +380,7 @@
<script src="/static/js/app/52-book-list.js"></script>
<script src="/static/js/app/53-library.js"></script>
<script src="/static/js/app/54-issues.js"></script>
<script src="/static/js/app/55-library-tools.js"></script>
<script src="/static/js/app/56-cloud-import.js"></script>
<script src="/static/js/app/57-cloud-inbox.js"></script>
<script src="/static/js/app/58-pdf.js"></script>
@ -395,6 +396,8 @@
<script src="/static/js/app/78-footnotes.js"></script>
<script src="/static/js/app/80-radio-sidebar.js"></script>
<script src="/static/js/app/99-init.js"></script>
{% if user.is_authenticated %}{# The KI features (docs/ai.md): the user's own key, calls from the device. #}
<script type="module" src="/static/js/ai.js"></script>{% endif %}
<script src="/static/js/podcasts-web.js"></script>
<script src="/static/js/home.js"></script>
<script src="/static/js/feeds.js"></script>