- 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>
116 lines
4.3 KiB
Python
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))
|