"""Recipe import: photo/screenshot (AI vision), pasted text (AI) and links. Every path ends in the normal recipe form, pre-filled as an editable draft and posted to recipes:create. Nothing is saved before the user presses "Speichern". """ import re from urllib.parse import urlencode from django import forms from django.contrib import messages from django.shortcuts import redirect, render from django.urls import reverse from django.views.decorators.http import require_GET, require_POST from . import ai, images, instagram, schema_org from .fetch import FetchError, fetch from .forms import MultipleFileInput, MultipleImageField, RecipeForm _URL_RE = re.compile(r"https?://[^\s<>\"']+") class LinkImportForm(forms.Form): url = forms.URLField( label="Link", max_length=2000, assume_scheme="https", widget=forms.URLInput(attrs={"placeholder": "https://… (Rezeptseite oder Instagram)", "inputmode": "url"}), ) class PhotoImportForm(forms.Form): photos = MultipleImageField( label="Foto oder Screenshot", widget=MultipleFileInput(attrs={"accept": "image/*"}), help_text=f"Bis zu {ai.MAX_IMAGES} Bilder vom selben Rezept, z. B. Kochbuchseite oder Screenshot.", ) def clean_photos(self): photos = self.cleaned_data["photos"] if not photos: raise forms.ValidationError("Bitte wähle ein Bild aus.") if len(photos) > ai.MAX_IMAGES: raise forms.ValidationError(f"Höchstens {ai.MAX_IMAGES} Bilder.") return photos class TextImportForm(forms.Form): text = forms.CharField( label="Rezepttext", max_length=ai.MAX_INPUT_CHARS, widget=forms.Textarea(attrs={"rows": 8, "placeholder": "Rezept hier einfügen, z. B. die Beschreibung eines Instagram-Beitrags"}), ) source_url = forms.URLField(label="Quelle (optional)", required=False, max_length=1000, assume_scheme="https") def render_draft(request, initial, notice, ai_draft=None, extra_context=None): """Show the recipe form filled with a draft; it posts to recipes:create.""" form = RecipeForm(initial=initial) return render(request, "recipes/form.html", { "form": form, "is_new": True, "draft_notice": notice, "ai_draft": ai_draft, "form_action": reverse("recipes:create"), **(extra_context or {}), }) def import_start(request, photo_form=None, text_form=None, link_form=None, instagram_failed=False, status=200): if link_form is None: link_form = LinkImportForm(initial={"url": request.GET.get("url", "")}) return render(request, "recipes/import.html", { "photo_form": photo_form or PhotoImportForm(), "text_form": text_form or TextImportForm(), "link_form": link_form, "instagram_failed": instagram_failed, }, status=status) @require_POST def import_photo(request): form = PhotoImportForm(request.POST, request.FILES) if not form.is_valid(): return import_start(request, photo_form=form) try: jpegs = [images.prepare_for_ai(f) for f in form.cleaned_data["photos"]] draft = ai.extract_from_images(request.user, jpegs) except (ai.AIError, images.ImageError) as error: messages.error(request, getattr(error, "user_message", str(error))) return redirect("recipes:import") return render_draft(request, draft.to_form_initial(), "Aus dem Bild erkannt", ai_draft=draft) @require_POST def import_text(request): form = TextImportForm(request.POST) if not form.is_valid(): return import_start(request, text_form=form) if not ai.is_configured(): # Without AI the pasted text becomes the description; the user splits it up by hand. initial = {"description": form.cleaned_data["text"], "source_url": form.cleaned_data["source_url"]} return render_draft(request, initial, "Text übernommen (KI nicht eingerichtet – bitte Zutaten und " "Schritte aus der Beschreibung in die passenden Felder übertragen)") try: draft = ai.structure_text(request.user, form.cleaned_data["text"]) except ai.AIError as error: messages.error(request, error.user_message) return import_start(request, text_form=form) initial = {**draft.to_form_initial(), "source_url": form.cleaned_data["source_url"]} return render_draft(request, initial, "Aus dem Text erkannt", ai_draft=draft) @require_POST def import_link(request): form = LinkImportForm(request.POST) if not form.is_valid(): return import_start(request, link_form=form, status=400) url = form.cleaned_data["url"] code = instagram.shortcode(url) if code: return _import_instagram(request, url, code, form) if instagram.is_instagram(url): form.add_error("url", "Bitte den Link zu einem einzelnen Beitrag oder Reel verwenden.") return import_start(request, link_form=form, status=400) return _import_website(request, url, form) def _instagram_fallback(request, url, form, reason): messages.warning(request, f"{reason} Füge die Beschreibung des Beitrags unten ein – oder lade einen Screenshot hoch.") text_form = TextImportForm(initial={"source_url": url}) return import_start(request, link_form=form, text_form=text_form, instagram_failed=True) def _import_instagram(request, url, code, form): try: caption, image = instagram.fetch_caption(code) except instagram.InstagramUnavailable: return _instagram_fallback(request, url, form, "Instagram lässt den automatischen Abruf gerade nicht zu.") extra = {"import_image_url": image} if image else {} if not ai.is_configured(): initial = {"description": caption, "source_url": url, **extra} return render_draft(request, initial, "Beschreibung von Instagram übernommen (KI nicht eingerichtet – " "bitte Zutaten und Schritte selbst aufteilen)") try: draft = ai.structure_text(request.user, caption, hint="Beschreibung (Caption) eines Instagram-Beitrags:") except ai.AIError as error: messages.error(request, error.user_message) return import_start(request, link_form=form, text_form=TextImportForm(initial={"text": caption, "source_url": url})) return render_draft(request, {**draft.to_form_initial(), "source_url": url, **extra}, "Aus Instagram erkannt", ai_draft=draft) def _import_website(request, url, form): try: result = fetch(url) except FetchError as error: form.add_error("url", error.user_message) return import_start(request, link_form=form, status=400) if result.status >= 400: form.add_error("url", f"Die Seite antwortete mit Fehler {result.status}.") return import_start(request, link_form=form, status=400) if "html" not in result.content_type.lower(): form.add_error("url", "Unter dem Link liegt keine Webseite.") return import_start(request, link_form=form, status=400) initial, image, page = schema_org.extract(result.text) extra = {"source_url": url, **({"import_image_url": image} if image else {})} if initial: return render_draft(request, {**initial, **extra}, "Von der Webseite übernommen") if not ai.is_configured(): form.add_error("url", "Auf der Seite wurde kein strukturiertes Rezept gefunden, und die KI ist nicht " "eingerichtet. Kopiere den Rezepttext und trag ihn von Hand ein.") return import_start(request, link_form=form, status=400) text = page.title.strip() + "\n\n" + page.visible_text() try: draft = ai.structure_text(request.user, text, hint=f"Text der Webseite {url}:") except ai.AIError as error: form.add_error("url", error.user_message) return import_start(request, link_form=form, status=400) return render_draft(request, {**draft.to_form_initial(), **extra}, "Von der Webseite (per KI) erkannt", ai_draft=draft) @require_GET def share_target(request): """Web Share Target (Android PWA): 'Teilen → Kochbuch' lands here with title/text/url.""" for value in (request.GET.get("url", ""), request.GET.get("text", ""), request.GET.get("title", "")): match = _URL_RE.search(value) if match: return redirect(f"{reverse('recipes:import')}?{urlencode({'url': match.group(0)})}") text = request.GET.get("text", "").strip() if text: return import_start(request, text_form=TextImportForm(initial={"text": text})) return redirect("recipes:import")