diora-web/podcasts/folders.py
mrwnslz 8f5be379fe Bibliothek neu: Ordner als Filter-Chips überall, Cover-Raster, Cover verschlüsselt und generiert (noch nicht veröffentlicht)
- Ordner wie Tags (docs/library.md): folders.js zeichnet die Chip-Reihe, das
  Blatt „In Ordner …“ (Häkchen, Neuer Ordner) und „Ordner verwalten“
  (umbenennen = zusammenlegen, löschen, Reihenfolge). E-Books: `folders` im
  verschlüsselten Meta, Reihenfolge in verschlüsselten Bibliotheks-Einstellungen
  (/books/prefs/). Podcasts/Hörbücher: PodcastFeed.folders (Migration 0021,
  übernimmt `folder`), /podcasts/folders/ (+rename, +delete). Blogs: Reihenfolge
  (/feeds/folders/order/).
- Bibliothek (53-library.js): Kopf mit Suche, Sortierung, Raster/Liste und +;
  Chips Angefangen · Ungelesen · Gelesen · Ordner; eine „Weiterlesen“-Karte;
  Mehrfachauswahl (lange drücken). E-Ink und diora Reader beginnen als Liste.
- Cover Ende-zu-Ende verschlüsselt (covers.js, /books/<id>/cover/): aus dem
  E-Book (EPUB-Coverbild, PDF Seite 1), 300 px, mit dem Nutzerschlüssel
  verschlüsselt, einmal hochgeladen; sparsam im Leerlauf, nie beim Lesen, nie
  auf E-Ink.
- Bücher ohne Cover bekommen eins (gencover.js): Stichwörter aus Titel, Autor,
  Regal und Ordnern wählen ein Gestaltungskonzept (Konstruktivismus, Geometrie,
  Psyche, Wellen, Karte, Kosmos, Ornament, Botanik, Berge, Zellen, Raster,
  Schatten, Typo, sonst Bauhaus); ein Hash aus Titel und Autor Palette, Aufbau
  und Details – einzigartig, überall gleich, nichts gespeichert.
- Hörbücher: Ordner-Chips statt Kacheln, „In Ordner …“ mit mehreren Ordnern.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 20:44:23 +02:00

116 lines
4.3 KiB
Python

"""Folders of podcasts and audiobooks (docs/library.md): like tags, any number per
subscription (PodcastFeed.folders), shown as filter chips in their order
(PodcastSettings.folder_order). Renaming onto an existing name merges; deleting
a folder leaves its subscriptions where they are."""
import json
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 PodcastFeed, PodcastSettings
MAX_FOLDERS = 20
MAX_NAME = 100
def clean_name(value):
return ' '.join(str(value or '').split())[:MAX_NAME]
def clean_list(value):
"""A list of folder names: trimmed, without empties and doubles (first spelling wins)."""
if not isinstance(value, list):
raise ValueError
out, seen = [], set()
for v in value:
if not isinstance(v, str):
raise ValueError
name = clean_name(v)
if name and name.casefold() not in seen:
seen.add(name.casefold())
out.append(name)
return out[:MAX_FOLDERS]
def set_folders(feed, names):
"""The feed's folders, and `folder` (the first) for older clients."""
feed.folders = names
feed.folder = names[0] if names else ''
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 _data(user):
counts = {}
for names in PodcastFeed.objects.filter(user=user).values_list('folders', flat=True):
for n in names or []:
counts[n] = counts.get(n, 0) + 1
order = [n for n in PodcastSettings.for_user(user).folder_order if n in counts]
rest = sorted((n for n in counts if n not in order), key=str.casefold)
return {'order': order + rest, 'folders': [{'name': n, 'count': counts[n]} for n in order + rest]}
@csrf_exempt
@require_http_methods(['GET', 'POST'])
def folders(request):
"""GET {order, folders: [{name, count}]}; POST {order: [names]} sets the chip order."""
if not request.user.is_authenticated:
return JsonResponse({'error': 'authentication required'}, status=401)
if request.method == 'POST':
body = _body(request)
try:
order = clean_list((body or {}).get('order'))
except ValueError:
return JsonResponse({'error': 'order: a list of names'}, status=400)
s = PodcastSettings.for_user(request.user)
s.folder_order = order
s.save(update_fields=['folder_order'])
return JsonResponse(_data(request.user))
def _rewrite(user, fn):
for feed in PodcastFeed.objects.filter(user=user).only('id', 'folders', 'folder'):
names = fn(list(feed.folders or []))
if names != (feed.folders or []):
set_folders(feed, clean_list(names))
feed.save(update_fields=['folders', 'folder'])
@csrf_exempt
@require_http_methods(['POST'])
def rename_folder(request):
"""{old, new}: every subscription in `old` is in `new` instead (merged if it exists)."""
if not request.user.is_authenticated:
return JsonResponse({'error': 'authentication required'}, status=401)
body = _body(request) or {}
old, new = clean_name(body.get('old')), clean_name(body.get('new'))
if not old or not new:
return JsonResponse({'error': 'old and new required'}, status=400)
_rewrite(request.user, lambda names: [new if n == old else n for n in names])
s = PodcastSettings.for_user(request.user)
s.folder_order = clean_list([new if n == old else n for n in s.folder_order])
s.save(update_fields=['folder_order'])
return JsonResponse(_data(request.user))
@csrf_exempt
@require_http_methods(['POST'])
def delete_folder(request):
"""{name}: the folder goes, its subscriptions stay."""
if not request.user.is_authenticated:
return JsonResponse({'error': 'authentication required'}, status=401)
name = clean_name((_body(request) or {}).get('name'))
if not name:
return JsonResponse({'error': 'name required'}, status=400)
_rewrite(request.user, lambda names: [n for n in names if n != name])
s = PodcastSettings.for_user(request.user)
s.folder_order = [n for n in s.folder_order if n != name]
s.save(update_fields=['folder_order'])
return JsonResponse(_data(request.user))