/*
* 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.',
tabs: [['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, '$1');
s = s.replace(/\*\*([^*]+)\*\*/g, '$1').replace(/(^|[^*])\*([^*\s][^*]*)\*/g, '$1$2');
s = s.replace(/\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g, '$1');
s = s.replace(/(^|[\s(])(https?:\/\/[^\s<]+[^\s<.,;)])/g, '$1$2');
// [@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 '(' + label.trim() +
(loc ? ', ' + loc : '') + ')';
});
return s;
}
function markdown(text, cite) {
const lines = String(text || '').replace(/\r/g, '').split('\n');
let out = '', list = null, para = [], quote = [];
const flush = () => {
if (para.length) { out += '
' + inline(para.join(' '), cite) + '
'; para = []; }
if (quote.length) { out += '
' + inline(quote.join(' '), cite) + '
'; quote = []; }
if (list) { out += '' + list + '>'; list = null; }
};
for (const raw of lines) {
const line = raw.replace(/\s+$/, '');
let m;
if (!line.trim()) { flush(); continue; }
if ((m = /^(#{1,4})\s+(.*)$/.exec(line))) { flush(); out += '' + inline(m[2], cite) + ''; continue; }
if ((m = /^\s*[-*+]\s+(?:\[( |x)\]\s+)?(.*)$/i.exec(line))) {
if (para.length || quote.length || list !== 'ul') { flush(); out += '
';
continue;
}
if ((m = /^\s*\d+[.)]\s+(.*)$/.exec(line))) {
if (para.length || quote.length || list !== 'ol') { flush(); out += ''; list = 'ol'; }
out += '
' + inline(m[1], cite) + '
';
continue;
}
if ((m = /^>\s?(.*)$/.exec(line))) { if (para.length || list) flush(); quote.push(m[1]); continue; }
if (/^(-{3,}|\*{3,})$/.test(line.trim())) { flush(); out += ''; 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)}));
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};
if (!keepView) {
st.view = 'sources';
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 = '
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“.
';
}
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 = '';
if (!list.length) return h + '
Noch keine Notizen.
';
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 += '
' + esc(g) + '' + groups[g].length + '
';
h += groups[g].map(n => '').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 = '