/* * 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, '$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 = [], table = null; const flush = () => { if (table) { out += '
' + table.map((row, n) => '' + row.map(c => (n ? '')).join('') + '').join('') + '
' : '') + inline(c, cite) + (n ? '' : '
'; table = null; } if (para.length) { out += '

' + inline(para.join(' '), cite) + '

'; para = []; } if (quote.length) { out += '
' + inline(quote.join(' '), cite) + '
'; quote = []; } if (list) { out += ''; 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 += '' + inline(m[2], cite) + ''; continue; } if ((m = /^\s*[-*+]\s+(?:\[( |x)\]\s+)?(.*)$/i.exec(line))) { if (para.length || quote.length || list !== 'ul') { flush(); out += '