Expand Doris Kitchen import, failures, and tag curation

This commit is contained in:
Fizzlepoof
2026-05-17 04:07:05 +00:00
parent e2470ff7c9
commit 165e772946
18 changed files with 1268 additions and 80 deletions

View File

@@ -1,6 +1,9 @@
from __future__ import annotations
import json
import mimetypes
import subprocess
import uuid
from typing import Any
from urllib import error, parse, request
@@ -44,6 +47,69 @@ class KitchenOwlService:
parsed = raw
return exc.code, parsed
def _fetch_binary(self, url: str, timeout: int = 45) -> tuple[bytes, str]:
req = request.Request(
url,
headers={
"User-Agent": "Mozilla/5.0 (Doris Kitchen)",
"Accept": "image/*,*/*;q=0.8",
"Accept-Language": "en-US,en;q=0.9",
},
)
try:
with request.urlopen(req, timeout=timeout) as resp:
mime_type = resp.headers.get_content_type() or "application/octet-stream"
return resp.read(), mime_type
except error.HTTPError as exc:
if exc.code not in {403, 406, 429}:
raise
curl = subprocess.run(
[
"curl", "-fsSL", "--max-time", str(timeout),
"-A", "Mozilla/5.0 (Doris Kitchen)",
"-H", "Accept: image/*,*/*;q=0.8",
"-H", "Accept-Language: en-US,en;q=0.9",
url,
],
capture_output=True,
check=False,
)
if curl.returncode != 0:
raise RuntimeError(f"Failed to fetch image URL: {url}")
guessed = mimetypes.guess_type(url)[0] or "application/octet-stream"
return curl.stdout, guessed
def _upload_photo_from_url(self, photo_url: str) -> str | None:
photo_url = str(photo_url or "").strip()
if not photo_url.startswith(("http://", "https://")):
return photo_url or None
content, mime_type = self._fetch_binary(photo_url)
ext = mimetypes.guess_extension(mime_type) or ".jpg"
filename = f"doris-{uuid.uuid4().hex}{ext}"
boundary = f"----DorisBoundary{uuid.uuid4().hex}"
parts = [
f"--{boundary}\r\n".encode("utf-8"),
f'Content-Disposition: form-data; name="file"; filename="{filename}"\r\n'.encode("utf-8"),
f"Content-Type: {mime_type}\r\n\r\n".encode("utf-8"),
content,
f"\r\n--{boundary}--\r\n".encode("utf-8"),
]
data = b"".join(parts)
req = request.Request(
f"{self.base_url}/api/upload",
data=data,
headers={
"Authorization": f"Bearer {self.token}",
"Accept": "application/json",
"Content-Type": f"multipart/form-data; boundary={boundary}",
"User-Agent": "doris-kitchen/1.0",
},
method="POST",
)
with request.urlopen(req, timeout=60) as resp:
payload = json.loads(resp.read().decode("utf-8", "replace"))
return str(payload.get("filename") or "").strip() or None
def list_recipes(self) -> list[dict[str, Any]]:
if not self.configured():
return []
@@ -64,15 +130,61 @@ class KitchenOwlService:
return recipe
return None
def _recipe_update_payload(self, existing: dict[str, Any], payload: dict[str, Any]) -> dict[str, Any]:
uploaded_photo = self._upload_photo_from_url(payload.get("photo")) if payload.get("photo") else existing.get("photo")
return {
"name": payload.get("name") or existing.get("name"),
"description": payload.get("description") or existing.get("description") or "",
"source": payload.get("source") or existing.get("source"),
"visibility": int(payload.get("visibility", existing.get("visibility", 0) or 0)),
"items": payload.get("items") or [
{
"name": item.get("name"),
"description": item.get("description", ""),
"optional": bool(item.get("optional", False)),
}
for item in (existing.get("items") or [])
],
"tags": payload.get("tags") or [
tag.get("name") if isinstance(tag, dict) else str(tag)
for tag in (existing.get("tags") or [])
],
"cook_time": payload.get("cook_time", existing.get("cook_time")),
"prep_time": payload.get("prep_time", existing.get("prep_time")),
"time": payload.get("time", existing.get("time")),
"yields": payload.get("yields", existing.get("yields")),
"photo": uploaded_photo or existing.get("photo"),
"planned": bool(existing.get("planned", False)),
"planned_cooking_dates": existing.get("planned_cooking_dates") or [],
"planned_days": existing.get("planned_days") or [],
}
def update_recipe(self, recipe_id: int | str, payload: dict[str, Any], existing: dict[str, Any] | None = None) -> dict[str, Any]:
if not self.configured():
raise RuntimeError("KitchenOwl credentials are not configured.")
if existing is None:
status, existing = self._request("GET", f"/api/recipe/{recipe_id}")
if status != 200 or not isinstance(existing, dict):
return {"status": "error", "http_status": status, "recipe": existing}
body = self._recipe_update_payload(existing, payload)
status, updated = self._request("POST", f"/api/recipe/{recipe_id}", body=body, timeout=45)
return {"status": "updated" if status == 200 else "error", "http_status": status, "recipe": updated}
def create_recipe(self, payload: dict[str, Any]) -> dict[str, Any]:
if not self.configured():
raise RuntimeError("KitchenOwl credentials are not configured.")
existing = self.find_existing(payload)
if existing:
if payload.get("photo") and not existing.get("photo"):
refreshed = self.update_recipe(existing.get("id"), payload, existing=existing)
if refreshed.get("status") == "updated":
return {"status": "updated", "recipe": refreshed.get("recipe")}
return {"status": "duplicate", "recipe": existing}
status, created = self._request("POST", f"/api/household/{self.household_id}/recipe", body=payload, timeout=45)
create_payload = dict(payload)
if create_payload.get("photo"):
create_payload["photo"] = self._upload_photo_from_url(create_payload["photo"])
status, created = self._request("POST", f"/api/household/{self.household_id}/recipe", body=create_payload, timeout=45)
return {"status": "created" if status == 200 else "error", "http_status": status, "recipe": created}
kitchenowl = KitchenOwlService()