Expand Doris Kitchen import, failures, and tag curation
This commit is contained in:
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user