diff --git a/diora/settings.py b/diora/settings.py index 0306dbe..e0c9112 100644 --- a/diora/settings.py +++ b/diora/settings.py @@ -32,6 +32,7 @@ INSTALLED_APPS = [ 'gpodder', 'tts', 'feeds', + 'projects', ] EBOOK_MAX_BYTES = 100 * 1024 * 1024 # 100 MB (big scanned PDFs, complete editions) diff --git a/diora/urls.py b/diora/urls.py index 3e1d1b7..68dceb6 100644 --- a/diora/urls.py +++ b/diora/urls.py @@ -13,6 +13,7 @@ urlpatterns = [ path('podcasts/', include('podcasts.urls')), path('books/', include('books.urls')), path('feeds/', include('feeds.urls')), + path('projects/', include('projects.urls')), path('tts/', include('tts.urls')), path('api/2/', include('gpodder.urls')), path('api/sync/', sync_snapshot, name='api_sync'), diff --git a/docs/projects.md b/docs/projects.md new file mode 100644 index 0000000..96078f0 --- /dev/null +++ b/docs/projects.md @@ -0,0 +1,70 @@ +# Projekte + +Projects of a preset: the first is **Forschung** (a research project: sources, citations, +notes; Radar and Schreiben follow). In the navigation (`nav.js`, section and group `projekte`, +flag `side`): a tab beside Home · Lesen · Hören on a phone, at the foot of the sidebar and of +the wide bar above Suche · Abos & Quellen. The search moved out of the phone's tab bar: a +magnifier in the bar on top (`suche` is a footer section; the apps' section bars show it). + +## End-to-end encrypted + +Like the books (docs/library.md): a project's data (`{name, description}`) and every item's +data are AES-GCM blobs with the user's key (`32-crypto.js`, iv hex, ciphertext base64). The +server stores them and knows only: a project exists, its preset, archived, and what an item +points to — `kind` + `ref` (a book's, episode's or article's id, never its title). + +## Items + +| kind | ref | data | +|---|---|---| +| `paper` | – | `{key, csl, note, doi}` (CSL-JSON from doi.org) | +| `book` | book id | `{key, csl, note, type}` (CSL from the book's meta) | +| `episode` | episode id | `{key, csl, note, podcast, feed, position}` (position: seconds, "ab 12:34") | +| `article` | feeds item id | `{key, csl, note}` | +| `note` | – | `{title, body (Markdown), folder}` | + +A book, episode or article is in a project once (adding again answers `existing: true`). +`key` is the citation key for `[@key]` / `[@key, S. 12]` in notes: first author's family name +(or the podcast's), year, first word of the title — unique in the project, editable. + +Notes: Markdown (headings, lists, quotes, bold/italic, links); the view shows citations as +"(Amodio & Jost 2007, S. 3)" and a list of the works cited. Saved as one types (0.9 s). +Export: BibTeX and CSL-JSON of the sources (all or the chosen kind). + +## Linking from elsewhere + +`DioraProjects.link(kind, ref, info)` opens the sheet "Mit Projekt verknüpfen" (the projects, +the ones it is in ticked, + Neues Projekt). Called from the book sheet (library, apps), the +article reader (◇ in its bar), the episode sheet and the player (apps, native-podcast.js). +`DioraProjects.of(kind, ref)` → `[{id, name}]` the projects it's in; `onChange(fn)` after changes. + +## Desktop: a window of its own + +The project's menu (⋯) has "In neuem Fenster öffnen" when `dioraDesktop.openWindow` is there: +the desktop opens the page with `?project=`; `projects.js` then shows only that project +(`html.prj-solo` hides the navigation and the dock). + +## Writing and conflicts + +Every write sends `base_iv` (the IV it changed). Changed elsewhere since: 409 with the current +state. A note's text then goes beside it as a copy "(Konflikt)"; other data reloads. + +## API (signed in) + +- `GET /projects/` → `{projects: [{id, preset, data_ct, data_iv, archived, position, items, …}], links: [{project, kind, ref}], presets}` +- `POST /projects/create/` `{preset, data_ct, data_iv}` → `{project}` +- `GET /projects//` → `{project, items: [{id, kind, ref, data_ct, data_iv, created_at, updated_at}]}` +- `POST /projects//` `{data_ct, data_iv, base_iv}` and/or `{archived}` / `{position}` +- `POST /projects//delete/` +- `POST /projects//items/add/` `{kind, ref?, data_ct, data_iv}` → `{item, existing}` +- `POST /projects/items//` `{data_ct, data_iv, base_iv}` → `{item}` (409 `{item}` when stale) +- `POST /projects/items//delete/` +- `GET /projects/doi/?q=` → `{doi, csl}` — fetched from + doi.org (Crossref, DataCite …) as CSL-JSON, cached a week, nothing else kept. + +## Next + +Radar (the weekly digests and new papers with lesen / später / verwerfen / → Quellen; who +fetches the papers is open), papers as books (a paper's PDF into the library, its highlights +into the project's notes), Schreiben (drafts with the bibliography in a citation style), +the projects in the search, more presets. diff --git a/docs/search.md b/docs/search.md index 3f248f3..07f4bb2 100644 --- a/docs/search.md +++ b/docs/search.md @@ -6,7 +6,7 @@ section and group `suche`, flag `side`): - phone (the apps' tab bar, the web page's narrow bar): beside Home · Lesen · Hören; - the apps' sidebar and the web page's wide bar: at the foot, above Abos & Quellen. -It's never the section the app opens in next time (`diora_nav_section` isn't written for it). +It's never the section the app opens in next time (`transient`: `diora_nav_section` isn't written for it). ## What is searched where diff --git a/e2e/navigation.spec.js b/e2e/navigation.spec.js index 4e4b286..407ed54 100644 --- a/e2e/navigation.spec.js +++ b/e2e/navigation.spec.js @@ -14,7 +14,7 @@ test('wide: every section in the bar under its group, each shows its panel and v const wide = page.locator('#tabs .nav-wide'); await expect(wide.locator('.nav-group-label')).toHaveText(['Lesen', 'Hören']); await expect(wide.locator('[data-nav]')).toHaveText( - ['Home', 'Bücher', 'Zeitschriften', 'Nachrichten', 'Blogs', 'Hörbücher', 'Podcasts', 'Radio', 'Ambient', 'Fokus', 'Suche', 'Abos & Quellen']); + ['Home', 'Bücher', 'Zeitschriften', 'Nachrichten', 'Blogs', 'Hörbücher', 'Podcasts', 'Radio', 'Ambient', 'Fokus', 'Projekte', 'Suche', 'Abos & Quellen']); await wide.locator('[data-nav="blogs"]').click(); await expect(page).toHaveURL(/#blogs$/); @@ -33,7 +33,8 @@ test('narrow: three groups, the sections as a row on top, each group remembers i await page.setViewportSize({ width: 390, height: 800 }); await page.goto('/#news'); const narrow = page.locator('#tabs .nav-narrow'); - await expect(narrow.locator('[data-nav]')).toHaveText(['Home', 'Lesen', 'Hören', 'Suche', '']); + await expect(narrow.locator('[data-nav]')).toHaveText(['Home', 'Lesen', 'Hören', 'Projekte', '', '']); + await expect(narrow.locator('[data-nav="suche"] svg.dnav-ic')).toBeVisible(); // the magnifier await expect(narrow.locator('[data-nav="sources"] svg.dnav-ic')).toBeVisible(); await expect(narrow.locator('[data-nav="lesen"]')).toHaveClass(/active/); const segs = page.locator('#nav-segments'); @@ -89,6 +90,52 @@ test('Suche: books from this device, the rest from the server, a hit opens where expect(await page.evaluate(() => localStorage.getItem('diora_nav_section'))).not.toBe('suche'); }); +test('Projekte: a Forschung project, a paper by DOI, a linked book, a note citing them', async ({ page }) => { + await page.setViewportSize({ width: 1200, height: 900 }); + await page.route('**/projects/doi/?q=*', route => route.fulfill({json: {doi: '10.1038/nn.1234', csl: { + type: 'article-journal', title: 'Neural correlates of political ideology', 'container-title': 'Nature Neuroscience', + author: [{family: 'Amodio', given: 'David'}, {family: 'Jost', given: 'John'}], issued: {'date-parts': [[2007, 9]]}, + DOI: '10.1038/nn1979', volume: '10', page: '1246-1247'}}})); + await page.goto('/#projekte'); + const tab = page.locator('#tab-projekte'); + await tab.locator('[data-act="new"]').click(); + await expect(tab.locator('.prj-preset.on')).toContainText('Forschung'); + await tab.locator('input[name="name"]').fill('Neuropolitik'); + await tab.locator('button[type="submit"]').click(); + await expect(tab.locator('.prj-h1')).toHaveText('Neuropolitik'); + await expect(page).toHaveURL(/#projekte\/\d+$/); + // The server keeps only ciphertext. + const raw = await page.evaluate(() => fetch('/projects/').then(r => r.json())); + expect(JSON.stringify(raw)).not.toContain('Neuropolitik'); + + await tab.locator('.prj-add input').fill('https://doi.org/10.1038/nn1979'); + await tab.locator('.prj-add button').click(); + await tab.locator('[data-add-doi]').click(); + await expect(tab.locator('.prj-row .prj-title')).toHaveText(['Neural correlates of political ideology']); + await expect(tab.locator('.prj-key')).toHaveText(['amodio2007neural']); + + // From a book's sheet elsewhere: DioraProjects.link. + await page.evaluate(() => DioraProjects.link('book', 4242, {title: 'Das politische Gehirn', author: 'Hans Muster'})); + await page.locator('.prj-picker [data-pick]').filter({hasText: 'Neuropolitik'}).click(); + await expect(tab.locator('.prj-row')).toHaveCount(2); + expect(await page.evaluate(() => DioraProjects.of('book', 4242).map(p => p.name))).toEqual(['Neuropolitik']); + + await tab.locator('[data-view="notes"]').click(); + await tab.locator('[data-act="new-note"]').click(); + await tab.locator('[data-note-title]').fill('These'); + await tab.locator('[data-note-body]').fill('# Idee\nIdeologie zeigt sich im Gehirn [@amodio2007neural, S. 3] und bei [@muster0jdas].'); + await expect(tab.locator('.prj-saved')).toHaveText('gespeichert', {timeout: 5000}); + await tab.locator('[data-act="preview"]').click(); + await expect(tab.locator('.prj-md .prj-cite').first()).toHaveText('(Amodio & Jost 2007, S. 3)'); + await expect(tab.locator('.prj-refs li').first()).toContainText('Nature Neuroscience'); + + await tab.locator('[data-view="sources"]').click(); + const [dl] = await Promise.all([page.waitForEvent('download'), tab.locator('[data-export="bib"]').click()]); + const bib = require('fs').readFileSync(await dl.path(), 'utf8'); + expect(bib).toContain('@article{amodio2007neural,'); + expect(bib).toContain('doi = {10.1038/nn1979}'); +}); + test('Abos & Quellen: one page for every subscription, reached from the feeds too', async ({ page }) => { await page.setViewportSize({ width: 1200, height: 800 }); await page.goto('/#news'); diff --git a/projects/__init__.py b/projects/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/projects/apps.py b/projects/apps.py new file mode 100644 index 0000000..afae498 --- /dev/null +++ b/projects/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class ProjectsConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'projects' diff --git a/projects/migrations/0001_initial.py b/projects/migrations/0001_initial.py new file mode 100644 index 0000000..176730f --- /dev/null +++ b/projects/migrations/0001_initial.py @@ -0,0 +1,52 @@ +# Generated by Django 6.1 on 2026-10-19 18:00 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Project', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('preset', models.CharField(choices=[('research', 'Forschung')], default='research', max_length=20)), + ('data_ct', models.TextField()), + ('data_iv', models.CharField(max_length=32)), + ('archived', models.BooleanField(default=False)), + ('position', models.IntegerField(default=0)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='projects', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['position', '-updated_at'], + }, + ), + migrations.CreateModel( + name='ProjectItem', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('kind', models.CharField(choices=[('book', 'Buch'), ('episode', 'Folge'), ('article', 'Artikel'), ('paper', 'Paper'), ('note', 'Notiz')], max_length=10)), + ('ref', models.BigIntegerField(blank=True, null=True)), + ('data_ct', models.TextField()), + ('data_iv', models.CharField(max_length=32)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='projects.project')), + ], + options={ + 'ordering': ['created_at'], + 'indexes': [models.Index(fields=['kind', 'ref'], name='projects_pr_kind_3ad4df_idx')], + 'constraints': [models.UniqueConstraint(condition=models.Q(('ref__isnull', False)), fields=('project', 'kind', 'ref'), name='project_item_once')], + }, + ), + ] diff --git a/projects/migrations/__init__.py b/projects/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/projects/models.py b/projects/models.py new file mode 100644 index 0000000..93b7349 --- /dev/null +++ b/projects/models.py @@ -0,0 +1,52 @@ +"""Projekte (docs/projects.md): a project of a preset (Forschung first), its sources and notes. + +End-to-end encrypted like the books: a project's name and settings, and every item's content +(a note's text, a source's citation data, a remark) are AES-GCM blobs the server can't read. +What it knows: that a project exists, its preset, and which book, episode or article an item +points to (kind + ref) — the ids, never their titles.""" +from django.contrib.auth.models import User +from django.db import models + + +class Project(models.Model): + RESEARCH = 'research' + PRESETS = [(RESEARCH, 'Forschung')] + + user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='projects') + preset = models.CharField(max_length=20, choices=PRESETS, default=RESEARCH) + data_ct = models.TextField() # {name, description, …} encrypted + data_iv = models.CharField(max_length=32) + archived = models.BooleanField(default=False) + position = models.IntegerField(default=0) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ['position', '-updated_at'] + + +class ProjectItem(models.Model): + """Something in a project. Sources point at what's elsewhere in diora (a book, an episode, + an article: ref is its id) or stand alone (a paper known by its citation data: no ref); + notes are text of their own (no ref).""" + BOOK = 'book' + EPISODE = 'episode' + ARTICLE = 'article' + PAPER = 'paper' + NOTE = 'note' + KINDS = [(BOOK, 'Buch'), (EPISODE, 'Folge'), (ARTICLE, 'Artikel'), (PAPER, 'Paper'), (NOTE, 'Notiz')] + LINKED = (BOOK, EPISODE, ARTICLE) + + project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name='items') + kind = models.CharField(max_length=10, choices=KINDS) + ref = models.BigIntegerField(null=True, blank=True) + data_ct = models.TextField() + data_iv = models.CharField(max_length=32) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ['created_at'] + constraints = [models.UniqueConstraint(fields=['project', 'kind', 'ref'], name='project_item_once', + condition=models.Q(ref__isnull=False))] + indexes = [models.Index(fields=['kind', 'ref'])] diff --git a/projects/tests.py b/projects/tests.py new file mode 100644 index 0000000..0f4a0e5 --- /dev/null +++ b/projects/tests.py @@ -0,0 +1,109 @@ +"""Projekte: the API (projects/views.py).""" +import json +from unittest.mock import patch + +from django.contrib.auth.models import User +from django.core.cache import cache +from django.test import TestCase + +from .models import Project, ProjectItem +from .views import doi_of + +IV = 'a1' * 12 + + +class Base(TestCase): + def setUp(self): + cache.clear() + self.user = User.objects.create_user(username='forscher', password='pw12345678') + self.client.force_login(self.user) + + def post(self, url, body=None): + return self.client.post(url, json.dumps(body or {}), content_type='application/json') + + def make(self, ct='name'): + return self.post('/projects/create/', {'preset': 'research', 'data_ct': ct, 'data_iv': IV}).json()['project'] + + +class ProjectTests(Base): + def test_create_list_update(self): + p = self.make() + self.assertEqual(p['preset'], 'research') + r = self.client.get('/projects/').json() + self.assertEqual([x['id'] for x in r['projects']], [p['id']]) + self.assertIn('research', r['presets']) + r = self.post(f"/projects/{p['id']}/", {'data_ct': 'neu', 'data_iv': 'b2' * 12, 'base_iv': IV}) + self.assertEqual(r.json()['project']['data_ct'], 'neu') + # Written from an old state: refused with the current one. + r = self.post(f"/projects/{p['id']}/", {'data_ct': 'alt', 'data_iv': 'c3' * 12, 'base_iv': IV}) + self.assertEqual((r.status_code, r.json()['project']['data_ct']), (409, 'neu')) + + def test_bad_input(self): + self.assertEqual(self.post('/projects/create/', {'data_ct': 'x', 'data_iv': 'zz'}).status_code, 400) + self.assertEqual(self.post('/projects/create/', {'preset': 'nope', 'data_ct': 'x', 'data_iv': IV}).status_code, 400) + + def test_only_ones_own(self): + p = self.make() + other = User.objects.create_user(username='andere', password='pw12345678') + self.client.force_login(other) + self.assertEqual(self.client.get(f"/projects/{p['id']}/").status_code, 404) + self.assertEqual(self.post(f"/projects/{p['id']}/items/add/", {'kind': 'note', 'data_ct': 'x', 'data_iv': IV}).status_code, 404) + self.assertEqual(self.client.get('/projects/').json()['projects'], []) + self.client.logout() + self.assertEqual(self.client.get('/projects/').status_code, 401) + + def test_delete_takes_the_items(self): + p = self.make() + self.post(f"/projects/{p['id']}/items/add/", {'kind': 'note', 'data_ct': 'x', 'data_iv': IV}) + self.post(f"/projects/{p['id']}/delete/") + self.assertEqual((Project.objects.count(), ProjectItem.objects.count()), (0, 0)) + + +class ItemTests(Base): + def test_links_once_notes_many(self): + p = self.make() + url = f"/projects/{p['id']}/items/add/" + a = self.post(url, {'kind': 'book', 'ref': 12, 'data_ct': 'b', 'data_iv': IV}).json() + b = self.post(url, {'kind': 'book', 'ref': 12, 'data_ct': 'b2', 'data_iv': IV}).json() + self.assertEqual((a['existing'], b['existing'], a['item']['id']), (False, True, b['item']['id'])) + self.post(url, {'kind': 'note', 'data_ct': 'n', 'data_iv': IV}) + self.post(url, {'kind': 'note', 'ref': 5, 'data_ct': 'n', 'data_iv': IV}) # a note has no ref + self.post(url, {'kind': 'episode', 'ref': 7, 'data_ct': 'e', 'data_iv': IV}) + self.assertEqual(self.post(url, {'kind': 'episode', 'data_ct': 'e', 'data_iv': IV}).status_code, 400) + got = self.client.get(f"/projects/{p['id']}/").json() + self.assertEqual([(i['kind'], i['ref']) for i in got['items']], + [('book', 12), ('note', None), ('note', None), ('episode', 7)]) + links = self.client.get('/projects/').json()['links'] + self.assertEqual(sorted((l['kind'], l['ref']) for l in links), [('book', 12), ('episode', 7)]) + + def test_update_with_base_and_delete(self): + p = self.make() + i = self.post(f"/projects/{p['id']}/items/add/", {'kind': 'note', 'data_ct': 'v1', 'data_iv': IV}).json()['item'] + r = self.post(f"/projects/items/{i['id']}/", {'data_ct': 'v2', 'data_iv': 'b2' * 12, 'base_iv': IV}) + self.assertEqual(r.status_code, 200) + r = self.post(f"/projects/items/{i['id']}/", {'data_ct': 'v3', 'data_iv': 'c3' * 12, 'base_iv': IV}) + self.assertEqual((r.status_code, r.json()['item']['data_ct']), (409, 'v2')) + self.post(f"/projects/items/{i['id']}/delete/") + self.assertFalse(ProjectItem.objects.exists()) + + +class DoiTests(Base): + def test_recognised(self): + self.assertEqual(doi_of('https://doi.org/10.1038/s41586-020-2649-2.'), '10.1038/s41586-020-2649-2') + self.assertEqual(doi_of('https://arxiv.org/abs/2401.01234v2'), '10.48550/arXiv.2401.01234') + self.assertEqual(doi_of('2401.01234'), '10.48550/arXiv.2401.01234') + self.assertIsNone(doi_of('kein doi')) + + def test_lookup_trimmed_and_cached(self): + class R: + status_code = 200 + + def json(self): + return {'title': 'Ein Paper', 'DOI': '10.1234/x', 'reference': [1, 2, 3], 'author': [{'family': 'A'}]} + with patch('projects.views.requests.get', return_value=R()) as get: + a = self.client.get('/projects/doi/', {'q': 'doi:10.1234/x'}).json() + self.client.get('/projects/doi/', {'q': '10.1234/X'}) + self.assertEqual(get.call_count, 1) + self.assertEqual(a['csl']['title'], 'Ein Paper') + self.assertNotIn('reference', a['csl']) + self.assertEqual(self.client.get('/projects/doi/', {'q': 'nichts'}).status_code, 400) diff --git a/projects/urls.py b/projects/urls.py new file mode 100644 index 0000000..1accac1 --- /dev/null +++ b/projects/urls.py @@ -0,0 +1,14 @@ +from django.urls import path + +from . import views + +urlpatterns = [ + path('', views.projects, name='projects'), + path('create/', views.create, name='projects_create'), + path('doi/', views.doi, name='projects_doi'), + path('/', views.project, name='projects_project'), + path('/delete/', views.delete, name='projects_delete'), + path('/items/add/', views.item_add, name='projects_item_add'), + path('items//', views.item_update, name='projects_item_update'), + path('items//delete/', views.item_delete, name='projects_item_delete'), +] diff --git a/projects/views.py b/projects/views.py new file mode 100644 index 0000000..c5fa312 --- /dev/null +++ b/projects/views.py @@ -0,0 +1,285 @@ +"""Projekte (docs/projects.md): the API. Everything a project says is encrypted on the device; +here it's stored and handed back. A write names the IV it changed (base_iv): changed +elsewhere since, it's refused with the current state (409) for the device to merge.""" +import json +import re + +import requests +from django.core.cache import cache +from django.db import IntegrityError, transaction +from django.http import JsonResponse +from django.views.decorators.csrf import csrf_exempt +from django.views.decorators.http import require_http_methods + +from .models import Project, ProjectItem + +MAX_DATA = 2_000_000 # characters of base64: a long draft, not a PDF +MAX_PROJECTS = 200 +MAX_ITEMS = 5000 + + +def _auth(request): + if not request.user.is_authenticated: + return JsonResponse({'error': 'authentication required'}, status=401) + return None + + +def _body(request): + try: + body = json.loads(request.body or b'{}') + except (json.JSONDecodeError, ValueError): + return None + return body if isinstance(body, dict) else None + + +def _hex_iv(value): + return isinstance(value, str) and 8 <= len(value) <= 32 and all(c in '0123456789abcdefABCDEF' for c in value) + + +def _blob(body): + """(data_ct, data_iv) from a request, or None.""" + ct, iv = body.get('data_ct'), body.get('data_iv') + if not isinstance(ct, str) or not ct or len(ct) > MAX_DATA or not _hex_iv(iv): + return None + return ct, iv + + +def _project_json(p, counts=None): + return {'id': p.id, 'preset': p.preset, 'data_ct': p.data_ct, 'data_iv': p.data_iv, 'archived': p.archived, + 'position': p.position, 'created_at': p.created_at.isoformat(), 'updated_at': p.updated_at.isoformat(), + 'items': (counts or {}).get(p.id, 0)} + + +def _item_json(i): + return {'id': i.id, 'project': i.project_id, 'kind': i.kind, 'ref': i.ref, 'data_ct': i.data_ct, + 'data_iv': i.data_iv, 'created_at': i.created_at.isoformat(), 'updated_at': i.updated_at.isoformat()} + + +def _project(request, pk): + return Project.objects.filter(pk=pk, user=request.user).first() + + +# ---- projects ------------------------------------------------------------------------------ + +@require_http_methods(['GET']) +def projects(request): + """All projects, and every link (kind, ref, project) — what a book's or an episode's sheet + shows as "in Projekt …".""" + err = _auth(request) + if err: + return err + rows = list(Project.objects.filter(user=request.user)) + counts = {} + links = [] + for pid, kind, ref in ProjectItem.objects.filter(project__user=request.user).values_list('project_id', 'kind', 'ref'): + counts[pid] = counts.get(pid, 0) + 1 + if ref is not None: + links.append({'project': pid, 'kind': kind, 'ref': ref}) + return JsonResponse({'projects': [_project_json(p, counts) for p in rows], 'links': links, + 'presets': [k for k, _ in Project.PRESETS]}) + + +@csrf_exempt +@require_http_methods(['POST']) +def create(request): + err = _auth(request) + if err: + return err + body = _body(request) + blob = body and _blob(body) + if not blob: + return JsonResponse({'error': 'data_ct, data_iv required'}, status=400) + preset = body.get('preset', Project.RESEARCH) + if preset not in dict(Project.PRESETS): + return JsonResponse({'error': 'unknown preset'}, status=400) + if Project.objects.filter(user=request.user).count() >= MAX_PROJECTS: + return JsonResponse({'error': 'too many projects'}, status=400) + p = Project.objects.create(user=request.user, preset=preset, data_ct=blob[0], data_iv=blob[1]) + return JsonResponse({'project': _project_json(p)}) + + +@csrf_exempt +@require_http_methods(['GET', 'POST']) +def project(request, pk): + """GET: the project with its items. POST: new data (with base_iv), archived, position.""" + err = _auth(request) + if err: + return err + p = _project(request, pk) + if not p: + return JsonResponse({'error': 'not found'}, status=404) + if request.method == 'GET': + items = list(p.items.all()) + return JsonResponse({'project': _project_json(p, {p.id: len(items)}), 'items': [_item_json(i) for i in items]}) + body = _body(request) + if body is None: + return JsonResponse({'error': 'invalid JSON'}, status=400) + fields = [] + if 'data_ct' in body: + blob = _blob(body) + if not blob: + return JsonResponse({'error': 'data_ct, data_iv required'}, status=400) + base = body.get('base_iv') + if base and base != p.data_iv: + return JsonResponse({'error': 'stale', 'project': _project_json(p)}, status=409) + p.data_ct, p.data_iv = blob + fields += ['data_ct', 'data_iv'] + if 'archived' in body: + p.archived = bool(body['archived']) + fields.append('archived') + if 'position' in body: + try: + p.position = int(body['position']) + except (TypeError, ValueError): + return JsonResponse({'error': 'position'}, status=400) + fields.append('position') + if fields: + p.save(update_fields=fields + ['updated_at']) + return JsonResponse({'project': _project_json(p)}) + + +@csrf_exempt +@require_http_methods(['POST']) +def delete(request, pk): + err = _auth(request) + if err: + return err + p = _project(request, pk) + if not p: + return JsonResponse({'error': 'not found'}, status=404) + p.delete() + return JsonResponse({'ok': True}) + + +# ---- items --------------------------------------------------------------------------------- + +@csrf_exempt +@require_http_methods(['POST']) +def item_add(request, pk): + """{kind, ref?, data_ct, data_iv}. A book, episode or article is in a project once: adding + it again answers with the item there (existing: true).""" + err = _auth(request) + if err: + return err + p = _project(request, pk) + if not p: + return JsonResponse({'error': 'not found'}, status=404) + body = _body(request) + blob = body and _blob(body) + if not blob: + return JsonResponse({'error': 'data_ct, data_iv required'}, status=400) + kind = body.get('kind') + if kind not in dict(ProjectItem.KINDS): + return JsonResponse({'error': 'kind'}, status=400) + ref = body.get('ref') + if kind in ProjectItem.LINKED: + if not isinstance(ref, int) or isinstance(ref, bool) or ref <= 0: + return JsonResponse({'error': 'ref required'}, status=400) + else: + ref = None + if p.items.count() >= MAX_ITEMS: + return JsonResponse({'error': 'too many items'}, status=400) + if ref is not None: + have = p.items.filter(kind=kind, ref=ref).first() + if have: + return JsonResponse({'item': _item_json(have), 'existing': True}) + try: + with transaction.atomic(): + i = ProjectItem.objects.create(project=p, kind=kind, ref=ref, data_ct=blob[0], data_iv=blob[1]) + except IntegrityError: + return JsonResponse({'item': _item_json(p.items.get(kind=kind, ref=ref)), 'existing': True}) + p.save(update_fields=['updated_at']) + return JsonResponse({'item': _item_json(i), 'existing': False}) + + +def _item(request, pk): + return ProjectItem.objects.filter(pk=pk, project__user=request.user).select_related('project').first() + + +@csrf_exempt +@require_http_methods(['POST']) +def item_update(request, pk): + """{data_ct, data_iv, base_iv}: a changed note or source; 409 with the current one when it + was changed elsewhere since.""" + err = _auth(request) + if err: + return err + i = _item(request, pk) + if not i: + return JsonResponse({'error': 'not found'}, status=404) + body = _body(request) + blob = body and _blob(body) + if not blob: + return JsonResponse({'error': 'data_ct, data_iv required'}, status=400) + base = body.get('base_iv') + if base and base != i.data_iv: + return JsonResponse({'error': 'stale', 'item': _item_json(i)}, status=409) + i.data_ct, i.data_iv = blob + i.save(update_fields=['data_ct', 'data_iv', 'updated_at']) + i.project.save(update_fields=['updated_at']) + return JsonResponse({'item': _item_json(i)}) + + +@csrf_exempt +@require_http_methods(['POST']) +def item_delete(request, pk): + err = _auth(request) + if err: + return err + i = _item(request, pk) + if not i: + return JsonResponse({'error': 'not found'}, status=404) + i.delete() + return JsonResponse({'ok': True}) + + +# ---- a paper's data by its DOI ---------------------------------------------------------------- + +DOI_RE = re.compile(r'(10\.\d{4,9}/[^\s"<>]+)', re.I) + + +def doi_of(text): + """The DOI in a DOI, a doi.org link, a publisher's link that carries it, or an arXiv id.""" + text = (text or '').strip() + m = DOI_RE.search(text) + if m: + return m.group(1).rstrip('.,;)') + m = re.search(r'arxiv\.org/(?:abs|pdf)/(\d{4}\.\d{4,5})|^(?:arxiv:)?(\d{4}\.\d{4,5})(?:v\d+)?$', text, re.I) + if m: + return '10.48550/arXiv.' + (m.group(1) or m.group(2)) + return None + + +@require_http_methods(['GET']) +def doi(request): + """?q=: the work's citation data (CSL-JSON) from doi.org — Crossref, + DataCite (arXiv) and the other registries answer there. Not kept beyond a cache.""" + err = _auth(request) + if err: + return err + d = doi_of(request.GET.get('q', '')) + if not d: + return JsonResponse({'error': 'Keine DOI erkannt.'}, status=400) + key = 'doi:' + re.sub(r'\W+', '_', d.lower())[:200] + csl = cache.get(key) + if csl is None: + try: + r = requests.get('https://doi.org/' + d, timeout=15, + headers={'Accept': 'application/vnd.citationstyles.csl+json', + 'User-Agent': 'diora (https://diora.creamfresh.xyz)'}) + except requests.RequestException: + return JsonResponse({'error': 'doi.org nicht erreichbar.'}, status=502) + if r.status_code == 404: + return JsonResponse({'error': 'DOI unbekannt.'}, status=404) + try: + csl = r.json() + except ValueError: + return JsonResponse({'error': 'Keine Daten zu dieser DOI.'}, status=502) + if not isinstance(csl, dict): + return JsonResponse({'error': 'Keine Daten zu dieser DOI.'}, status=502) + # What a citation needs; the reference lists and licences stay out. + for k in ('reference', 'license', 'link', 'relation', 'assertion', 'funder', 'content-domain', + 'indexed', 'deposited', 'update-policy', 'resource'): + csl.pop(k, None) + cache.set(key, csl, 7 * 24 * 3600) + return JsonResponse({'doi': d, 'csl': csl}) diff --git a/static/js/app/20-tabs.js b/static/js/app/20-tabs.js index dec1a2d..a5a794e 100644 --- a/static/js/app/20-tabs.js +++ b/static/js/app/20-tabs.js @@ -4,7 +4,7 @@ // Tabs // --------------------------------------------------------------------------- -const TOP_TABS = ['home', 'radio', 'focus', 'podcasts', 'books', 'feeds', 'ambient', 'audiobooks', 'sources', 'suche']; +const TOP_TABS = ['home', 'radio', 'focus', 'podcasts', 'books', 'feeds', 'ambient', 'audiobooks', 'sources', 'suche', 'projekte']; const RADIO_SUB_TABS = ['search', 'saved', 'history']; // --------------------------------------------------------------------------- diff --git a/static/js/app/53-library.js b/static/js/app/53-library.js index 6048801..4fb228b 100644 --- a/static/js/app/53-library.js +++ b/static/js/app/53-library.js @@ -684,6 +684,11 @@ function _libSeriesManage() { // ---- a book's actions (the apps may bring their own sheet: __dioraBookSheet) ------------------------- +function _libProjectsLabel(b) { + const inIt = window.DioraProjects ? DioraProjects.of('book', b.id) : []; + return 'Mit Projekt verknüpfen …' + (inIt.length ? ` (${inIt.map(p => _libEsc(p.name)).join(', ')})` : ''); +} + function _libSheet(b) { if (window.__dioraBookSheet && window.__dioraBookSheet(b) !== false) return; const broken = typeof _brokenBooks !== 'undefined' && _brokenBooks.has(b.id); @@ -695,6 +700,8 @@ function _libSheet(b) { ['Serie …', () => _libSeriesSheet(b)], [_libEditionsOf(b).length > 1 ? `Ausgaben (${_libEditionsOf(b).length}) …` : 'Als Ausgabe verbinden …', () => _libEditionsSheet(b)], [b.is_read ? 'Als ungelesen markieren' : 'Als gelesen markieren', () => toggleBookRead(b.id, !!b.is_read)], + // Projekte (projects.js): "Forschung › Mit Projekt verknüpfen", and where it is already. + window.DioraProjects && [_libProjectsLabel(b), () => DioraProjects.link('book', b.id, {title: b.title, author: b.author})], b.has_highlights && ['Markierungen exportieren', () => downloadBookAnnotationsFromList(b.id, null)], ['Regal-Kategorie abrufen', () => lookupBookMetadata(b.id)], ['Mehrere auswählen', () => { _lib.selecting = new Set([b.id]); renderBookList(_lastBookListData); }], diff --git a/static/js/feeds.js b/static/js/feeds.js index d2d00c4..0c66dab 100644 --- a/static/js/feeds.js +++ b/static/js/feeds.js @@ -639,6 +639,8 @@ '' + esc(i ? i.source_title : '') + '' + '' + (i ? '' : '') + + // Projekte (projects.js): this article as a source of a project. + (i && window.DioraProjects ? '' : '') + (i && i.url ? '↗' : '') + '
' + inner + '
'; } @@ -757,6 +759,9 @@ try { localStorage.setItem('diora_feeds_font', String(v)); } catch (err) {} const art = reader.querySelector('.fd-art'); if (art) art.style.fontSize = v + 'px'; + } else if (r === 'project' && readerItem && window.DioraProjects) { + window.DioraProjects.link('article', readerItem.id, {title: readerItem.title, source: readerItem.source_title, + author: readerItem.author, date: readerItem.published, url: readerItem.url, news: readerItem.kind === 'news'}); } else if (r === 'star' && readerItem) { readerItem.starred = !readerItem.starred; api('/feeds/items/' + readerItem.id + '/star/', {starred: readerItem.starred}).catch(() => {}); diff --git a/static/js/nav.js b/static/js/nav.js index 66640bd..12b55ff 100644 --- a/static/js/nav.js +++ b/static/js/nav.js @@ -1,7 +1,7 @@ /* * The navigation (docs/navigation.md): diora's sections, sorted by what one does — * Home; Lesen: Bücher, Zeitschriften, Nachrichten, Blogs; Hören: Hörbücher, Podcasts, - * Radio, Ambient; Suche across all of them. One definition for every platform: the web page's #tabs are drawn + * Radio, Ambient; Projekte; Suche across all of them. One definition for every platform: the web page's #tabs are drawn * here, the apps' tab bar and sidebar (native-radio.js) from DioraNav.groups() and * DioraNav.sections(). A section is a panel (showTab, as before) plus a view in it. * On a phone, Lesen and Hören show their sections as a row on top (#nav-segments). @@ -25,9 +25,12 @@ {id: 'ambient', group: 'hoeren', label: 'Ambient', icon: '∿', panel: 'ambient'}, // The focus timer: the web page's own (the apps never had it). {id: 'focus', group: 'hoeren', label: 'Fokus', icon: '◷', panel: 'focus', webOnly: true}, - // The search across diora (docs/search.md, search.js): beside Home · Lesen · Hören on a - // phone, at the foot of the sidebar (above Abos & Quellen) and of the wide bar ("side"). - {id: 'suche', group: 'suche', label: 'Suche', icon: '⌕', panel: 'suche', account: true, side: true}, + // Projects (docs/projects.md, projects.js): a tab beside Home · Lesen · Hören on a phone, + // at the foot of the sidebar and of the wide bar ("side"), above Suche. + {id: 'projekte', group: 'projekte', label: 'Projekte', icon: '◇', panel: 'projekte', account: true, side: true}, + // The search across diora (docs/search.md, search.js): at the foot, above Abos & Quellen; + // on a phone a magnifier in the bar on top (the apps' section bars, the page's narrow bar). + {id: 'suche', group: 'more', label: 'Suche', icon: '⌕', panel: 'suche', account: true, footer: true, transient: true}, // Managing every subscription (docs/sources.md): at the foot of the sidebar, in the app // menu on a phone — not a tab of its own. {id: 'sources', group: 'more', label: 'Abos & Quellen', icon: '⚙', panel: 'sources', account: true, footer: true}, @@ -36,7 +39,7 @@ {id: 'home', label: 'Home', icon: '⌂'}, {id: 'lesen', label: 'Lesen', icon: '▤'}, {id: 'hoeren', label: 'Hören', icon: '◉'}, - {id: 'suche', label: 'Suche', icon: '⌕'}, + {id: 'projekte', label: 'Projekte', icon: '◇'}, ]; const byId = {}; SECTIONS.forEach(s => { byId[s.id] = s; }); @@ -63,6 +66,7 @@ '', focus: '', sources: '', + projekte: '', suche: '', menu: '' + '', @@ -142,7 +146,7 @@ function setCurrent(id) { current = id; const s = byId[id]; - if (!s.side) save('diora_nav_section', id); // the app opens where one was, not in the search + if (!s.transient) save('diora_nav_section', id); // the app opens where one was, not in the search if (!s.footer) save('diora_nav_last_' + s.group, id); syncHash(); render(); diff --git a/static/js/projects.js b/static/js/projects.js new file mode 100644 index 0000000..4eba74b --- /dev/null +++ b/static/js/projects.js @@ -0,0 +1,1160 @@ +/* + * 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 = 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 += '
    '; list = 'ul'; } + out += '
  • ' + (m[1] ? (m[1] === ' ' ? '☐ ' : '☑ ') : '') + inline(m[2], cite) + '
  • '; + continue; + } + if ((m = /^\s*\d+[.)]\s+(.*)$/.exec(line))) { + if (para.length || quote.length || list !== 'ol') { flush(); out += '
      '; list = 'ol'; } + out += '
    1. ' + inline(m[1], cite) + '
    2. '; + 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 = ''; + 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 = '
      '; + 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 = '

      Projekte

      '; + if (st.list === null) return h + '

      Lädt …

      '; + if (st.error) h += '

      ' + esc(st.error) + '

      '; + const active = st.list.filter(p => !p.archived); + const archived = st.list.filter(p => p.archived); + const card = p => ''; + h += '
      ' + active.map(card).join('') + + '
      '; + if (archived.length) { + h += '
      Archiviert (' + archived.length + ')
      ' + + archived.map(card).join('') + '
      '; + } + return h; + } + + function createHtml() { + const c = st.creating; + let h = ''; + h += '

      Neues Projekt

      '; + Object.keys(PRESETS).forEach(id => { + h += ''; + }); + h += '
      Weitere Vorlagen' + + 'Lesekreis, Lernplan, Hörbuch … folgen.
      '; + h += '
      ' + + '' + + '
      '; + return h; + } + + function sources() { + return st.open.items.filter(i => i.kind !== 'note'); + } + + 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 ? '' : ''; + h += '
      ' + esc(preset.label) + '' + + '

      ' + esc(p.data ? p.data.name : 'Nicht lesbar') + '

      ' + + (p.data && p.data.description ? '

      ' + esc(p.data.description) + '

      ' : '') + + '
      '; + if (st.menu) { + h += '
      ' + + (window.dioraDesktop && window.dioraDesktop.openWindow && !solo + ? '' : '') + + '' + + '' + + '
      '; + } + h += '
      ' + preset.tabs.map(t => '').join('') + '
      '; + h += st.view === 'notes' ? notesHtml() : 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 = '
      '; + if (a) { + h += '
      '; + if (a.loading) h += '

      Sucht …

      '; + else if (a.error) h += '

      ' + esc(a.error) + '

      '; + else if (a.doi) { + const c = a.doi.csl; + h += '
      Paper
      ' + esc(cslTitle(c)) + '' + + '' + esc([authorsText(c), year(c), container(c)].filter(Boolean).join(' · ')) + '
      ' + + '
      '; + } else if (!a.found.length) h += '

      Nichts gefunden. Eine DOI oder ein arXiv-Link findet jedes Paper.

      '; + else { + const linked = (kind, ref) => all.some(i => i.kind === kind && i.ref === ref); + h += a.found.map((f, n) => '
      ' + esc(KIND[f.kind].label) + '' + + '
      ' + esc(f.title) + '' + esc(f.sub) + '
      ' + + (linked(f.kind, f.ref) ? '✓' + : '') + '
      ').join(''); + } + h += '
      '; + } + if (!all.length) { + return h + '

      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“.

      '; + } + const counts = {}; + all.forEach(i => { counts[i.kind] = (counts[i.kind] || 0) + 1; }); + h += '
      ' + + SOURCE_KINDS.filter(k => counts[k]).map(k => '').join('') + + '' + + '
      '; + 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 '
      Nicht lesbar (anderer Schlüssel)
      '; + const open = st.expanded === i.id; + return '
      ' + + (open ? sourceActionsHtml(i) : '') + '
      '; + }).join(''); + return h; + } + + function sourceActionsHtml(i) { + const c = i.data.csl || {}; + return '
      ' + + '' + + '' + + '
      ' + (i.kind !== 'paper' || c.URL || c.DOI ? '' : '') + + '' + + '
      '; + } + + 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 = '
      ' + + '' + esc(saveState) + '' + + '
      '; + h += ''; + if (st.preview) { + h += '
      ' + markdown(d.body, citeIndex()) + '
      '; + 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 += '

      Literatur

        ' + refs.sort((a, b) => + (authorsText(a.data.csl) || '').localeCompare(authorsText(b.data.csl) || '', 'de')).map(i => + '
      1. ' + esc(reference(i)) + '
      2. ').join('') + '
      '; + } + } else { + h += ''; + h += '
      Zitieren: [@' + esc((sources()[0] && sources()[0].data && sources()[0].data.key) || 'schlüssel') + + '] oder [@schlüssel, S. 12]
      '; + } + if (n) h += ''; + 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) { + if (i.kind === 'book') { + if (typeof openBook === 'function') openBook(i.ref); + } else if (i.kind === 'episode') { + const row = {id: i.ref, feed__id: i.data.feed, title: cslTitle(i.data.csl), feed__title: i.data.podcast}; + if (window.__dioraPodcastOpenEpisode && window.__dioraPodcastOpenEpisode(row)) return; + if (i.data.feed && window.__dioraPodcastOpenFeed) window.__dioraPodcastOpenFeed(i.data.feed); + } else if (i.kind === 'article') { + if (window.__dioraFeedsOpenItem) window.__dioraFeedsOpenItem(i.ref); + } else { + const c = i.data.csl || {}; + const url = c.DOI ? 'https://doi.org/' + c.DOI : c.URL; + if (url) window.open(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.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(); }); + 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); }, + 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-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-section-bar { display: none !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); +})(); diff --git a/static/js/sw.js b/static/js/sw.js index db480c8..68002c7 100644 --- a/static/js/sw.js +++ b/static/js/sw.js @@ -2,7 +2,7 @@ * diora service worker — caches the app shell for offline use. */ -const CACHE = 'diora-v140'; +const CACHE = 'diora-v141'; const SHELL = [ '/static/css/app.css', '/static/js/app/01-core.js', @@ -49,6 +49,7 @@ const SHELL = [ '/static/js/gencover.js', '/static/js/sources.js', '/static/js/search.js', + '/static/js/projects.js', '/static/podcasts/native-podcast.js', '/static/podcasts/podcast-ui.css', '/static/podcasts/podcasts-web.css', diff --git a/templates/radio/player.html b/templates/radio/player.html index 9ee3923..ef9b523 100644 --- a/templates/radio/player.html +++ b/templates/radio/player.html @@ -267,6 +267,8 @@ {% if user.is_authenticated %}{% endif %} {% comment %}Abos & Quellen, every subscription in one place (docs/sources.md): filled by static/js/sources.js.{% endcomment %} {% if user.is_authenticated %}{% endif %} +{% comment %}Projekte, e.g. Forschung (docs/projects.md): filled by static/js/projects.js.{% endcomment %} +{% if user.is_authenticated %}{% endif %} {% comment %}The search across diora (docs/search.md): filled by static/js/search.js.{% endcomment %} {% if user.is_authenticated %}{% endif %} {% comment %}The feed reader, news and blogs: filled by static/js/feeds.js.{% endcomment %} @@ -398,4 +400,5 @@ + {% endblock %}