diora-web/static/js/projects.js
mrwnslz c8fd139b44
Some checks failed
Build and push Docker image / build (push) Successful in 1m11s
Test / test (push) Successful in 10m32s
Test / e2e (push) Failing after 1m31s
Projekte: Radar – wöchentlich neue Papers (PubMed, arXiv, bioRxiv, medRxiv), von Claude im Batch sortiert, Entscheidungen verschlüsselt, → Quellen; Preset Neuropolitik aus F:\neuropolitik\tools; sw v145
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 20:43:43 +02:00

1417 lines
74 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/*
* Projekte (docs/projects.md): projects of a preset — Forschung first: sources (papers by
* DOI, books, podcast episodes with a time, articles) with citation data and keys, notes in
* Markdown, export as BibTeX / CSL-JSON. End-to-end encrypted like the books: the project's
* name and every item's content are AES-GCM blobs (32-crypto.js), the server keeps them.
*
* In the navigation (nav.js) above Suche at the foot of the sidebar, a tab on a phone.
* Anything elsewhere joins a project through DioraProjects.link(kind, ref, info) — the book,
* episode and article sheets call it; DioraProjects.of(kind, ref) says where it is.
* On the desktop a project opens in a window of its own (dioraDesktop.openWindow).
* Chrome-61-safe: no ?. / ?? / optional catch binding.
*/
(function () {
'use strict';
const tab = document.getElementById('tab-projekte');
if (!tab) return;
const PRESETS = {
research: {
label: 'Forschung',
hint: 'Papers, Bücher, Folgen und Artikel als Quellen, Zitationen, Notizen.',
// Radar only where the project has one (a server-side config, docs/projects.md).
tabs: [['radar', 'Radar'], ['sources', 'Quellen'], ['notes', 'Notizen']],
},
};
const KIND = {
paper: {label: 'Paper', many: 'Papers', glyph: '¶'},
book: {label: 'Buch', many: 'Bücher', glyph: '▤'},
episode: {label: 'Folge', many: 'Folgen', glyph: '◎'},
article: {label: 'Artikel', many: 'Artikel', glyph: '✎'},
};
const SOURCE_KINDS = ['paper', 'book', 'episode', 'article'];
const st = {
list: null, // [{id, preset, data, archived, items, updated_at}]
links: [], // [{project, kind, ref}]
error: '',
open: null, // {id, preset, data, iv, items: [{id, kind, ref, data, iv, …}]}
view: 'sources',
filter: 'all',
expanded: null, // item id whose actions are shown
note: null, // the note being edited: item id, or 'new'
preview: false,
adding: null, // {q, loading, error, doi: {csl}, found: [...]}
creating: null, // {preset}
menu: false,
};
// A window of its own (desktop): only this project, no navigation.
const solo = /[?&]project=(\d+)/.exec(location.search);
if (solo) document.documentElement.classList.add('prj-solo');
// ---- helpers -------------------------------------------------------------------------
function esc(s) {
return String(s == null ? '' : s).replace(/[&<>"']/g, c =>
({'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'}[c]));
}
async function api(path, body) {
const opts = {credentials: 'same-origin', cache: 'no-store'};
if (body !== undefined) {
opts.method = 'POST';
opts.headers = {'Content-Type': 'application/json'};
opts.body = JSON.stringify(body);
}
const r = await fetch(path, opts);
const d = await r.json().catch(() => null);
if (r.status === 409 && d) { const e = new Error('stale'); e.stale = d; throw e; }
if (!r.ok || !d) throw new Error((d && d.error) || ('HTTP ' + r.status));
return d;
}
async function seal(obj) {
const key = await getOrCreateEncKey();
const out = await encryptBytes(key, new TextEncoder().encode(JSON.stringify(obj)));
return {data_ct: out.ciphertext, data_iv: out.iv};
}
async function open_(ct, iv) {
try {
const key = await getOrCreateEncKey();
return JSON.parse(new TextDecoder().decode(await decryptBytes(key, iv, ct)));
} catch (e) {
return null; // another key (an old device?): shown as unreadable
}
}
function toast(text) {
let t = document.getElementById('prj-toast');
if (!t) {
t = document.createElement('div');
t.id = 'prj-toast';
document.body.appendChild(t);
}
t.textContent = text;
t.classList.add('prj-show');
clearTimeout(toast.timer);
toast.timer = setTimeout(() => t.classList.remove('prj-show'), 2800);
}
function go(id) {
if (window.DioraNav) window.DioraNav.go(id);
else if (typeof showTab === 'function') showTab(id);
}
function nameOf(p) {
return p && p.data ? (p.data.name || 'Ohne Namen') : 'Nicht lesbar';
}
function download(name, text, type) {
const a = document.createElement('a');
a.href = URL.createObjectURL(new Blob([text], {type: type}));
a.download = name;
document.body.appendChild(a);
a.click();
setTimeout(() => { URL.revokeObjectURL(a.href); a.remove(); }, 1000);
}
async function copy(text) {
try {
await navigator.clipboard.writeText(text);
return true;
} catch (e) {
const t = document.createElement('textarea');
t.value = text;
document.body.appendChild(t);
t.select();
let ok = false;
try { ok = document.execCommand('copy'); } catch (e2) {}
t.remove();
return ok;
}
}
// ---- citation data (CSL-JSON) ----------------------------------------------------------
function year(csl) {
const d = csl && (csl.issued || csl.published || csl['published-print'] || csl['published-online']);
const p = d && d['date-parts'] && d['date-parts'][0];
return p && p[0] ? String(p[0]) : '';
}
function dateParts(iso) {
const d = iso ? new Date(iso) : null;
return d && !isNaN(d) ? {'date-parts': [[d.getFullYear(), d.getMonth() + 1, d.getDate()]]} : undefined;
}
function names(text) {
// "Karl Marx, Friedrich Engels" / "Marx, Karl; Engels, Friedrich" → CSL names.
const s = String(text || '').trim();
if (!s) return [];
const parts = s.indexOf(';') >= 0 ? s.split(';') : s.split(/,\s*(?=[^,]+\s)|\s+(?:und|and|&)\s+/);
return parts.map(x => x.trim()).filter(Boolean).map(x => {
if (x.indexOf(',') > 0) {
const p = x.split(',');
return {family: p[0].trim(), given: p.slice(1).join(',').trim()};
}
const w = x.split(/\s+/);
return w.length > 1 ? {family: w[w.length - 1], given: w.slice(0, -1).join(' ')} : {literal: x};
});
}
function authorsText(csl, short) {
const a = (csl && (csl.author || csl.editor)) || [];
const n = a.map(x => x.family || x.literal || '').filter(Boolean);
if (!n.length) return '';
if (short) return n.length > 2 ? n[0] + ' u. a.' : n.join(' & ');
return n.length > 4 ? n.slice(0, 3).join(', ') + ' u. a.' : n.join(', ');
}
function cslTitle(csl) {
const t = csl && csl.title;
return Array.isArray(t) ? (t[0] || '') : (t || '');
}
function container(csl) {
const c = csl && csl['container-title'];
return Array.isArray(c) ? (c[0] || '') : (c || '');
}
// The citation data of a linked thing, from what diora knows of it.
function cslFor(kind, info) {
if (kind === 'book') {
const csl = {type: 'book', title: info.title || '', author: names(info.author)};
if (info.isbn) csl.ISBN = info.isbn;
if (info.series) csl['collection-title'] = info.series;
if (info.seriesIndex) csl['collection-number'] = String(info.seriesIndex);
return csl;
}
if (kind === 'episode') {
// Without hosts named, the podcast stands for them ("Wissenschaft im Brennpunkt (2025)").
const who = names(info.author);
return {type: 'broadcast', genre: 'Podcast', title: info.title || '', 'container-title': info.podcast || '',
author: who.length ? who : (info.podcast ? [{literal: info.podcast}] : []), issued: dateParts(info.date),
URL: info.url || undefined};
}
if (kind === 'article') {
return {type: info.news ? 'article-newspaper' : 'post-weblog', title: info.title || '',
'container-title': info.source || '', author: names(info.author), issued: dateParts(info.date),
URL: info.url || undefined};
}
return {};
}
// A key for [@key]: family name of the first author, year, first word of the title.
function makeKey(csl, taken) {
const fold = s => String(s || '').normalize('NFKD').replace(/[̀-ͯ]/g, '')
.replace(/ß/g, 'ss').replace(/[^A-Za-z0-9]+/g, '');
const a = (csl.author || csl.editor || [])[0];
const who = fold(a ? (a.family || a.literal) : '').toLowerCase() || 'anon';
const skip = /^(the|a|an|der|die|das|ein|eine|on|of|zur|zum|uber|über)$/i;
const word = (cslTitle(csl).split(/\s+/).filter(w => !skip.test(w))[0] || '');
let key = who + (year(csl) || 'oj') + fold(word).toLowerCase().slice(0, 12);
let n = 0;
const base = key;
while (taken[key]) key = base + String.fromCharCode(97 + (n++ % 26)) + (n > 26 ? n : '');
return key;
}
function takenKeys(exceptId) {
const t = {};
((st.open && st.open.items) || []).forEach(i => {
if (i.id !== exceptId && i.data && i.data.key) t[i.data.key] = true;
});
return t;
}
const BIB_TYPE = {'article-journal': 'article', 'paper-conference': 'inproceedings', book: 'book',
chapter: 'incollection', thesis: 'phdthesis', report: 'techreport', 'posted-content': 'misc',
broadcast: 'misc', 'post-weblog': 'online', 'article-newspaper': 'article', webpage: 'online'};
function bibtex(items) {
const field = (k, v) => v ? ' ' + k + ' = {' + String(v).replace(/[{}]/g, '') + '},\n' : '';
return items.map(i => {
const c = i.data.csl || {};
const people = (c.author || []).map(a => a.literal || ((a.family || '') + (a.given ? ', ' + a.given : ''))).join(' and ');
return '@' + (BIB_TYPE[c.type] || 'misc') + '{' + i.data.key + ',\n' +
field('author', people) + field('title', cslTitle(c)) +
field(c.type === 'article-journal' || c.type === 'article-newspaper' ? 'journal' : 'booktitle', container(c)) +
field('year', year(c)) + field('volume', c.volume) + field('number', c.issue) + field('pages', c.page) +
field('publisher', c.publisher) + field('series', c['collection-title']) + field('isbn', c.ISBN) +
field('doi', c.DOI) + field('url', c.URL) +
field('note', i.kind === 'episode' && i.data.position ? 'ab ' + clock(i.data.position) : '') + '}\n';
}).join('\n');
}
function cslJson(items) {
return JSON.stringify(items.map(i => Object.assign({}, i.data.csl || {}, {id: i.data.key})), null, 2);
}
function clock(sec) {
sec = Math.max(0, Math.round(sec || 0));
const h = Math.floor(sec / 3600), m = Math.floor(sec / 60) % 60, s = sec % 60;
const two = n => (n < 10 ? '0' : '') + n;
return (h ? h + ':' + two(m) : m) + ':' + two(s);
}
// ---- Markdown (the notes) ----------------------------------------------------------------
function inline(s, cite) {
s = esc(s);
s = s.replace(/`([^`]+)`/g, '<code>$1</code>');
s = s.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>').replace(/(^|[^*])\*([^*\s][^*]*)\*/g, '$1<em>$2</em>');
s = s.replace(/\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>');
s = s.replace(/(^|[\s(])(https?:\/\/[^\s<]+[^\s<.,;)])/g, '$1<a href="$2" target="_blank" rel="noopener">$2</a>');
// [@key] and [@key, S. 12]: the source, by author and year.
s = s.replace(/\[@([\w:.\-]+)(?:,\s*([^\]]+))?\]/g, (m, key, loc) => {
const i = cite ? cite[key] : null;
const label = i ? (authorsText(i.data.csl, true) || cslTitle(i.data.csl)) + ' ' + year(i.data.csl) : '@' + key;
return '<span class="prj-cite' + (i ? '' : ' prj-cite-missing') + '" data-cite="' + key + '">(' + label.trim() +
(loc ? ', ' + loc : '') + ')</span>';
});
return s;
}
function markdown(text, cite) {
const lines = String(text || '').replace(/\r/g, '').split('\n');
let out = '', list = null, para = [], quote = [], table = null;
const flush = () => {
if (table) {
out += '<div class="prj-table"><table>' + table.map((row, n) => '<tr>' + row.map(c =>
(n ? '<td>' : '<th>') + inline(c, cite) + (n ? '</td>' : '</th>')).join('') + '</tr>').join('') + '</table></div>';
table = null;
}
if (para.length) { out += '<p>' + inline(para.join(' '), cite) + '</p>'; para = []; }
if (quote.length) { out += '<blockquote>' + inline(quote.join(' '), cite) + '</blockquote>'; quote = []; }
if (list) { out += '</' + list + '>'; list = null; }
};
for (const raw of lines) {
const line = raw.replace(/\s+$/, '');
let m;
if (!line.trim()) { flush(); continue; }
// A table: | a | b |, under its head a | --- | --- | line.
if (/^\s*\|.*\|$/.test(line)) {
if (!table) { flush(); table = []; }
if (!/^\s*\|[\s:|-]+\|$/.test(line)) table.push(line.trim().replace(/^\||\|$/g, '').split('|').map(c => c.trim()));
continue;
}
if (table) flush();
if ((m = /^(#{1,4})\s+(.*)$/.exec(line))) { flush(); out += '<h' + (m[1].length + 2) + '>' + inline(m[2], cite) + '</h' + (m[1].length + 2) + '>'; continue; }
if ((m = /^\s*[-*+]\s+(?:\[( |x)\]\s+)?(.*)$/i.exec(line))) {
if (para.length || quote.length || list !== 'ul') { flush(); out += '<ul>'; list = 'ul'; }
out += '<li>' + (m[1] ? (m[1] === ' ' ? '☐ ' : '☑ ') : '') + inline(m[2], cite) + '</li>';
continue;
}
if ((m = /^\s*\d+[.)]\s+(.*)$/.exec(line))) {
if (para.length || quote.length || list !== 'ol') { flush(); out += '<ol>'; list = 'ol'; }
out += '<li>' + inline(m[1], cite) + '</li>';
continue;
}
if ((m = /^>\s?(.*)$/.exec(line))) { if (para.length || list) flush(); quote.push(m[1]); continue; }
if (/^(-{3,}|\*{3,})$/.test(line.trim())) { flush(); out += '<hr>'; continue; }
if (list) flush();
para.push(line.trim());
}
flush();
return out;
}
// ---- loading -----------------------------------------------------------------------------
async function loadList() {
try {
const d = await api('/projects/');
const list = [];
for (const p of d.projects) {
list.push({id: p.id, preset: p.preset, archived: p.archived, items: p.items, updated_at: p.updated_at,
position: p.position, iv: p.data_iv, data: await open_(p.data_ct, p.data_iv)});
}
st.list = list;
st.links = d.links || [];
st.error = '';
} catch (e) {
if (st.list === null) st.list = [];
st.error = navigator.onLine === false ? 'Offline: Projekte brauchen gerade eine Verbindung.' : 'Projekte nicht erreichbar.';
}
linksChanged();
}
async function openProject(id, keepView) {
const d = await api('/projects/' + id + '/');
const items = [];
for (const i of d.items) items.push(Object.assign({}, i, {iv: i.data_iv, data: await open_(i.data_ct, i.data_iv)}));
let radar = {radar: null, digests: []};
try { radar = await api('/projects/' + id + '/radar/'); } catch (e) {}
st.open = {id: d.project.id, preset: d.project.preset, archived: d.project.archived, iv: d.project.data_iv,
data: await open_(d.project.data_ct, d.project.data_iv), items: items, radar: radar.radar, digests: radar.digests || []};
if (!keepView) {
st.view = st.open.radar ? 'radar' : 'sources';
st.digest = 0;
st.radarShow = 'all';
st.filter = 'all';
st.expanded = null;
st.note = null;
st.adding = null;
st.menu = false;
}
setHash();
render();
}
function setHash() {
if (solo || !st.open) return;
const want = '#projekte/' + st.open.id;
if (location.hash !== want && /^#projekte/.test(location.hash || '#projekte')) history.replaceState(null, '', want);
}
// ---- changing ----------------------------------------------------------------------------
async function addItem(kind, ref, data, projectId) {
const pid = projectId || st.open.id;
const body = Object.assign({kind: kind}, ref != null ? {ref: ref} : {}, await seal(data));
const d = await api('/projects/' + pid + '/items/add/', body);
if (!d.existing && ref != null) st.links.push({project: pid, kind: kind, ref: ref});
if (st.open && st.open.id === pid && !d.existing) {
st.open.items.push(Object.assign({}, d.item, {iv: d.item.data_iv, data: data}));
}
const p = (st.list || []).filter(x => x.id === pid)[0];
if (p && !d.existing) p.items++;
linksChanged();
return d;
}
// Save an item's data; changed elsewhere since: theirs is kept, ours merged onto it where
// that's clear (a note's text: a copy beside it).
async function saveItem(item, data) {
try {
const d = await api('/projects/items/' + item.id + '/', Object.assign({base_iv: item.iv}, await seal(data)));
item.iv = d.item.data_iv;
item.data = data;
return true;
} catch (e) {
if (!e.stale) throw e;
const theirs = e.stale.item;
item.iv = theirs.data_iv;
item.data = await open_(theirs.data_ct, theirs.data_iv);
if (item.kind === 'note') {
await addItem('note', null, Object.assign({}, data, {title: (data.title || 'Notiz') + ' (Konflikt)'}));
toast('Die Notiz wurde auch anderswo geändert: deine Fassung liegt als Kopie daneben.');
} else {
toast('Anderswo geändert: die neuere Fassung ist geladen.');
}
return false;
}
}
async function removeItem(item) {
await api('/projects/items/' + item.id + '/delete/', {});
st.open.items = st.open.items.filter(i => i.id !== item.id);
st.links = st.links.filter(l => !(l.project === st.open.id && l.kind === item.kind && l.ref === item.ref));
const p = (st.list || []).filter(x => x.id === st.open.id)[0];
if (p) p.items = Math.max(0, p.items - 1);
linksChanged();
}
async function saveProject(data) {
try {
const d = await api('/projects/' + st.open.id + '/', Object.assign({base_iv: st.open.iv}, await seal(data)));
st.open.iv = d.project.data_iv;
st.open.data = data;
} catch (e) {
if (!e.stale) throw e;
st.open.iv = e.stale.project.data_iv;
st.open.data = await open_(e.stale.project.data_ct, e.stale.project.data_iv);
toast('Anderswo geändert: die neuere Fassung ist geladen.');
}
const p = (st.list || []).filter(x => x.id === st.open.id)[0];
if (p) { p.data = st.open.data; p.iv = st.open.iv; }
}
async function createProject(preset, name, description) {
const d = await api('/projects/create/', Object.assign({preset: preset}, await seal({name: name, description: description || ''})));
await loadList();
await openProject(d.project.id);
}
// ---- linking from elsewhere ----------------------------------------------------------------
const linkListeners = [];
function linksChanged() {
linkListeners.slice().forEach(fn => { try { fn(); } catch (e) {} });
}
// What diora knows of a book (its meta is on this device only).
function bookInfo(id) {
const m = (typeof bookMetaCache !== 'undefined' && bookMetaCache[id]) || {};
return {title: m.title || ('Buch #' + id), author: m.author || '', isbn: m.isbn || '', series: m.series || '',
seriesIndex: m.seriesIndex || '', type: m.type || ''};
}
function snapshot(kind, ref, info) {
info = info || {};
if (kind === 'book') info = Object.assign(bookInfo(ref), info);
const csl = cslFor(kind, info);
const data = {csl: csl, note: ''};
if (kind === 'episode') {
data.podcast = info.podcast || '';
data.feed = info.feed || null;
if (info.position > 5) data.position = Math.round(info.position);
}
if (kind === 'book') data.type = info.type;
return data;
}
let picker = null;
function closePicker() {
if (picker) { picker.remove(); picker = null; }
}
// The sheet "Mit Projekt verknüpfen": the projects, where it is already ticked.
async function link(kind, ref, info) {
if (!SOURCE_KINDS.some(k => k === kind)) return;
if (st.list === null) await loadList();
closePicker();
picker = document.createElement('div');
picker.className = 'prj-picker';
const projects = (st.list || []).filter(p => !p.archived && p.data);
const inIt = id => st.links.some(l => l.project === id && l.kind === kind && l.ref === Number(ref));
picker.innerHTML = '<div class="prj-picker-box" role="dialog" aria-label="Mit Projekt verknüpfen">' +
'<div class="prj-picker-h">Mit Projekt verknüpfen</div>' +
(info && info.title ? '<div class="prj-picker-what">' + esc(info.title) +
(info.position > 5 ? ' · ab ' + clock(info.position) : '') + '</div>' : '') +
projects.map(p => '<button type="button" class="prj-picker-p" data-pick="' + p.id + '"' + (inIt(p.id) ? ' disabled' : '') + '>' +
'<span>' + esc(nameOf(p)) + '</span><span class="prj-muted">' + (inIt(p.id) ? '✓ verknüpft' : esc(PRESETS[p.preset] ? PRESETS[p.preset].label : '')) +
'</span></button>').join('') +
(projects.length ? '' : '<p class="prj-muted">Noch kein Projekt.</p>') +
'<button type="button" class="prj-picker-p prj-picker-new" data-pick="new">+ Neues Projekt …</button>' +
'<button type="button" class="prj-btn prj-picker-close" data-pick="close">Schließen</button></div>';
document.body.appendChild(picker);
picker.addEventListener('click', async e => {
if (e.target === picker) { closePicker(); return; }
const b = e.target.closest('[data-pick]');
if (!b || b.disabled) return;
const what = b.dataset.pick;
if (what === 'close') { closePicker(); return; }
if (what === 'new') {
closePicker();
st.creating = {preset: 'research', then: {kind: kind, ref: ref, info: info}};
st.open = null;
go('projekte');
render();
return;
}
b.disabled = true;
try {
const pid = Number(what);
const data = snapshot(kind, Number(ref), info);
await keyed(data, pid);
const d = await addItem(kind, Number(ref), data, pid);
const p = st.list.filter(x => x.id === pid)[0];
toast(d.existing ? 'Schon in ' + nameOf(p) : 'Mit ' + nameOf(p) + ' verknüpft');
closePicker();
if (st.open && st.open.id === pid) render();
} catch (err) {
b.disabled = false;
toast('Verknüpfen ging nicht: ' + err.message);
}
});
}
// A citation key, unique in the project (its items loaded for that when it isn't open).
async function keyed(data, pid) {
let taken = {};
if (st.open && st.open.id === pid) taken = takenKeys();
else {
try {
const d = await api('/projects/' + pid + '/');
for (const i of d.items) {
const x = await open_(i.data_ct, i.data_iv);
if (x && x.key) taken[x.key] = true;
}
} catch (e) {}
}
data.key = makeKey(data.csl || {}, taken);
return data;
}
// ---- adding sources inside a project ------------------------------------------------------------
const looksLikeDoi = q => /10\.\d{4,9}\/\S+|arxiv\.org\/(abs|pdf)\/|^(arxiv:)?\d{4}\.\d{4,5}(v\d+)?$/i.test(q.trim());
async function findToAdd(q) {
q = q.trim();
st.adding = {q: q, loading: true, error: '', doi: null, found: []};
render();
try {
if (looksLikeDoi(q)) {
const d = await api('/projects/doi/?q=' + encodeURIComponent(q));
st.adding.doi = d;
} else {
const found = [];
const qw = q.toLowerCase().split(/\s+/).filter(Boolean);
const has = t => { t = String(t || '').toLowerCase(); return qw.every(w => t.indexOf(w) >= 0); };
const books = typeof _lastBookListData !== 'undefined' && _lastBookListData && _lastBookListData.length
? _lastBookListData : Object.keys(typeof bookMetaCache !== 'undefined' ? bookMetaCache : {}).map(id =>
Object.assign({id: Number(id)}, bookMetaCache[id]));
books.filter(b => has(b.title + ' ' + b.author + ' ' + (b.series || ''))).slice(0, 8).forEach(b =>
found.push({kind: 'book', ref: b.id, title: b.title, sub: b.author || '', info: {}}));
if (q.length >= 2) {
const s = await api('/search/?q=' + encodeURIComponent(q));
(s.episodes || []).slice(0, 8).forEach(e => found.push({kind: 'episode', ref: e.id, title: e.title,
sub: e.feed__title || '', info: episodeInfo(e)}));
(s.articles || []).slice(0, 8).forEach(a => found.push({kind: 'article', ref: a.id, title: a.title,
sub: a.source || '', info: {title: a.title, source: a.source, author: a.author, date: a.published, news: a.kind === 'news'}}));
}
st.adding.found = found;
}
} catch (e) {
st.adding.error = e.message;
}
st.adding.loading = false;
render();
}
function episodeInfo(e) {
return {title: e.title, podcast: e.feed__title || '', author: e.author || e.feed__author || '', date: e.pub_date,
url: e.link || e.audio_url || '', feed: e.feed__id};
}
// ---- drawing -------------------------------------------------------------------------------
function render() {
if (!tab.querySelector('.prj')) tab.innerHTML = '<div class="prj"></div>';
const root = tab.querySelector('.prj');
const focus = document.activeElement && root.contains(document.activeElement) ? document.activeElement : null;
const keep = focus && focus.dataset && focus.dataset.keep;
const sel = keep && typeof focus.selectionStart === 'number' ? [focus.selectionStart, focus.selectionEnd] : null;
root.innerHTML = st.creating ? createHtml() : st.open ? projectHtml() : listHtml();
if (keep) {
const again = root.querySelector('[data-keep="' + keep + '"]');
if (again) {
again.focus();
if (sel) try { again.setSelectionRange(sel[0], sel[1]); } catch (e) {}
}
}
}
function listHtml() {
let h = '<h2 class="prj-h1">Projekte</h2>';
if (st.list === null) return h + '<p class="prj-muted">Lädt …</p>';
if (st.error) h += '<p class="prj-muted">' + esc(st.error) + '</p>';
const active = st.list.filter(p => !p.archived);
const archived = st.list.filter(p => p.archived);
const card = p => '<button type="button" class="prj-card" data-open-project="' + p.id + '">' +
'<span class="prj-card-preset">' + esc(PRESETS[p.preset] ? PRESETS[p.preset].label : p.preset) + '</span>' +
'<span class="prj-card-name">' + esc(nameOf(p)) + '</span>' +
(p.data && p.data.description ? '<span class="prj-card-desc">' + esc(p.data.description) + '</span>' : '') +
'<span class="prj-muted">' + (p.items === 1 ? '1 Eintrag' : p.items + ' Einträge') + '</span></button>';
h += '<div class="prj-cards">' + active.map(card).join('') +
'<button type="button" class="prj-card prj-card-new" data-act="new"><span class="prj-card-plus">+</span>' +
'<span class="prj-card-name">Neues Projekt</span></button></div>';
if (archived.length) {
h += '<details class="prj-archived"><summary>Archiviert (' + archived.length + ')</summary><div class="prj-cards">' +
archived.map(card).join('') + '</div></details>';
}
return h;
}
function createHtml() {
const c = st.creating;
let h = '<button type="button" class="prj-back" data-act="cancel-new">‹ Projekte</button>';
h += '<h2 class="prj-h1">Neues Projekt</h2><div class="prj-presets">';
Object.keys(PRESETS).forEach(id => {
h += '<button type="button" class="prj-preset' + (c.preset === id ? ' on' : '') + '" data-preset="' + id + '">' +
'<span class="prj-preset-name">' + esc(PRESETS[id].label) + '</span><span class="prj-muted">' + esc(PRESETS[id].hint) + '</span></button>';
});
h += '<div class="prj-preset prj-preset-soon"><span class="prj-preset-name">Weitere Vorlagen</span>' +
'<span class="prj-muted">Lesekreis, Lernplan, Hörbuch … folgen.</span></div></div>';
h += '<form class="prj-form" data-form="create"><label>Name<input name="name" required maxlength="120" data-keep="new-name" autocomplete="off"></label>' +
'<label>Worum es geht (optional)<textarea name="description" rows="2" maxlength="600"></textarea></label>' +
'<button type="submit" class="prj-btn prj-primary">Anlegen</button></form>';
return h;
}
function sources() {
return st.open.items.filter(i => SOURCE_KINDS.indexOf(i.kind) >= 0);
}
function notes() {
return st.open.items.filter(i => i.kind === 'note');
}
function projectHtml() {
const p = st.open;
const preset = PRESETS[p.preset] || PRESETS.research;
let h = solo ? '' : '<button type="button" class="prj-back" data-act="back">‹ Projekte</button>';
h += '<div class="prj-head"><div><span class="prj-card-preset">' + esc(preset.label) + '</span>' +
'<h2 class="prj-h1">' + esc(p.data ? p.data.name : 'Nicht lesbar') + '</h2>' +
(p.data && p.data.description ? '<p class="prj-muted prj-desc">' + esc(p.data.description) + '</p>' : '') +
'</div><button type="button" class="prj-icon" data-act="menu" aria-label="Projekt-Menü">⋯</button></div>';
if (st.menu) {
h += '<div class="prj-menu">' +
(window.dioraDesktop && window.dioraDesktop.openWindow && !solo
? '<button type="button" class="prj-btn" data-act="window">In neuem Fenster öffnen</button>' : '') +
'<button type="button" class="prj-btn" data-act="rename">Umbenennen …</button>' +
'<button type="button" class="prj-btn" data-act="archive">' + (p.archived ? 'Aus dem Archiv holen' : 'Archivieren') + '</button>' +
'<button type="button" class="prj-btn prj-danger" data-act="delete">Löschen …</button></div>';
}
const count = id => id === 'sources' ? sources().length : id === 'notes' ? notes().length : radarNew();
h += '<div class="prj-tabs">' + preset.tabs.filter(t => t[0] !== 'radar' || p.radar).map(t =>
'<button type="button" class="prj-tab' + (st.view === t[0] ? ' on' : '') + '" data-view="' + t[0] + '">' + esc(t[1]) +
'<span class="prj-count">' + (count(t[0]) || '') + '</span></button>').join('') + '</div>';
h += st.view === 'notes' ? notesHtml() : st.view === 'radar' && p.radar ? radarHtml() : sourcesHtml();
return h;
}
function sourceMeta(i) {
const c = (i.data && i.data.csl) || {};
const bits = [authorsText(c), year(c), container(c) || (i.data && i.data.podcast) || ''];
if (i.kind === 'episode' && i.data.position) bits.push('ab ' + clock(i.data.position));
return bits.filter(Boolean).map(esc).join(' · ');
}
function sourcesHtml() {
const all = sources();
const a = st.adding;
let h = '<form class="prj-add" data-form="add"><input name="q" placeholder="DOI, arXiv-Link oder Titel aus deiner Bibliothek …" ' +
'autocomplete="off" data-keep="add-q" value="' + esc(a ? a.q : '') + '"><button type="submit" class="prj-btn prj-primary">Suchen</button></form>';
if (a) {
h += '<div class="prj-found">';
if (a.loading) h += '<p class="prj-muted">Sucht …</p>';
else if (a.error) h += '<p class="prj-muted">' + esc(a.error) + '</p>';
else if (a.doi) {
const c = a.doi.csl;
h += '<div class="prj-cand"><span class="prj-kind">Paper</span><div class="prj-text"><b>' + esc(cslTitle(c)) + '</b>' +
'<span class="prj-muted">' + esc([authorsText(c), year(c), container(c)].filter(Boolean).join(' · ')) + '</span></div>' +
'<button type="button" class="prj-btn prj-primary" data-add-doi="1">Hinzufügen</button></div>';
} else if (!a.found.length) h += '<p class="prj-muted">Nichts gefunden. Eine DOI oder ein arXiv-Link findet jedes Paper.</p>';
else {
const linked = (kind, ref) => all.some(i => i.kind === kind && i.ref === ref);
h += a.found.map((f, n) => '<div class="prj-cand"><span class="prj-kind">' + esc(KIND[f.kind].label) + '</span>' +
'<div class="prj-text"><b>' + esc(f.title) + '</b><span class="prj-muted">' + esc(f.sub) + '</span></div>' +
(linked(f.kind, f.ref) ? '<span class="prj-muted">✓</span>'
: '<button type="button" class="prj-btn" data-add-found="' + n + '">+</button>') + '</div>').join('');
}
h += '<button type="button" class="prj-link" data-act="close-add">Schließen</button></div>';
}
if (!all.length) {
return h + '<p class="prj-muted prj-empty">Noch keine Quellen. Füge oben ein Paper per DOI hinzu, oder verknüpfe ein Buch, ' +
'eine Folge oder einen Artikel über ihr Menü: „Mit Projekt verknüpfen“.</p>';
}
const counts = {};
all.forEach(i => { counts[i.kind] = (counts[i.kind] || 0) + 1; });
h += '<div class="prj-chips"><button type="button" class="prj-chip' + (st.filter === 'all' ? ' on' : '') + '" data-filter="all">Alle ' + all.length + '</button>' +
SOURCE_KINDS.filter(k => counts[k]).map(k => '<button type="button" class="prj-chip' + (st.filter === k ? ' on' : '') +
'" data-filter="' + k + '">' + KIND[k].many + ' ' + counts[k] + '</button>').join('') +
'<span class="prj-spacer"></span><button type="button" class="prj-chip" data-export="bib">BibTeX</button>' +
'<button type="button" class="prj-chip" data-export="csl">CSL-JSON</button></div>';
const shown = all.filter(i => st.filter === 'all' || i.kind === st.filter).sort((x, y) =>
(authorsText(x.data && x.data.csl) || cslTitle(x.data && x.data.csl)).localeCompare(
authorsText(y.data && y.data.csl) || cslTitle(y.data && y.data.csl), 'de'));
h += shown.map(i => {
if (!i.data) return '<div class="prj-row"><span class="prj-muted">Nicht lesbar (anderer Schlüssel)</span></div>';
const open = st.expanded === i.id;
return '<div class="prj-row' + (open ? ' open' : '') + '"><button type="button" class="prj-main" data-expand="' + i.id + '">' +
'<span class="prj-glyph">' + KIND[i.kind].glyph + '</span><span class="prj-text"><span class="prj-title">' +
esc(cslTitle(i.data.csl) || '?') + '</span><span class="prj-muted">' + sourceMeta(i) + '</span>' +
(i.data.note && !open ? '<span class="prj-note-line">' + esc(i.data.note) + '</span>' : '') + '</span>' +
'<code class="prj-key">' + esc(i.data.key || '') + '</code></button>' +
(open ? sourceActionsHtml(i) : '') + '</div>';
}).join('');
return h;
}
function sourceActionsHtml(i) {
const c = i.data.csl || {};
return '<div class="prj-actions">' +
'<label class="prj-field">Notiz<textarea data-note-of="' + i.id + '" data-keep="note-of-' + i.id + '" rows="3" ' +
'placeholder="Warum diese Quelle, was steht drin …">' + esc(i.data.note || '') + '</textarea></label>' +
'<label class="prj-field">Zitierschlüssel<input data-key-of="' + i.id + '" data-keep="key-of-' + i.id + '" value="' + esc(i.data.key || '') + '"></label>' +
'<div>' + (i.kind !== 'paper' || c.URL || c.DOI ? '<button type="button" class="prj-btn prj-primary" data-go="' + i.id + '">Öffnen</button>' : '') +
'<button type="button" class="prj-btn" data-cite-copy="' + i.id + '">[@' + esc(i.data.key || '') + '] kopieren</button>' +
'<button type="button" class="prj-btn prj-danger" data-remove="' + i.id + '">Entfernen</button></div></div>';
}
// ---- the Radar: the week's papers, sorted by Claude on the server; the decisions are ours ----------
const DECISIONS = {read: 'gelesen', later: 'später', drop: 'verworfen', source: 'in Quellen'};
// The project's item holding the decisions ({id: state}), encrypted like everything else.
function radarItem() {
return st.open.items.filter(i => i.kind === 'radar' && i.data)[0] || null;
}
function decisions() {
if (st.open.radarDec) return st.open.radarDec; // changed here, maybe not saved yet
const i = radarItem();
return (i && i.data.decisions) || {};
}
// Undecided papers of the latest digest's first group (the worth-reading): the tab's number.
function radarNew() {
const d = st.open.digests[0];
if (!d || !st.open.radar) return 0;
const dec = decisions();
return (d.bundle.items || []).filter(it => it.gruppe === (st.open.radar.groups || [])[0] && !dec[it.id]).length;
}
let deciding = Promise.resolve();
function decide(id, state) {
// Shown at once; the saves follow one after the other, each with everything decided here.
const open = st.open;
const dec = Object.assign({}, decisions());
if (state) dec[id] = state; else delete dec[id];
open.radarDec = dec;
const changed = {};
changed[id] = state || null;
deciding = deciding.then(async () => {
const it = open.items.filter(i => i.kind === 'radar' && i.data)[0];
if (!it) { await addItem('radar', null, {decisions: Object.assign({}, open.radarDec)}); return; }
if (await saveItem(it, {decisions: Object.assign({}, open.radarDec)})) return;
// Decided elsewhere meanwhile: theirs with this one on top.
const merged = Object.assign({}, (it.data && it.data.decisions) || {});
if (changed[id]) merged[id] = changed[id]; else delete merged[id];
open.radarDec = merged;
await saveItem(it, {decisions: Object.assign({}, merged)});
if (st.open === open) render();
}).catch(e => toast('Speichern ging nicht: ' + e.message));
return deciding;
}
function paperById(id) {
for (const d of st.open.digests) {
const it = (d.bundle.items || []).filter(x => x.id === id)[0];
if (it) return it;
}
return null;
}
// A paper of the Radar as a source: its citation data from doi.org, else from what the Radar has.
async function paperToSource(it) {
let csl = null;
if (it.doi) {
try { csl = (await api('/projects/doi/?q=' + encodeURIComponent(it.doi))).csl; } catch (e) {}
}
if (!csl) {
const pre = /preprint/i.test((it.pub_types || []).join(' ')) || /arxiv|rxiv/i.test(it.source || '');
csl = {type: pre ? 'article' : 'article-journal', title: it.title, 'container-title': it.journal || '',
author: it.first_author ? names(it.first_author).concat(it.n_authors > 1 ? [{literal: 'u. a.'}] : []) : [],
issued: it.date ? {'date-parts': [String(it.date).split('-').map(Number)]} : undefined, URL: it.url,
DOI: it.doi || undefined};
}
const data = {csl: csl, note: it.kurz || '', doi: it.doi || '', radar: it.id};
await keyed(data, st.open.id);
await addItem('paper', null, data);
}
function weekOf(iso) {
const d = new Date(iso + 'T12:00:00');
const t = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()));
t.setUTCDate(t.getUTCDate() + 4 - (t.getUTCDay() || 7));
const y = new Date(Date.UTC(t.getUTCFullYear(), 0, 1));
return Math.ceil(((t - y) / 864e5 + 1) / 7);
}
function dayText(iso, withTime) {
const d = new Date(iso.length <= 10 ? iso + 'T12:00:00' : iso);
if (isNaN(d)) return '';
const o = {weekday: 'short', day: 'numeric', month: 'long'};
if (withTime) { o.hour = '2-digit'; o.minute = '2-digit'; }
return d.toLocaleString('de-DE', o);
}
function radarStatusHtml(r) {
let s = '';
if (r.running) s = 'Claude sortiert gerade die Papers' + (r.submitted ? ' (seit ' + dayText(r.submitted, true) + ')' : '') + '.';
else if (!r.enabled) s = 'Pausiert.';
else if (r.next_run && new Date(r.next_run) - Date.now() < 60 * 60 * 1000) s = 'Der nächste Abruf kommt in der nächsten halben Stunde.';
else if (r.next_run) s = 'Nächster Abruf: ' + dayText(r.next_run, true) + '.';
let h = '<div class="prj-radar-status"><span class="prj-muted">' + esc(s) +
(r.sources.length ? ' ' + esc(r.sources.join(', ')) + ', die letzten ' + r.days + ' Tage.' : '') + '</span>';
if (!r.running) h += '<button type="button" class="prj-chip" data-radar-run="1">Jetzt abrufen</button>';
h += '<button type="button" class="prj-chip" data-radar-pause="1">' + (r.enabled ? 'Pausieren' : 'Wieder an') + '</button></div>';
if (!r.key) h += '<p class="prj-warn">Auf dem Server fehlt noch der API-Schlüssel (ANTHROPIC_API_KEY): bis dahin kommt kein neuer Digest.</p>';
if (r.last_error) h += '<p class="prj-warn">' + esc(r.last_error) + '</p>';
return h;
}
function paperHtml(it, full, dec) {
const state = dec[it.id];
const who = (it.first_author || '?') + ((it.n_authors || 0) > 1 ? ' u. a.' : '');
const meta = [who, it.journal || it.source, it.date].filter(Boolean).join(' · ');
let h = '<div class="prj-paper' + (state === 'drop' ? ' prj-dropped' : '') + '">' +
'<div class="prj-paper-title">' + (it.url ? '<a href="' + esc(it.url) + '" target="_blank" rel="noopener">' + esc(it.title) + '</a>' : esc(it.title)) + '</div>' +
'<div class="prj-muted">' + esc(meta) + (state ? ' · <b>' + esc(DECISIONS[state]) + '</b>' : '') + '</div>';
if (full) {
if (it.worum_es_geht) h += '<p><b>Worum es geht:</b> ' + inline(it.worum_es_geht) + '</p>';
if (it.einordnung) h += '<p><b>Einordnung:</b> ' + inline(it.einordnung) + '</p>';
if (it.politischer_haken) h += '<p><b>Politischer Haken:</b> ' + inline(it.politischer_haken) + '</p>';
} else if (it.kurz) h += '<div class="prj-paper-short">' + inline(it.kurz) + '</div>';
const b = (s, label) => '<button type="button" class="prj-chip' + (state === s ? ' on' : '') + '" data-radar-decide="' +
esc(it.id) + '" data-state="' + s + '">' + label + '</button>';
h += '<div class="prj-paper-acts">' + b('later', 'Später') + b('read', 'Gelesen') + b('drop', 'Verwerfen') +
(state === 'source' ? '<span class="prj-muted">✓ in Quellen</span>'
: '<button type="button" class="prj-chip" data-radar-source="' + esc(it.id) + '">→ Quellen</button>') + '</div></div>';
return h;
}
function radarHtml() {
const r = st.open.radar;
const digests = st.open.digests;
const dec = decisions();
let h = radarStatusHtml(r);
if (!digests.length) return h + '<p class="prj-muted prj-empty">Noch kein Digest.</p>';
const later = [];
digests.forEach(d => (d.bundle.items || []).forEach(it => { if (dec[it.id] === 'later') later.push(it); }));
h += '<div class="prj-chips">' + digests.map((d, n) => '<button type="button" class="prj-chip' +
(st.radarShow !== 'later' && (st.digest || 0) === n ? ' on' : '') + '" data-radar-digest="' + n + '">KW ' + weekOf(d.generated) + '</button>').join('') +
(later.length ? '<button type="button" class="prj-chip' + (st.radarShow === 'later' ? ' on' : '') + '" data-radar-later="1">Später ' + later.length + '</button>' : '') + '</div>';
if (st.radarShow === 'later') {
return h + '<h3 class="prj-h3">Für später</h3>' + later.map(it => paperHtml(it, !!it.worum_es_geht, dec)).join('');
}
const d = digests[Math.min(st.digest || 0, digests.length - 1)];
const b = d.bundle;
const groups = r.groups && r.groups.length ? r.groups : ['lesenswert'];
const label = g => (r.group_labels && r.group_labels[g]) || g;
h += '<h3 class="prj-radar-h">' + esc(r.title || 'Digest') + ' · KW ' + weekOf(d.generated) + ', ' + esc(dayText(d.generated)) + '</h3>';
if (b.auf_einen_blick) h += '<p class="prj-radar-glance">' + inline(b.auf_einen_blick) + '</p>';
groups.forEach((g, n) => {
const items = (b.items || []).filter(it => it.gruppe === g);
if (!items.length) return;
const noise = n === groups.length - 1 && groups.length > 2; // the last group (Rauschen): closed
h += '<details class="prj-radar-group"' + (noise ? '' : ' open') + '><summary class="prj-h3">' + esc(label(g)) +
'<span class="prj-count">' + items.length + '</span></summary>' +
items.map(it => paperHtml(it, n === 0 && !!it.worum_es_geht, dec)).join('') + '</details>';
});
if (b.news_markdown && !/^Keine relevanten Meldungen/.test(b.news_markdown.trim())) {
h += '<details class="prj-radar-group" open><summary class="prj-h3">Aus Branche und Politik</summary><div class="prj-md">' +
markdown(b.news_markdown) + '</div></details>';
}
const g = b.grundlagenbegriff;
if (g && g.begriff) {
h += '<div class="prj-radar-term"><div class="prj-h3">Grundlagenbegriff der Woche</div><b>' + esc(g.begriff) + '.</b> ' +
inline(g.erklaerung || '') + '</div>';
}
const s = b.stats || {};
h += '<p class="prj-muted prj-radar-stats">' + (s.records || 0) + ' Papers gesichtet' +
(s.per_source ? ' (' + Object.keys(s.per_source).map(k => k + ' ' + s.per_source[k]).join(', ') + ')' : '') +
(typeof s.cost_usd === 'number' ? ' · ' + s.cost_usd.toFixed(2).replace('.', ',') + ' $' : '') +
((s.errors || []).length ? ' · Fehler: ' + esc(s.errors.join('; ')) : '') + '</p>';
return h;
}
function notesHtml() {
if (st.note != null) return noteEditorHtml();
const list = notes().filter(n => n.data).sort((a, b) => String(b.updated_at).localeCompare(String(a.updated_at)));
let h = '<div class="prj-chips"><button type="button" class="prj-btn prj-primary" data-act="new-note">+ Notiz</button></div>';
if (!list.length) return h + '<p class="prj-muted prj-empty">Noch keine Notizen.</p>';
const groups = {};
const order = [];
list.forEach(n => {
const g = n.data.folder || 'Notizen';
if (!groups[g]) { groups[g] = []; order.push(g); }
groups[g].push(n);
});
order.sort((a, b) => (a === 'Notizen' ? -1 : b === 'Notizen' ? 1 : a.localeCompare(b, 'de')));
order.forEach(g => {
h += '<h3 class="prj-h3">' + esc(g) + '<span class="prj-count">' + groups[g].length + '</span></h3>';
h += groups[g].map(n => '<button type="button" class="prj-note" data-open-note="' + n.id + '">' +
'<span class="prj-title">' + esc(n.data.title || 'Ohne Titel') + '</span>' +
'<span class="prj-muted">' + esc(firstLine(n.data.body)) + '</span></button>').join('');
});
return h;
}
function firstLine(body) {
const l = String(body || '').split('\n').map(x => x.replace(/^[#>*\-\s]+/, '').trim()).filter(Boolean);
return (l[0] || '').slice(0, 140);
}
function citeIndex() {
const idx = {};
sources().forEach(i => { if (i.data && i.data.key) idx[i.data.key] = i; });
return idx;
}
function noteEditorHtml() {
const n = st.note === 'new' ? null : st.open.items.filter(i => i.id === st.note)[0];
const d = (n && n.data) || draft;
let h = '<div class="prj-note-head"><button type="button" class="prj-back" data-act="close-note">‹ Notizen</button>' +
'<span class="prj-muted prj-saved">' + esc(saveState) + '</span>' +
'<button type="button" class="prj-chip' + (st.preview ? ' on' : '') + '" data-act="preview">' + (st.preview ? 'Bearbeiten' : 'Ansehen') + '</button></div>';
h += '<input class="prj-note-title" data-note-title data-keep="note-title" placeholder="Titel" value="' + esc(d.title || '') + '">';
if (st.preview) {
h += '<div class="prj-md">' + markdown(d.body, citeIndex()) + '</div>';
const cited = {};
String(d.body || '').replace(/\[@([\w:.\-]+)/g, (m, k) => { cited[k] = true; return m; });
const idx = citeIndex();
const refs = Object.keys(cited).map(k => idx[k]).filter(Boolean);
if (refs.length) {
h += '<h3 class="prj-h3">Literatur</h3><ol class="prj-refs">' + refs.sort((a, b) =>
(authorsText(a.data.csl) || '').localeCompare(authorsText(b.data.csl) || '', 'de')).map(i =>
'<li>' + esc(reference(i)) + '</li>').join('') + '</ol>';
}
} else {
h += '<textarea class="prj-note-body" data-note-body data-keep="note-body" placeholder="Markdown: # Überschrift, - Liste, **fett**, [@schlüssel] zitiert eine Quelle">' +
esc(d.body || '') + '</textarea>';
h += '<div class="prj-muted prj-hint">Zitieren: [@' + esc((sources()[0] && sources()[0].data && sources()[0].data.key) || 'schlüssel') +
'] oder [@schlüssel, S. 12]</div>';
}
if (n) h += '<button type="button" class="prj-link prj-danger" data-act="delete-note">Notiz löschen</button>';
return h;
}
// A reference in the list (author-year, close to APA).
function reference(i) {
const c = i.data.csl || {};
const parts = [authorsText(c) || cslTitle(c), year(c) ? '(' + year(c) + ')' : '', cslTitle(c) && authorsText(c) ? cslTitle(c) + '.' : ''];
const where = [container(c), c.volume ? c.volume + (c.issue ? '(' + c.issue + ')' : '') : '', c.page].filter(Boolean).join(', ');
if (where) parts.push(where + '.');
if (c.publisher && !container(c)) parts.push(c.publisher + '.');
if (c.DOI) parts.push('https://doi.org/' + c.DOI);
else if (c.URL) parts.push(c.URL);
return parts.filter(Boolean).join(' ');
}
// ---- the note editor's saving ------------------------------------------------------------------
let draft = {title: '', body: ''};
let saveState = '';
let saveTimer = 0;
let saving = Promise.resolve();
function noteChanged(field, value) {
const n = st.note === 'new' ? null : st.open.items.filter(i => i.id === st.note)[0];
const d = Object.assign({}, (n && n.data) || draft);
d[field] = value;
if (n) n.data = d; else draft = d;
saveState = 'nicht gespeichert';
const label = tab.querySelector('.prj-saved');
if (label) label.textContent = saveState;
clearTimeout(saveTimer);
saveTimer = setTimeout(saveNote, 900);
}
function saveNote() {
clearTimeout(saveTimer);
saving = saving.then(async () => {
if (!st.open || st.note == null || saveState !== 'nicht gespeichert') return;
const label = () => tab.querySelector('.prj-saved');
try {
if (st.note === 'new') {
if (!draft.title && !draft.body) return;
const data = Object.assign({folder: 'Notizen'}, draft);
const d = await addItem('note', null, data);
st.note = d.item.id;
draft = {title: '', body: ''};
} else {
const n = st.open.items.filter(i => i.id === st.note)[0];
if (n) {
const pending = n.data;
const ok = await saveItem(n, pending);
if (!ok) render();
}
}
saveState = 'gespeichert';
} catch (e) {
saveState = 'nicht gespeichert (' + e.message + ')';
}
if (label()) label().textContent = saveState;
});
return saving;
}
// ---- events --------------------------------------------------------------------------------------
function itemById(id) {
return st.open ? st.open.items.filter(i => i.id === Number(id))[0] : null;
}
function openSource(i) {
const c = (i.data && i.data.csl) || {};
const what = {kind: i.kind, ref: i.ref, feed: i.data ? i.data.feed : 0, title: cslTitle(c),
podcast: i.data ? i.data.podcast : '', url: c.DOI ? 'https://doi.org/' + c.DOI : (c.URL || '')};
// A project's own window (desktop): the main window opens it, with its reader and player.
if (solo && what.kind !== 'paper' && window.dioraDesktop && window.dioraDesktop.openInMain) {
window.dioraDesktop.openInMain(what);
return;
}
openThing(what);
}
// {kind, ref, feed, url}: a book, an episode, an article where it lives; a paper by its link.
function openThing(w) {
if (w.kind === 'book') {
if (typeof openBook === 'function') openBook(w.ref);
} else if (w.kind === 'episode') {
const row = {id: w.ref, feed__id: w.feed, title: w.title || '', feed__title: w.podcast || ''};
if (window.__dioraPodcastOpenEpisode && window.__dioraPodcastOpenEpisode(row)) return;
if (w.feed && window.__dioraPodcastOpenFeed) window.__dioraPodcastOpenFeed(w.feed);
} else if (w.kind === 'article') {
if (window.__dioraFeedsOpenItem) window.__dioraFeedsOpenItem(w.ref);
} else if (w.url) {
window.open(w.url, '_blank', 'noopener');
}
}
tab.addEventListener('click', async e => {
const t = e.target.closest('button, [data-cite]');
if (!t) return;
const ds = t.dataset;
try {
if (ds.openProject) { await openProject(Number(ds.openProject)); return; }
if (ds.preset) { st.creating.preset = ds.preset; render(); return; }
if (ds.view) { await saveNote(); st.view = ds.view; st.note = null; render(); return; }
if (ds.filter) { st.filter = ds.filter; render(); return; }
if (ds.expand) { st.expanded = st.expanded === Number(ds.expand) ? null : Number(ds.expand); render(); return; }
if (ds.go) { const i = itemById(ds.go); if (i) openSource(i); return; }
if (ds.citeCopy) {
const i = itemById(ds.citeCopy);
if (i && await copy('[@' + i.data.key + ']')) toast('Kopiert');
return;
}
if (ds.remove) {
const i = itemById(ds.remove);
if (i && confirm('„' + cslTitle(i.data.csl) + '“ aus dem Projekt entfernen?')) { await removeItem(i); render(); }
return;
}
if (ds.export) {
const items = sources().filter(i => i.data && (st.filter === 'all' || i.kind === st.filter));
const base = (nameOf(st.open) || 'projekt').replace(/[^\w\-]+/g, '_');
if (ds.export === 'bib') download(base + '.bib', bibtex(items), 'application/x-bibtex');
else download(base + '.json', cslJson(items), 'application/json');
return;
}
if (ds.addFound != null) {
const f = st.adding.found[Number(ds.addFound)];
const data = snapshot(f.kind, f.ref, Object.assign({title: f.title}, f.info));
await keyed(data, st.open.id);
await addItem(f.kind, f.ref, data);
toast('Hinzugefügt');
render();
return;
}
if (ds.addDoi) {
const csl = st.adding.doi.csl;
const data = {csl: csl, note: '', doi: st.adding.doi.doi};
await keyed(data, st.open.id);
await addItem('paper', null, data);
st.adding = null;
toast('Paper hinzugefügt');
render();
return;
}
if (ds.radarDigest != null) { st.digest = Number(ds.radarDigest); st.radarShow = 'all'; render(); return; }
if (ds.radarLater) { st.radarShow = 'later'; render(); return; }
if (ds.radarDecide) {
const now = decisions()[ds.radarDecide];
decide(ds.radarDecide, now === ds.state ? null : ds.state);
render();
return;
}
if (ds.radarSource) {
const it = paperById(ds.radarSource);
if (!it) return;
t.disabled = true;
await paperToSource(it);
await decide(it.id, 'source');
toast('In den Quellen');
render();
return;
}
if (ds.radarRun) {
if (!confirm('Jetzt die Papers der letzten Tage abrufen und von Claude sortieren lassen? Das kostet etwa 0,40 $.')) return;
st.open.radar = (await api('/projects/' + st.open.id + '/radar/run/', {})).radar;
render();
return;
}
if (ds.radarPause) {
st.open.radar = (await api('/projects/' + st.open.id + '/radar/settings/', {enabled: !st.open.radar.enabled})).radar;
render();
return;
}
if (ds.openNote) { st.note = Number(ds.openNote); st.preview = true; saveState = ''; render(); return; }
if (ds.cite) {
const i = citeIndex()[ds.cite];
if (i) { st.view = 'sources'; st.note = null; st.expanded = i.id; render(); }
return;
}
switch (ds.act) {
case 'new': st.creating = {preset: 'research'}; render(); return;
case 'cancel-new': st.creating = null; render(); return;
case 'back': await saveNote(); st.open = null; st.menu = false; history.replaceState(null, '', '#projekte'); render(); loadList().then(render); return;
case 'menu': st.menu = !st.menu; render(); return;
case 'window': st.menu = false; render(); window.dioraDesktop.openWindow('project', st.open.id); return;
case 'rename': {
const name = prompt('Name des Projekts', st.open.data ? st.open.data.name : '');
if (name && name.trim()) await saveProject(Object.assign({}, st.open.data || {}, {name: name.trim()}));
st.menu = false;
render();
return;
}
case 'archive':
await api('/projects/' + st.open.id + '/', {archived: !st.open.archived});
st.open.archived = !st.open.archived;
st.menu = false;
await loadList();
render();
return;
case 'delete':
if (!confirm('„' + nameOf(st.open) + '“ mit allen Quellen und Notizen löschen? Das lässt sich nicht rückgängig machen.')) return;
await api('/projects/' + st.open.id + '/delete/', {});
st.open = null;
await loadList();
render();
if (solo) window.close();
return;
case 'close-add': st.adding = null; render(); return;
case 'new-note': draft = {title: '', body: ''}; st.note = 'new'; st.preview = false; saveState = ''; render(); return;
case 'close-note': await saveNote(); st.note = null; render(); return;
case 'preview': await saveNote(); st.preview = !st.preview; render(); return;
case 'delete-note': {
const n = itemById(st.note);
if (n && confirm('Notiz „' + (n.data.title || 'Ohne Titel') + '“ löschen?')) {
await removeItem(n);
st.note = null;
render();
}
return;
}
}
} catch (err) {
toast(err.message === 'stale' ? 'Anderswo geändert, bitte noch einmal.' : 'Ging nicht: ' + err.message);
}
});
tab.addEventListener('submit', async e => {
e.preventDefault();
const f = e.target;
if (f.dataset.form === 'add') {
const q = f.q.value.trim();
if (q) findToAdd(q);
} else if (f.dataset.form === 'create') {
const name = f.name.value.trim();
if (!name) return;
const btn = f.querySelector('button[type="submit"]');
btn.disabled = true;
try {
const then = st.creating.then;
const preset = st.creating.preset;
st.creating = null;
await createProject(preset, name, f.description.value.trim());
if (then) {
const data = snapshot(then.kind, Number(then.ref), then.info);
await keyed(data, st.open.id);
await addItem(then.kind, Number(then.ref), data);
toast('Mit ' + name + ' verknüpft');
render();
}
} catch (err) {
btn.disabled = false;
toast('Anlegen ging nicht: ' + err.message);
}
}
});
tab.addEventListener('input', e => {
const t = e.target;
if (t.matches('[data-note-body]')) noteChanged('body', t.value);
else if (t.matches('[data-note-title]')) noteChanged('title', t.value);
});
// A source's note and key: saved when the field is left.
tab.addEventListener('change', async e => {
const t = e.target;
const id = t.dataset.noteOf || t.dataset.keyOf;
if (!id) return;
const i = itemById(id);
if (!i) return;
const data = Object.assign({}, i.data);
if (t.dataset.noteOf) data.note = t.value;
else {
const key = t.value.trim().replace(/[^\w:.\-]+/g, '');
if (!key || takenKeys(i.id)[key]) { toast('Diesen Schlüssel gibt es schon.'); t.value = i.data.key; return; }
data.key = key;
}
try {
await saveItem(i, data);
toast('Gespeichert');
} catch (err) {
toast('Speichern ging nicht: ' + err.message);
}
});
// ---- showing ---------------------------------------------------------------------------------------
async function show() {
render();
await loadList();
const m = solo ? [0, solo[1]] : /^#projekte\/(\d+)/.exec(location.hash || '');
if (m && (!st.open || st.open.id !== Number(m[1]))) {
try { await openProject(Number(m[1])); return; } catch (e) {}
}
if (st.open) {
try { await openProject(st.open.id, true); return; } catch (e) { st.open = null; }
}
render();
}
const showTab = window.showTab;
if (typeof showTab === 'function') {
window.showTab = function (name) {
const r = showTab.apply(this, arguments);
if (name === 'projekte') show();
else saveNote();
return r;
};
}
window.addEventListener('beforeunload', () => { if (saveState === 'nicht gespeichert') saveNote(); });
// Back at this window (another one, another device may have changed the project): fresh,
// unless a note is being written here.
window.addEventListener('focus', () => {
if (tab.style.display === 'none' || st.note != null || st.creating) return;
if (st.open) openProject(st.open.id, true).catch(() => {});
else loadList().then(render);
});
if (solo) {
document.title = 'Projekt · diora';
if (typeof showTab === 'function') showTab('projekte');
} else if (tab.style.display !== 'none') show();
window.DioraProjects = {
// "Mit Projekt verknüpfen": kind 'book' | 'episode' | 'article', ref its id, info what's
// known of it ({title, author, podcast, date, url, position (s), feed, source, news}).
link: link,
// The projects a thing is in: [{id, name}] (after the first load; onChange tells).
of(kind, ref) {
const ids = st.links.filter(l => l.kind === kind && l.ref === Number(ref)).map(l => l.project);
return (st.list || []).filter(p => ids.indexOf(p.id) >= 0).map(p => ({id: p.id, name: nameOf(p)}));
},
open(id) {
go('projekte');
openProject(Number(id)).catch(() => {});
},
onChange(fn) { if (typeof fn === 'function') linkListeners.push(fn); },
openSource: openThing,
load: loadList,
};
// The links for the sheets ("in Projekt …"), once the page has settled.
if (typeof IS_AUTHENTICATED === 'undefined' || IS_AUTHENTICATED) setTimeout(() => { if (st.list === null) loadList(); }, 4000);
// ---- styles --------------------------------------------------------------------------------------
const style = document.createElement('style');
style.textContent = [
'#tab-projekte .prj { max-width: 820px; margin: 0 auto; padding-bottom: 32px; }',
'#tab-projekte .prj-h1 { font-size: 20px; margin: 4px 0 12px; }',
'#tab-projekte .prj-h3 { display: flex; gap: 8px; align-items: baseline; font-size: 12px; text-transform: uppercase; letter-spacing: .08em; color: var(--muted, #9a9a9a); margin: 18px 0 4px; }',
'#tab-projekte .prj-muted { color: var(--muted, #9a9a9a); font-size: 12px; }',
'#tab-projekte .prj-count { color: var(--muted, #9a9a9a); font-size: 12px; margin-left: 6px; font-weight: 400; letter-spacing: 0; }',
'#tab-projekte .prj-cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 10px; }',
'#tab-projekte .prj-card { display: flex; flex-direction: column; gap: 6px; align-items: flex-start; min-height: 120px; padding: 14px; border-radius: 10px; border: 1px solid var(--border, #2a2a2a); background: var(--card, #0b0b0b); color: inherit; font: inherit; text-align: left; cursor: pointer; }',
'#tab-projekte .prj-card:hover { border-color: var(--border-strong, #444); }',
'#tab-projekte .prj-card-preset { font-size: 11px; text-transform: uppercase; letter-spacing: .08em; color: var(--accent, #e63946); }',
'#tab-projekte .prj-card-name { font-size: 17px; font-weight: 700; }',
'#tab-projekte .prj-card-desc { font-size: 13px; color: var(--muted, #9a9a9a); display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }',
'#tab-projekte .prj-card-new { align-items: center; justify-content: center; border-style: dashed; }',
'#tab-projekte .prj-card-plus { font-size: 28px; line-height: 1; }',
'#tab-projekte .prj-archived { margin-top: 20px; }',
'#tab-projekte .prj-archived summary { cursor: pointer; color: var(--muted, #9a9a9a); font-size: 13px; padding: 6px 0; }',
'#tab-projekte .prj-back, #tab-projekte .prj-link { background: none; border: none; color: var(--accent, #e63946); font: inherit; font-size: 14px; cursor: pointer; padding: 6px 0; }',
'#tab-projekte .prj-head { display: flex; justify-content: space-between; gap: 10px; align-items: flex-start; }',
'#tab-projekte .prj-head .prj-h1 { margin: 2px 0 4px; }',
'#tab-projekte .prj-desc { font-size: 13px; margin: 0 0 6px; }',
'#tab-projekte .prj-icon { width: 40px; height: 40px; flex: none; border-radius: 20px; border: 1px solid var(--border-strong, #444); background: none; color: inherit; font-size: 18px; cursor: pointer; }',
'#tab-projekte .prj-menu { display: flex; flex-wrap: wrap; gap: 6px; margin: 4px 0 8px; }',
'#tab-projekte .prj-tabs { display: flex; gap: 4px; border-bottom: 1px solid var(--border, #2a2a2a); margin: 10px 0 12px; }',
'#tab-projekte .prj-tab { padding: 10px 14px; background: none; border: none; border-bottom: 2px solid transparent; color: inherit; font: inherit; font-size: 15px; cursor: pointer; }',
'#tab-projekte .prj-tab.on { border-bottom-color: var(--accent, #e63946); font-weight: 700; }',
'#tab-projekte .prj-btn { display: inline-flex; align-items: center; min-height: 38px; padding: 0 14px; border-radius: 19px; border: 1px solid var(--border-strong, #444); background: none; color: inherit; font: inherit; font-size: 14px; cursor: pointer; margin: 4px 6px 0 0; }',
'#tab-projekte .prj-primary { background: var(--accent, #e63946); border-color: var(--accent, #e63946); color: var(--on-accent, #fff); }',
'#tab-projekte .prj-danger { color: var(--accent, #e63946); }',
'#tab-projekte .prj-btn[disabled] { opacity: .5; }',
'#tab-projekte .prj-add { display: flex; gap: 8px; }',
'#tab-projekte input, #tab-projekte textarea { box-sizing: border-box; padding: 10px 12px; border-radius: 6px; border: 1px solid var(--border, #2a2a2a); background: var(--bg, #000); color: inherit; font: inherit; font-size: 15px; }',
'#tab-projekte .prj-add input { flex: 1; min-width: 0; }',
'#tab-projekte .prj-found { margin: 10px 0; padding: 4px 12px 8px; border: 1px solid var(--border, #2a2a2a); border-radius: 8px; background: var(--card, #0b0b0b); }',
'#tab-projekte .prj-cand { display: flex; gap: 10px; align-items: center; padding: 8px 0; border-bottom: 1px solid var(--border, #2a2a2a); }',
'#tab-projekte .prj-kind { flex: none; font-size: 11px; padding: 1px 6px; border-radius: 8px; background: var(--raised, rgba(255,255,255,.12)); }',
'#tab-projekte .prj-text { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }',
'#tab-projekte .prj-chips { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; margin: 14px 0 6px; }',
'#tab-projekte .prj-spacer { flex: 1; }',
'#tab-projekte .prj-chip { min-height: 32px; padding: 0 12px; border-radius: 16px; border: 1px solid var(--border-strong, #444); background: none; color: inherit; font: inherit; font-size: 13px; cursor: pointer; }',
'#tab-projekte .prj-chip.on { background: var(--accent, #e63946); border-color: var(--accent, #e63946); color: var(--on-accent, #fff); }',
'#tab-projekte .prj-row { border-bottom: 1px solid var(--border, #2a2a2a); }',
'#tab-projekte .prj-main { display: flex; align-items: flex-start; gap: 10px; width: 100%; padding: 10px 0; background: none; border: none; color: inherit; font: inherit; text-align: left; cursor: pointer; }',
'#tab-projekte .prj-glyph { width: 28px; flex: none; text-align: center; color: var(--muted, #9a9a9a); font-size: 16px; padding-top: 1px; }',
'#tab-projekte .prj-title { font-size: 15px; }',
'#tab-projekte .prj-note-line { font-size: 13px; font-style: italic; color: var(--muted, #9a9a9a); }',
'#tab-projekte .prj-key { flex: none; font-size: 11px; color: var(--muted, #9a9a9a); max-width: 34%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }',
'#tab-projekte .prj-actions { padding: 0 0 12px 38px; display: flex; flex-direction: column; gap: 8px; }',
'#tab-projekte .prj-field { display: flex; flex-direction: column; gap: 4px; font-size: 12px; color: var(--muted, #9a9a9a); }',
'#tab-projekte .prj-field input, #tab-projekte .prj-field textarea { color: var(--fg, #eee); }',
'#tab-projekte .prj-empty { margin-top: 18px; line-height: 1.5; font-size: 14px; }',
'#tab-projekte .prj-presets { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 10px; margin-bottom: 16px; }',
'#tab-projekte .prj-preset { display: flex; flex-direction: column; gap: 4px; padding: 14px; border-radius: 10px; border: 1px solid var(--border, #2a2a2a); background: none; color: inherit; font: inherit; text-align: left; cursor: pointer; }',
'#tab-projekte .prj-preset.on { border-color: var(--accent, #e63946); box-shadow: inset 0 0 0 1px var(--accent, #e63946); }',
'#tab-projekte .prj-preset-name { font-weight: 700; }',
'#tab-projekte .prj-preset-soon { opacity: .5; cursor: default; border-style: dashed; }',
'#tab-projekte .prj-form { display: flex; flex-direction: column; gap: 12px; max-width: 520px; }',
'#tab-projekte .prj-form label { display: flex; flex-direction: column; gap: 4px; font-size: 13px; color: var(--muted, #9a9a9a); }',
'#tab-projekte .prj-form input, #tab-projekte .prj-form textarea { color: var(--fg, #eee); }',
'#tab-projekte .prj-note { display: flex; flex-direction: column; gap: 2px; width: 100%; padding: 10px 0; background: none; border: none; border-bottom: 1px solid var(--border, #2a2a2a); color: inherit; font: inherit; text-align: left; cursor: pointer; }',
'#tab-projekte .prj-note-head { display: flex; align-items: center; gap: 10px; }',
'#tab-projekte .prj-saved { flex: 1; text-align: right; }',
'#tab-projekte .prj-note-title { width: 100%; font-size: 18px; font-weight: 700; margin: 8px 0; }',
'#tab-projekte .prj-note-body { width: 100%; min-height: 55vh; font-family: inherit; line-height: 1.5; resize: vertical; }',
'#tab-projekte .prj-hint { margin-top: 4px; }',
'#tab-projekte .prj-md { line-height: 1.6; font-size: 15px; overflow-wrap: anywhere; }',
'#tab-projekte .prj-md h3 { font-size: 19px; } #tab-projekte .prj-md h4 { font-size: 16px; } #tab-projekte .prj-md h5, #tab-projekte .prj-md h6 { font-size: 15px; }',
'#tab-projekte .prj-md blockquote { margin: 8px 0; padding-left: 12px; border-left: 3px solid var(--border-strong, #444); color: var(--muted, #9a9a9a); }',
'#tab-projekte .prj-md a { color: var(--accent, #e63946); }',
'#tab-projekte .prj-radar-status { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; margin: 4px 0 6px; }',
'#tab-projekte .prj-radar-status .prj-muted { flex: 1 1 260px; }',
'#tab-projekte .prj-warn { color: var(--accent, #e63946); font-size: 13px; margin: 6px 0; }',
'#tab-projekte .prj-radar-h { font-size: 17px; margin: 16px 0 6px; }',
'#tab-projekte .prj-radar-glance { line-height: 1.6; font-size: 15px; }',
'#tab-projekte .prj-radar-group summary { cursor: pointer; list-style: none; }',
'#tab-projekte .prj-radar-group summary::-webkit-details-marker { display: none; }',
'#tab-projekte .prj-radar-group summary::before { content: "▸ "; } #tab-projekte .prj-radar-group[open] > summary::before { content: "▾ "; }',
'#tab-projekte .prj-paper { padding: 10px 0 12px; border-bottom: 1px solid var(--border, #2a2a2a); }',
'#tab-projekte .prj-paper p { margin: 6px 0; line-height: 1.55; font-size: 14px; }',
'#tab-projekte .prj-paper-title { font-size: 15px; font-weight: 700; margin-bottom: 2px; }',
'#tab-projekte .prj-paper-title a { color: inherit; text-decoration: none; } #tab-projekte .prj-paper-title a:hover { text-decoration: underline; }',
'#tab-projekte .prj-paper-short { font-size: 14px; margin-top: 4px; line-height: 1.5; }',
'#tab-projekte .prj-paper-acts { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; margin-top: 8px; }',
'#tab-projekte .prj-paper-acts .prj-chip { min-height: 30px; font-size: 12px; }',
'#tab-projekte .prj-dropped { opacity: .45; }',
'#tab-projekte .prj-radar-term { margin: 16px 0; padding: 12px 14px; border-radius: 8px; background: var(--card, #0b0b0b); border: 1px solid var(--border, #2a2a2a); line-height: 1.55; font-size: 14px; }',
'#tab-projekte .prj-radar-term .prj-h3 { margin-top: 0; }',
'#tab-projekte .prj-radar-stats { margin-top: 14px; }',
'#tab-projekte .prj-table { overflow-x: auto; margin: 10px 0; }',
'#tab-projekte .prj-table table { border-collapse: collapse; font-size: 14px; }',
'#tab-projekte .prj-table th, #tab-projekte .prj-table td { border: 1px solid var(--border, #2a2a2a); padding: 6px 8px; text-align: left; vertical-align: top; }',
'#tab-projekte .prj-cite { color: var(--accent, #e63946); cursor: pointer; }',
'#tab-projekte .prj-cite-missing { text-decoration: underline wavy; }',
'#tab-projekte .prj-refs { font-size: 13px; line-height: 1.5; padding-left: 20px; overflow-wrap: anywhere; }',
'.prj-picker { position: fixed; inset: 0; z-index: 3000; background: rgba(0,0,0,.55); display: flex; align-items: flex-end; justify-content: center; }',
'.prj-picker-box { width: 100%; max-width: 480px; max-height: 80vh; overflow: auto; background: var(--card, #111); color: var(--fg, #eee); border-radius: 14px 14px 0 0; padding: 14px 16px calc(14px + env(safe-area-inset-bottom, 0px)); box-sizing: border-box; }',
'@media (min-width: 721px) { .prj-picker { align-items: center; } .prj-picker-box { border-radius: 14px; } }',
'.prj-picker-h { font-weight: 700; font-size: 16px; margin-bottom: 4px; }',
'.prj-picker-what { font-size: 13px; color: var(--muted, #9a9a9a); margin-bottom: 8px; }',
'.prj-picker-p { display: flex; justify-content: space-between; gap: 10px; width: 100%; padding: 12px 4px; background: none; border: none; border-bottom: 1px solid var(--border, #2a2a2a); color: inherit; font: inherit; font-size: 15px; text-align: left; cursor: pointer; }',
'.prj-picker-p[disabled] { opacity: .6; cursor: default; }',
'.prj-picker-new { color: var(--accent, #e63946); }',
'.prj-picker .prj-muted { color: var(--muted, #9a9a9a); font-size: 12px; }',
'.prj-picker-close { display: inline-flex; align-items: center; min-height: 38px; padding: 0 14px; margin-top: 12px; border-radius: 19px; border: 1px solid var(--border-strong, #444); background: none; color: inherit; font: inherit; cursor: pointer; }',
'#prj-toast { position: fixed; left: 50%; bottom: calc(var(--nui-dock-h, 0px) + 90px); transform: translateX(-50%); z-index: 3100; 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); }',
'#prj-toast.prj-show { opacity: 1; }',
// A window of its own (desktop): only the project.
'html.prj-solo #tabs, html.prj-solo #nav-segments, html.prj-solo header, html.prj-solo .site-header, html.prj-solo #nui-dock, html.prj-solo .nui-tab-bar { display: none !important; }',
'html.prj-solo body { padding-left: 0 !important; padding-bottom: 0 !important; }',
'html.prj-solo #tab-projekte { padding-top: 12px; }',
'body.eink #tab-projekte .prj-primary, body.eink #tab-projekte .prj-chip.on { background: #000; color: #fff; }',
].join('\n');
document.head.appendChild(style);
})();