1417 lines
74 KiB
JavaScript
1417 lines
74 KiB
JavaScript
/*
|
||
* 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 =>
|
||
({'&': '&', '<': '<', '>': '>', '"': '"', "'": '''}[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);
|
||
})();
|