mirror of
				https://github.com/mealie-recipes/mealie.git
				synced 2025-10-31 02:03:35 -04:00 
			
		
		
		
	
		
			
				
	
	
		
			385 lines
		
	
	
		
			13 KiB
		
	
	
	
		
			Vue
		
	
	
	
	
	
			
		
		
	
	
			385 lines
		
	
	
		
			13 KiB
		
	
	
	
		
			Vue
		
	
	
	
	
	
| <template>
 | |
|   <v-container
 | |
|     v-if="recipe && recipe.slug && recipe.settings && recipe.recipeIngredient"
 | |
|     :class="{
 | |
|       'pa-0': $vuetify.breakpoint.smAndDown,
 | |
|     }"
 | |
|   >
 | |
|     <BannerExperimental />
 | |
| 
 | |
|     <div v-if="loading">
 | |
|       <v-spacer />
 | |
|       <v-progress-circular indeterminate class="" color="primary"> </v-progress-circular>
 | |
|       {{ loadingText }}
 | |
|       <v-spacer />
 | |
|     </div>
 | |
|     <v-row v-if="!loading">
 | |
|       <v-col cols="12" sm="7" md="7" lg="7">
 | |
|         <RecipeOcrEditorPageCanvas
 | |
|           :image="canvasImage"
 | |
|           :tsv="tsv"
 | |
|           @setText="canvasSetText"
 | |
|           @update-recipe="updateRecipe"
 | |
|           @close-editor="closeEditor"
 | |
|           @text-selected="updateSelectedText"
 | |
|         >
 | |
|         </RecipeOcrEditorPageCanvas>
 | |
| 
 | |
|         <RecipeOcrEditorPageHelp />
 | |
|       </v-col>
 | |
|       <v-col cols="12" sm="5" md="5" lg="5">
 | |
|         <v-tabs v-model="tab" fixed-tabs>
 | |
|           <v-tab key="header">
 | |
|             {{ $t("general.recipe") }}
 | |
|           </v-tab>
 | |
|           <v-tab key="ingredients">
 | |
|             {{ $t("recipe.ingredients") }}
 | |
|           </v-tab>
 | |
|           <v-tab key="instructions">
 | |
|             {{ $t("recipe.instructions") }}
 | |
|           </v-tab>
 | |
|         </v-tabs>
 | |
|         <v-tabs-items v-model="tab">
 | |
|           <v-tab-item key="header">
 | |
|             <v-text-field
 | |
|               v-model="recipe.name"
 | |
|               class="my-3"
 | |
|               :label="$t('recipe.recipe-name')"
 | |
|               :rules="[validators.required]"
 | |
|               @focus="selectedRecipeField = 'name'"
 | |
|             >
 | |
|             </v-text-field>
 | |
| 
 | |
|             <div class="d-flex flex-wrap">
 | |
|               <v-text-field
 | |
|                 v-model="recipe.totalTime"
 | |
|                 class="mx-2"
 | |
|                 :label="$t('recipe.total-time')"
 | |
|                 @click="selectedRecipeField = 'totalTime'"
 | |
|               ></v-text-field>
 | |
|               <v-text-field
 | |
|                 v-model="recipe.prepTime"
 | |
|                 class="mx-2"
 | |
|                 :label="$t('recipe.prep-time')"
 | |
|                 @click="selectedRecipeField = 'prepTime'"
 | |
|               ></v-text-field>
 | |
|               <v-text-field
 | |
|                 v-model="recipe.performTime"
 | |
|                 class="mx-2"
 | |
|                 :label="$t('recipe.perform-time')"
 | |
|                 @click="selectedRecipeField = 'performTime'"
 | |
|               ></v-text-field>
 | |
|             </div>
 | |
| 
 | |
|             <v-textarea
 | |
|               v-model="recipe.description"
 | |
|               auto-grow
 | |
|               min-height="100"
 | |
|               :label="$t('recipe.description')"
 | |
|               @click="selectedRecipeField = 'description'"
 | |
|             >
 | |
|             </v-textarea>
 | |
|             <v-text-field
 | |
|               v-model="recipe.recipeYield"
 | |
|               dense
 | |
|               :label="$t('recipe.servings')"
 | |
|               @click="selectedRecipeField = 'recipeYield'"
 | |
|             >
 | |
|             </v-text-field>
 | |
|           </v-tab-item>
 | |
|           <v-tab-item key="ingredients">
 | |
|             <div class="d-flex justify-end mt-2">
 | |
|               <RecipeDialogBulkAdd class="ml-1 mr-1" :input-text-prop="canvasSelectedText" @bulk-data="addIngredient" />
 | |
|               <BaseButton @click="addIngredient"> {{ $t("general.new") }} </BaseButton>
 | |
|             </div>
 | |
|             <draggable
 | |
|               v-if="recipe.recipeIngredient.length > 0"
 | |
|               v-model="recipe.recipeIngredient"
 | |
|               handle=".handle"
 | |
|               v-bind="{
 | |
|                 animation: 200,
 | |
|                 group: 'description',
 | |
|                 disabled: false,
 | |
|                 ghostClass: 'ghost',
 | |
|               }"
 | |
|               @start="drag = true"
 | |
|               @end="drag = false"
 | |
|             >
 | |
|               <TransitionGroup type="transition" :name="!drag ? 'flip-list' : ''">
 | |
|                 <RecipeIngredientEditor
 | |
|                   v-for="(ingredient, index) in recipe.recipeIngredient"
 | |
|                   :key="ingredient.referenceId"
 | |
|                   v-model="recipe.recipeIngredient[index]"
 | |
|                   class="list-group-item"
 | |
|                   :disable-amount="recipe.settings.disableAmount"
 | |
|                   @delete="recipe.recipeIngredient.splice(index, 1)"
 | |
|                   @clickIngredientField="setSingleIngredient($event, index)"
 | |
|                 />
 | |
|               </TransitionGroup>
 | |
|             </draggable>
 | |
|           </v-tab-item>
 | |
|           <v-tab-item key="instructions">
 | |
|             <div class="d-flex justify-end mt-2">
 | |
|               <RecipeDialogBulkAdd class="ml-1 mr-1" :input-text-prop="canvasSelectedText" @bulk-data="addStep" />
 | |
|               <BaseButton @click="addStep()"> {{ $t("general.new") }}</BaseButton>
 | |
|             </div>
 | |
|             <RecipePageInstructions
 | |
|               v-model="recipe.recipeInstructions"
 | |
|               :ingredients="recipe.recipeIngredient"
 | |
|               :disable-amount="recipe.settings.disableAmount"
 | |
|               :edit="true"
 | |
|               :recipe="recipe"
 | |
|               :assets.sync="recipe.assets"
 | |
|               @click-instruction-field="setSingleStep"
 | |
|             />
 | |
|           </v-tab-item>
 | |
|         </v-tabs-items>
 | |
|       </v-col>
 | |
|     </v-row>
 | |
|   </v-container>
 | |
| </template>
 | |
| 
 | |
| <script lang="ts">
 | |
| import { defineComponent, ref, onMounted, reactive, toRefs, useRouter } from "@nuxtjs/composition-api";
 | |
| import { until } from "@vueuse/core";
 | |
| import { invoke } from "@vueuse/shared";
 | |
| import draggable from "vuedraggable";
 | |
| import RecipePageInstructions from "~/components/Domain/Recipe/RecipePage/RecipePageParts/RecipePageInstructions.vue";
 | |
| import { useUserApi, useStaticRoutes } from "~/composables/api";
 | |
| import { OcrTsvResponse as NullableOcrTsvResponse } from "~/lib/api/types/ocr";
 | |
| import { validators } from "~/composables/use-validators";
 | |
| import { Recipe, RecipeIngredient, RecipeStep } from "~/lib/api/types/recipe";
 | |
| import { Paths, Leaves, SelectedRecipeLeaves } from "~/types/ocr-types";
 | |
| import BannerExperimental from "~/components/global/BannerExperimental.vue";
 | |
| import RecipeDialogBulkAdd from "~/components/Domain/Recipe/RecipeDialogBulkAdd.vue";
 | |
| import RecipeIngredientEditor from "~/components/Domain/Recipe/RecipeIngredientEditor.vue";
 | |
| import RecipeOcrEditorPageCanvas from "~/components/Domain/Recipe/RecipeOcrEditorPage/RecipeOcrEditorPageParts/RecipeOcrEditorPageCanvas.vue";
 | |
| import RecipeOcrEditorPageHelp from "~/components/Domain/Recipe/RecipeOcrEditorPage/RecipeOcrEditorPageParts/RecipeOcrEditorPageHelp.vue";
 | |
| import { uuid4 } from "~/composables/use-utils";
 | |
| import { NoUndefinedField } from "~/lib/api/types/non-generated";
 | |
| 
 | |
| // Temporary Shim until we have a better solution
 | |
| // https://github.com/phillipdupuis/pydantic-to-typescript/issues/28
 | |
| type OcrTsvResponse = NoUndefinedField<NullableOcrTsvResponse>;
 | |
| 
 | |
| export default defineComponent({
 | |
|   components: {
 | |
|     RecipeIngredientEditor,
 | |
|     draggable,
 | |
|     BannerExperimental,
 | |
|     RecipeDialogBulkAdd,
 | |
|     RecipePageInstructions,
 | |
|     RecipeOcrEditorPageCanvas,
 | |
|     RecipeOcrEditorPageHelp,
 | |
|   },
 | |
|   props: {
 | |
|     recipe: {
 | |
|       type: Object as () => NoUndefinedField<Recipe>,
 | |
|       required: true,
 | |
|     },
 | |
|   },
 | |
|   setup(props) {
 | |
|     const router = useRouter();
 | |
|     const api = useUserApi();
 | |
| 
 | |
|     const tsv = ref<OcrTsvResponse[]>([]);
 | |
| 
 | |
|     const drag = ref(false);
 | |
| 
 | |
|     const { recipeAssetPath } = useStaticRoutes();
 | |
| 
 | |
|     function assetURL(assetName: string) {
 | |
|       return recipeAssetPath(props.recipe.id, assetName);
 | |
|     }
 | |
| 
 | |
|     const state = reactive({
 | |
|       loading: true,
 | |
|       loadingText: "Loading recipe...",
 | |
|       tab: null,
 | |
|       selectedRecipeField: "" as SelectedRecipeLeaves | "",
 | |
|       canvasSelectedText: "",
 | |
|       canvasImage: new Image(),
 | |
|     });
 | |
| 
 | |
|     const setPropertyValueByPath = function <T extends Recipe>(object: T, path: Paths<T>, value: any) {
 | |
|       const a = path.split(".");
 | |
|       let nextProperty: any = object;
 | |
|       for (let i = 0, n = a.length - 1; i < n; ++i) {
 | |
|         const k = a[i];
 | |
|         if (k in nextProperty) {
 | |
|           nextProperty = nextProperty[k];
 | |
|         } else {
 | |
|           return;
 | |
|         }
 | |
|       }
 | |
|       nextProperty[a[a.length - 1]] = value;
 | |
|     };
 | |
| 
 | |
|     /**
 | |
|      * This function will find the title of a recipe with the assumption that the title
 | |
|      * has the biggest ratio of surface area / number of words on the image.
 | |
|      * @return Returns the text parts of the block with the highest score.
 | |
|      */
 | |
|     function findRecipeTitle() {
 | |
|       const filtered = tsv.value.filter((element) => element.level === 2 || element.level === 5);
 | |
|       const blocks = [[]] as OcrTsvResponse[][];
 | |
|       let blockNum = 1;
 | |
|       filtered.forEach((element, index, array) => {
 | |
|         if (index !== 0 && array[index - 1].blockNum !== element.blockNum) {
 | |
|           blocks.push([]);
 | |
|           blockNum = element.blockNum;
 | |
|         }
 | |
|         blocks[blockNum - 1].push(element);
 | |
|       });
 | |
| 
 | |
|       let bestScore = 0;
 | |
|       let bestBlock = blocks[0];
 | |
|       blocks.forEach((element) => {
 | |
|         // element[0] is the block declaration line containing the blocks total dimensions
 | |
|         // element.length is the number of words (+ 2) contained in that block
 | |
|         const elementScore = (element[0].height * element[0].width) / element.length; // Prettier is adding useless parenthesis for a mysterious reason
 | |
|         const elementText = element.map((element) => element.text).join(""); // Identify empty blocks and don't count them
 | |
|         if (elementScore > bestScore && elementText !== "") {
 | |
|           bestBlock = element;
 | |
|           bestScore = elementScore;
 | |
|         }
 | |
|       });
 | |
| 
 | |
|       return bestBlock
 | |
|         .filter((element) => element.level === 5 && element.conf >= 40)
 | |
|         .map((element) => {
 | |
|           return element.text.trim();
 | |
|         })
 | |
|         .join(" ");
 | |
|     }
 | |
| 
 | |
|     onMounted(() => {
 | |
|       invoke(async () => {
 | |
|         await until(props.recipe).not.toBeNull();
 | |
|         state.loadingText = "Loading OCR data...";
 | |
| 
 | |
|         const assetName = props.recipe.assets[0].fileName;
 | |
|         const imagesrc = assetURL(assetName);
 | |
|         state.canvasImage.src = imagesrc;
 | |
| 
 | |
|         const res = await api.ocr.assetToTsv(props.recipe.slug, assetName);
 | |
|         tsv.value = res.data as OcrTsvResponse[];
 | |
|         state.loading = false;
 | |
| 
 | |
|         if (props.recipe.name.match(/New\sOCR\sRecipe(\s\([0-9]+\))?/g)) {
 | |
|           props.recipe.name = findRecipeTitle();
 | |
|         }
 | |
|       });
 | |
|     });
 | |
| 
 | |
|     function addIngredient(ingredients: Array<string> | null = null) {
 | |
|       if (ingredients?.length) {
 | |
|         const newIngredients = ingredients.map((x) => {
 | |
|           return {
 | |
|             referenceId: uuid4(),
 | |
|             title: "",
 | |
|             note: x,
 | |
|             unit: undefined,
 | |
|             food: undefined,
 | |
|             disableAmount: true,
 | |
|             quantity: 1,
 | |
|             originalText: "",
 | |
|           };
 | |
|         });
 | |
| 
 | |
|         if (newIngredients) {
 | |
|           // @ts-expect-error - prop can be null-type by NoUndefinedField type forces it to be set
 | |
|           props.recipe.recipeIngredient.push(...newIngredients);
 | |
|         }
 | |
|       } else {
 | |
|         props.recipe.recipeIngredient.push({
 | |
|           referenceId: uuid4(),
 | |
|           title: "",
 | |
|           note: "",
 | |
|           // @ts-expect-error - prop can be null-type by NoUndefinedField type forces it to be set
 | |
|           unit: undefined,
 | |
|           // @ts-expect-error - prop can be null-type by NoUndefinedField type forces it to be set
 | |
|           food: undefined,
 | |
|           disableAmount: true,
 | |
|           quantity: 1,
 | |
|         });
 | |
|       }
 | |
|     }
 | |
| 
 | |
|     function addStep(steps: Array<string> | null = null) {
 | |
|       if (!props.recipe.recipeInstructions) {
 | |
|         return;
 | |
|       }
 | |
| 
 | |
|       if (steps) {
 | |
|         const cleanedSteps = steps.map((step) => {
 | |
|           return { id: uuid4(), text: step, title: "", ingredientReferences: [] };
 | |
|         });
 | |
| 
 | |
|         props.recipe.recipeInstructions.push(...cleanedSteps);
 | |
|       } else {
 | |
|         props.recipe.recipeInstructions.push({ id: uuid4(), text: "", title: "", ingredientReferences: [] });
 | |
|       }
 | |
|     }
 | |
| 
 | |
|     //  EVENT HANDLERS
 | |
| 
 | |
|     // Canvas component event handlers
 | |
|     async function updateRecipe() {
 | |
|       const { data } = await api.recipes.updateOne(props.recipe.slug, props.recipe);
 | |
|       if (data?.slug) {
 | |
|         router.push("/recipe/" + data.slug);
 | |
|       }
 | |
|     }
 | |
| 
 | |
|     function closeEditor() {
 | |
|       router.push("/recipe/" + props.recipe.slug);
 | |
|     }
 | |
| 
 | |
|     const canvasSetText = function () {
 | |
|       if (state.selectedRecipeField !== "") {
 | |
|         setPropertyValueByPath<Recipe>(props.recipe, state.selectedRecipeField, state.canvasSelectedText);
 | |
|       }
 | |
|     };
 | |
| 
 | |
|     function updateSelectedText(value: string) {
 | |
|       state.canvasSelectedText = value;
 | |
|     }
 | |
| 
 | |
|     // Recipe field selection event handlers
 | |
|     function setSingleIngredient(f: keyof RecipeIngredient, index: number) {
 | |
|       state.selectedRecipeField = `recipeIngredient.${index}.${f}` as SelectedRecipeLeaves;
 | |
|     }
 | |
| 
 | |
|     // Leaves<RecipeStep[]> will return some function types making eslint very unhappy
 | |
|     type RecipeStepsLeaves = `${number}.${Leaves<RecipeStep>}`;
 | |
| 
 | |
|     function setSingleStep(path: RecipeStepsLeaves) {
 | |
|       state.selectedRecipeField = `recipeInstructions.${path}` as SelectedRecipeLeaves;
 | |
|     }
 | |
| 
 | |
|     return {
 | |
|       ...toRefs(state),
 | |
|       addIngredient,
 | |
|       addStep,
 | |
|       drag,
 | |
|       assetURL,
 | |
|       updateRecipe,
 | |
|       closeEditor,
 | |
|       updateSelectedText,
 | |
|       tsv,
 | |
|       validators,
 | |
|       setSingleIngredient,
 | |
|       setSingleStep,
 | |
|       canvasSetText,
 | |
|     };
 | |
|   },
 | |
| });
 | |
| </script>
 | |
| 
 | |
| <style lang="css">
 | |
| .ghost {
 | |
|   opacity: 0.5;
 | |
| }
 | |
| </style>
 |