feat: create recipe from multiple images (#5590)
Some checks are pending
CodeQL / Analyze (push) Waiting to run
Docker Nightly Production / Backend Server Tests (push) Waiting to run
Docker Nightly Production / Frontend Tests (push) Waiting to run
Docker Nightly Production / Build Package (push) Waiting to run
Docker Nightly Production / Build Tagged Release (push) Blocked by required conditions
Docker Nightly Production / Notify Discord (push) Blocked by required conditions
Release Drafter / ✏️ Draft release (push) Waiting to run

Co-authored-by: Kuchenpirat <24235032+Kuchenpirat@users.noreply.github.com>
Co-authored-by: Kuchenpirat <jojow@gmx.net>
Co-authored-by: Michael Genson <71845777+michael-genson@users.noreply.github.com>
This commit is contained in:
Ross 2025-06-28 22:11:12 +02:00 committed by GitHub
parent 084f99b0de
commit 95fa0af28a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 149 additions and 131 deletions

View file

@ -231,7 +231,7 @@ export default defineNuxtComponent({
{ {
insertDivider: false, insertDivider: false,
icon: $globals.icons.fileImage, icon: $globals.icons.fileImage,
title: i18n.t("recipe.create-from-image"), title: i18n.t("recipe.create-from-images"),
subtitle: i18n.t("recipe.create-recipe-from-an-image"), subtitle: i18n.t("recipe.create-recipe-from-an-image"),
to: `/g/${groupSlug.value}/r/create/image`, to: `/g/${groupSlug.value}/r/create/image`,
restricted: true, restricted: true,

View file

@ -1,10 +1,11 @@
<template> <template>
<v-form ref="file"> <v-form ref="files">
<input <input
ref="uploader" ref="uploader"
class="d-none" class="d-none"
type="file" type="file"
:accept="accept" :accept="accept"
:multiple="multiple"
@change="onFileChanged" @change="onFileChanged"
> >
<slot v-bind="{ isSelecting, onButtonClick }"> <slot v-bind="{ isSelecting, onButtonClick }">
@ -72,9 +73,13 @@ export default defineNuxtComponent({
type: Boolean, type: Boolean,
default: false, default: false,
}, },
multiple: {
type: Boolean,
default: false,
},
}, },
setup(props, context) { setup(props, context) {
const file = ref<File | null>(null); const files = ref<File[]>([]);
const uploader = ref<HTMLInputElement | null>(null); const uploader = ref<HTMLInputElement | null>(null);
const isSelecting = ref(false); const isSelecting = ref(false);
@ -86,35 +91,52 @@ export default defineNuxtComponent({
const api = useUserApi(); const api = useUserApi();
async function upload() { async function upload() {
if (file.value != null) { if (files.value.length === 0) {
isSelecting.value = true; return;
if (!props.post) {
context.emit(UPLOAD_EVENT, file.value);
isSelecting.value = false;
return;
}
const formData = new FormData();
formData.append(props.fileName, file.value);
try {
const response = await api.upload.file(props.url, formData);
if (response) {
context.emit(UPLOAD_EVENT, response);
}
}
catch (e) {
console.error(e);
context.emit(UPLOAD_EVENT, null);
}
isSelecting.value = false;
} }
isSelecting.value = true;
if (!props.post) {
// NOTE: To preserve behaviour for other parents of this component,
// we emit a single File if !props.multiple.
context.emit(UPLOAD_EVENT, props.multiple ? files.value : files.value[0]);
isSelecting.value = false;
return;
}
// WARN: My change is only for !props.post.
// I have not added support for multiple files in the API.
// Existing call-sites never passed the `multiple` prop,
// so this case will only be hit if the prop is set to true.
if (props.multiple && files.value.length > 1) {
console.warn("Multiple file uploads are not supported by the API.");
return;
}
const file = files.value[0];
const formData = new FormData();
formData.append(props.fileName, file);
try {
const response = await api.upload.file(props.url, formData);
if (response) {
context.emit(UPLOAD_EVENT, response);
}
}
catch (e) {
console.error(e);
context.emit(UPLOAD_EVENT, null);
}
isSelecting.value = false;
} }
function onFileChanged(e: Event) { function onFileChanged(e: Event) {
const target = e.target as HTMLInputElement; const target = e.target as HTMLInputElement;
if (target.files !== null && target.files.length > 0 && file.value !== null) {
file.value = target.files[0]; if (target.files !== null && target.files.length > 0) {
files.value = Array.from(target.files);
upload(); upload();
} }
} }
@ -132,7 +154,7 @@ export default defineNuxtComponent({
} }
return { return {
file, files,
uploader, uploader,
isSelecting, isSelecting,
effIcon, effIcon,

View file

@ -1,16 +1,17 @@
import type { AxiosInstance, AxiosResponse } from "axios"; import type { AxiosInstance, AxiosResponse, AxiosRequestConfig } from "axios";
import type { ApiRequestInstance, RequestResponse } from "~/lib/api/types/non-generated"; import type { ApiRequestInstance, RequestResponse } from "~/lib/api/types/non-generated";
import { AdminAPI, PublicApi, UserApi } from "~/lib/api"; import { AdminAPI, PublicApi, UserApi } from "~/lib/api";
import { PublicExploreApi } from "~/lib/api/client-public"; import { PublicExploreApi } from "~/lib/api/client-public";
const request = { const request = {
async safe<T, U>( async safe<T, U>(
funcCall: (url: string, data: U) => Promise<AxiosResponse<T>>, funcCall: (url: string, data: U, config?: AxiosRequestConfig) => Promise<AxiosResponse<T>>,
url: string, url: string,
data: U, data: U,
config?: AxiosRequestConfig,
): Promise<RequestResponse<T>> { ): Promise<RequestResponse<T>> {
let error = null; let error = null;
const response = await funcCall(url, data).catch(function (e) { const response = await funcCall(url, data, config).catch(function (e) {
console.log(e); console.log(e);
// Insert Generic Error Handling Here // Insert Generic Error Handling Here
error = e; error = e;
@ -22,9 +23,9 @@ const request = {
function getRequests(axiosInstance: AxiosInstance): ApiRequestInstance { function getRequests(axiosInstance: AxiosInstance): ApiRequestInstance {
return { return {
async get<T>(url: string, params = {}): Promise<RequestResponse<T>> { async get<T>(url: string, params = {}, config?: AxiosRequestConfig): Promise<RequestResponse<T>> {
let error = null; let error = null;
const response = await axiosInstance.get<T>(url, params).catch((e) => { const response = await axiosInstance.get<T>(url, { ...config, params }).catch((e) => {
error = e; error = e;
}); });
if (response != null) { if (response != null) {
@ -33,20 +34,20 @@ function getRequests(axiosInstance: AxiosInstance): ApiRequestInstance {
return { response: null, error, data: null }; return { response: null, error, data: null };
}, },
async post<T, U>(url: string, data: U) { async post<T, U>(url: string, data: U, config?: AxiosRequestConfig) {
return await request.safe<T, U>(axiosInstance.post, url, data); return await request.safe<T, U>(axiosInstance.post, url, data, config);
}, },
async put<T, U = T>(url: string, data: U) { async put<T, U = T>(url: string, data: U, config?: AxiosRequestConfig) {
return await request.safe<T, U>(axiosInstance.put, url, data); return await request.safe<T, U>(axiosInstance.put, url, data, config);
}, },
async patch<T, U = Partial<T>>(url: string, data: U) { async patch<T, U = Partial<T>>(url: string, data: U, config?: AxiosRequestConfig) {
return await request.safe<T, U>(axiosInstance.patch, url, data); return await request.safe<T, U>(axiosInstance.patch, url, data, config);
}, },
async delete<T>(url: string) { async delete<T>(url: string, config?: AxiosRequestConfig) {
return await request.safe<T, undefined>(axiosInstance.delete, url, undefined); return await request.safe<T, undefined>(axiosInstance.delete, url, undefined, config);
}, },
}; };
} }

View file

@ -596,12 +596,13 @@
"create-recipe-description": "Create a new recipe from scratch.", "create-recipe-description": "Create a new recipe from scratch.",
"create-recipes": "Create Recipes", "create-recipes": "Create Recipes",
"import-with-zip": "Import with .zip", "import-with-zip": "Import with .zip",
"create-recipe-from-an-image": "Create Recipe from an Image", "create-recipe-from-an-image": "Create Recipe from Images",
"create-recipe-from-an-image-description": "Create a recipe by uploading an image of it. Mealie will attempt to extract the text from the image using AI and create a recipe from it.", "create-recipe-from-an-image-description": "Create a recipe by uploading images of the recipe text. Mealie will attempt to extract the text from the images using AI and create a new recipe from it.",
"crop-and-rotate-the-image": "Crop and rotate the image so that only the text is visible, and it's in the correct orientation.", "crop-and-rotate-the-image": "Crop and rotate the image so that only the text is visible, and it's in the correct orientation.",
"create-from-image": "Create from Image", "create-from-images": "Create from Images",
"should-translate-description": "Translate the recipe into my language", "should-translate-description": "Translate the recipe into my language",
"please-wait-image-procesing": "Please wait, the image is processing. This may take some time.", "please-wait-image-procesing": "Please wait, the image is processing. This may take some time.",
"please-wait-images-processing": "Please wait, the images are processing. This may take some time.",
"bulk-url-import": "Bulk URL Import", "bulk-url-import": "Bulk URL Import",
"debug-scraper": "Debug Scraper", "debug-scraper": "Debug Scraper",
"create-a-recipe-by-providing-the-name-all-recipes-must-have-unique-names": "Create a recipe by providing the name. All recipes must have unique names.", "create-a-recipe-by-providing-the-name-all-recipes-must-have-unique-names": "Create a recipe by providing the name. All recipes must have unique names.",
@ -660,7 +661,10 @@
"no-food": "No Food" "no-food": "No Food"
}, },
"reset-servings-count": "Reset Servings Count", "reset-servings-count": "Reset Servings Count",
"not-linked-ingredients": "Additional Ingredients" "not-linked-ingredients": "Additional Ingredients",
"upload-another-image": "Upload another image",
"upload-images": "Upload images",
"upload-more-images": "Upload more images"
}, },
"recipe-finder": { "recipe-finder": {
"recipe-finder": "Recipe Finder", "recipe-finder": "Recipe Finder",

View file

@ -1,4 +1,4 @@
import type { AxiosResponse } from "axios"; import type { AxiosRequestConfig, AxiosResponse } from "axios";
export type NoUndefinedField<T> = { [P in keyof T]-?: NoUndefinedField<NonNullable<T[P]>> }; export type NoUndefinedField<T> = { [P in keyof T]-?: NoUndefinedField<NonNullable<T[P]>> };
@ -9,11 +9,11 @@ export interface RequestResponse<T> {
} }
export interface ApiRequestInstance { export interface ApiRequestInstance {
get<T>(url: string, data?: unknown): Promise<RequestResponse<T>>; get<T>(url: string, data?: unknown, config?: AxiosRequestConfig): Promise<RequestResponse<T>>;
post<T>(url: string, data: unknown): Promise<RequestResponse<T>>; post<T>(url: string, data: unknown, config?: AxiosRequestConfig): Promise<RequestResponse<T>>;
put<T, U = T>(url: string, data: U): Promise<RequestResponse<T>>; put<T, U = T>(url: string, data: U, config?: AxiosRequestConfig): Promise<RequestResponse<T>>;
patch<T, U = Partial<T>>(url: string, data: U): Promise<RequestResponse<T>>; patch<T, U = Partial<T>>(url: string, data: U, config?: AxiosRequestConfig): Promise<RequestResponse<T>>;
delete<T>(url: string): Promise<RequestResponse<T>>; delete<T>(url: string, config?: AxiosRequestConfig): Promise<RequestResponse<T>>;
} }
export interface PaginationData<T> { export interface PaginationData<T> {

View file

@ -157,17 +157,19 @@ export class RecipeAPI extends BaseCRUDAPI<CreateRecipe, Recipe, Recipe> {
return await this.requests.post<string>(routes.recipesCreateUrlBulk, payload); return await this.requests.post<string>(routes.recipesCreateUrlBulk, payload);
} }
async createOneFromImage(fileObject: Blob | File, fileName: string, translateLanguage: string | null = null) { async createOneFromImages(fileObjects: (Blob | File)[], translateLanguage: string | null = null) {
const formData = new FormData(); const formData = new FormData();
formData.append("images", fileObject);
formData.append("extension", fileName.split(".").pop() ?? ""); fileObjects.forEach((file) => {
formData.append("images", file);
});
let apiRoute = routes.recipesCreateFromImage; let apiRoute = routes.recipesCreateFromImage;
if (translateLanguage) { if (translateLanguage) {
apiRoute = `${apiRoute}?translateLanguage=${translateLanguage}`; apiRoute = `${apiRoute}?translateLanguage=${translateLanguage}`;
} }
return await this.requests.post<string>(apiRoute, formData); return await this.requests.post<string>(apiRoute, formData, { timeout: 120000 });
} }
async parseIngredients(parser: Parser, ingredients: Array<string>) { async parseIngredients(parser: Parser, ingredients: Array<string>) {

View file

@ -80,7 +80,7 @@ export default defineNuxtComponent({
}, },
{ {
icon: $globals.icons.fileImage, icon: $globals.icons.fileImage,
text: i18n.t("recipe.create-from-image"), text: i18n.t("recipe.create-from-images"),
value: "image", value: "image",
hide: !enableOpenAIImages.value, hide: !enableOpenAIImages.value,
}, },

View file

@ -1,86 +1,70 @@
<template> <template>
<div> <div>
<v-form <v-form ref="domUrlForm" @submit.prevent="createRecipe">
ref="domUrlForm"
@submit.prevent="createRecipe"
>
<div> <div>
<v-card-title class="headline"> <v-card-title class="headline">
{{ $t('recipe.create-recipe-from-an-image') }} {{ $t("recipe.create-recipe-from-an-image") }}
</v-card-title> </v-card-title>
<v-card-text> <v-card-text>
<p>{{ $t('recipe.create-recipe-from-an-image-description') }}</p> <p>{{ $t("recipe.create-recipe-from-an-image-description") }}</p>
<v-container class="pa-0"> <v-container class="pa-0">
<v-row> <v-row>
<v-col <v-col cols="auto" align-self="center">
cols="auto"
align-self="center"
>
<AppButtonUpload <AppButtonUpload
v-if="!uploadedImage"
class="ml-auto" class="ml-auto"
url="none" url="none"
file-name="image" file-name="images"
accept="image/*" accept="image/*"
:text="$t('recipe.upload-image')" :text="uploadedImages.length ? $t('recipe.upload-more-images') : $t('recipe.upload-images')"
:text-btn="false" :text-btn="false"
:post="false" :post="false"
@uploaded="uploadImage" :multiple="true"
@uploaded="uploadImages"
/> />
<v-btn
v-if="!!uploadedImage"
color="error"
@click="clearImage"
>
<v-icon start>
{{ $globals.icons.close }}
</v-icon>
{{ $t('recipe.remove-image') }}
</v-btn>
</v-col> </v-col>
<v-spacer /> <v-spacer />
</v-row> </v-row>
<div <div v-if="uploadedImages.length" class="mt-3">
v-if="uploadedImage && uploadedImagePreviewUrl"
class="mt-3"
>
<v-row> <v-row>
<v-col <v-col cols="12" class="pb-0">
cols="12"
class="pb-0"
>
<v-card-text class="pa-0"> <v-card-text class="pa-0">
<p class="mb-0"> <p class="mb-0">
{{ $t('recipe.crop-and-rotate-the-image') }} {{ $t("recipe.crop-and-rotate-the-image") }}
</p> </p>
</v-card-text> </v-card-text>
</v-col> </v-col>
</v-row> </v-row>
<v-row style="max-width: 600px;"> <v-row style="max-width: 600px">
<v-spacer /> <v-spacer />
<v-col cols="12"> <v-col v-for="(imageUrl, index) in uploadedImagesPreviewUrls" :key="index" cols="12">
<ImageCropper <v-row>
:img="uploadedImagePreviewUrl" <v-col cols="auto" align-self="center">
cropper-height="50vh" <ImageCropper
cropper-width="100%" :img="imageUrl"
@save="updateUploadedImage" cropper-height="100%"
/> cropper-width="100%"
@save="(croppedImage) => updateUploadedImage(index, croppedImage)"
/>
<v-btn color="error" @click="() => clearImage(index)">
<v-icon start>
{{ $globals.icons.close }}
</v-icon>
{{ $t("recipe.remove-image") }}
</v-btn>
</v-col>
</v-row>
</v-col> </v-col>
<v-spacer /> <v-spacer />
</v-row> </v-row>
</div> </div>
</v-container> </v-container>
</v-card-text> </v-card-text>
<v-card-actions v-if="uploadedImage"> <v-card-actions v-if="uploadedImages.length">
<div> <div>
<p style="width: 250px"> <p style="width: 250px">
<BaseButton <BaseButton rounded block type="submit" :loading="loading" />
rounded
block
type="submit"
:loading="loading"
/>
</p> </p>
<p> <p>
<v-checkbox <v-checkbox
@ -90,11 +74,12 @@
:disabled="loading" :disabled="loading"
/> />
</p> </p>
<p <p v-if="loading" class="mb-0">
v-if="loading" {{
class="mb-0" uploadedImages.length > 1
> ? $t("recipe.please-wait-images-processing")
{{ $t('recipe.please-wait-image-procesing') }} : $t("recipe.please-wait-image-procesing")
}}
</p> </p>
</div> </div>
</v-card-actions> </v-card-actions>
@ -121,55 +106,59 @@ export default defineNuxtComponent({
const groupSlug = computed(() => route.params.groupSlug || ""); const groupSlug = computed(() => route.params.groupSlug || "");
const domUrlForm = ref<VForm | null>(null); const domUrlForm = ref<VForm | null>(null);
const uploadedImage = ref<Blob | File>(); const uploadedImages = ref<(Blob | File)[]>([]);
const uploadedImageName = ref<string>(""); const uploadedImageNames = ref<string[]>([]);
const uploadedImagePreviewUrl = ref<string>(); const uploadedImagesPreviewUrls = ref<string[]>([]);
const shouldTranslate = ref(true); const shouldTranslate = ref(true);
function uploadImage(fileObject: File) { function uploadImages(files: File[]) {
uploadedImage.value = fileObject; uploadedImages.value = [...uploadedImages.value, ...files];
uploadedImageName.value = fileObject.name; uploadedImageNames.value = [...uploadedImageNames.value, ...files.map(file => file.name)];
uploadedImagePreviewUrl.value = URL.createObjectURL(fileObject); uploadedImagesPreviewUrls.value = [
...uploadedImagesPreviewUrls.value,
...files.map(file => URL.createObjectURL(file)),
];
} }
function updateUploadedImage(fileObject: Blob) { function clearImage(index: number) {
uploadedImage.value = fileObject; URL.revokeObjectURL(uploadedImagesPreviewUrls.value[index]);
uploadedImagePreviewUrl.value = URL.createObjectURL(fileObject);
}
function clearImage() { uploadedImages.value = uploadedImages.value.filter((_, i) => i !== index);
uploadedImage.value = undefined; uploadedImageNames.value = uploadedImageNames.value.filter((_, i) => i !== index);
uploadedImageName.value = ""; uploadedImagesPreviewUrls.value = uploadedImagesPreviewUrls.value.filter((_, i) => i !== index);
uploadedImagePreviewUrl.value = undefined;
} }
async function createRecipe() { async function createRecipe() {
if (!uploadedImage.value) { if (uploadedImages.value.length === 0) {
return; return;
} }
state.loading = true; state.loading = true;
const translateLanguage = shouldTranslate.value ? i18n.locale : undefined; const translateLanguage = shouldTranslate.value ? i18n.locale : undefined;
const { data, error } = await api.recipes.createOneFromImage(uploadedImage.value, uploadedImageName.value, translateLanguage?.value); const { data, error } = await api.recipes.createOneFromImages(uploadedImages.value, translateLanguage?.value);
if (error || !data) { if (error || !data) {
alert.error(i18n.t("events.something-went-wrong")); alert.error(i18n.t("events.something-went-wrong"));
state.loading = false; state.loading = false;
} }
else { else {
router.push(`/g/${groupSlug.value}/r/${data}`); router.push(`/g/${groupSlug.value}/r/${data}`);
}; }
}
function updateUploadedImage(index: number, croppedImage: Blob) {
uploadedImages.value[index] = croppedImage;
} }
return { return {
...toRefs(state), ...toRefs(state),
domUrlForm, domUrlForm,
uploadedImage, uploadedImages,
uploadedImagePreviewUrl, uploadedImagesPreviewUrls,
shouldTranslate, shouldTranslate,
uploadImage, uploadImages,
updateUploadedImage,
clearImage, clearImage,
createRecipe, createRecipe,
updateUploadedImage,
}; };
}, },
}); });