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>
This commit is contained in:
parent
a73ade5422
commit
0ba16a40ff
13 changed files with 714 additions and 2 deletions
28
accounts/migrations/0007_uservault.py
Normal file
28
accounts/migrations/0007_uservault.py
Normal 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')},
|
||||
},
|
||||
),
|
||||
]
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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
46
docs/ai.md
Normal 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).
|
||||
|
|
@ -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
284
static/js/ai.js
Normal 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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
|
||||
// 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'));
|
||||
237
static/js/app/55-library-tools.js
Normal file
237
static/js/app/55-library-tools.js
Normal 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, '&').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`;
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -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
20
static/vendor/anthropic-sdk-0.133.0.mjs
vendored
Normal file
File diff suppressed because one or more lines are too long
8
static/vendor/anthropic-sdk-LICENSE.txt
vendored
Normal file
8
static/vendor/anthropic-sdk-LICENSE.txt
vendored
Normal 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.
|
||||
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue