mirror of
				https://github.com/mealie-recipes/mealie.git
				synced 2025-11-04 03:03:18 -05:00 
			
		
		
		
	* add vitest * initialize lib w/ tests * move to dev dep * run tests in CI * update file names * move api folder to lib * move api and api types to same folder * update generator outpath * rm husky * i guess i _did_ need those types * reorg types * extract validators into testable components * (WIP) start composable testing * fix import type * fix linter complaint * simplify icon type def * fix linter errors (maybe?) * rename client file for sorting
		
			
				
	
	
		
			306 lines
		
	
	
		
			9.6 KiB
		
	
	
	
		
			Vue
		
	
	
	
	
	
			
		
		
	
	
			306 lines
		
	
	
		
			9.6 KiB
		
	
	
	
		
			Vue
		
	
	
	
	
	
<template>
 | 
						|
  <v-container v-if="recipe">
 | 
						|
    <v-container>
 | 
						|
      <v-alert dismissible border="left" colored-border type="warning" elevation="2" :icon="$globals.icons.alert">
 | 
						|
        <b>Experimental Feature</b>
 | 
						|
        <div>
 | 
						|
          Mealie can use natural language processing to attempt to parse and create units, and foods for your Recipe
 | 
						|
          ingredients. This is experimental and may not work as expected. If you choose to not use the parsed results
 | 
						|
          you can select cancel and your changes will not be saved.
 | 
						|
        </div>
 | 
						|
      </v-alert>
 | 
						|
 | 
						|
      <BaseCardSectionTitle title="Ingredients Processor">
 | 
						|
        To use the ingredient parser, click the "Parse All" button and the process will start. When the processed
 | 
						|
        ingredients are available, you can look through the items and verify that they were parsed correctly. The models
 | 
						|
        confidence score is displayed on the right of the title item. This is an average of all scores and may not be
 | 
						|
        wholely accurate.
 | 
						|
 | 
						|
        <div class="my-4">
 | 
						|
          Alerts will be displayed if a matching foods or unit is found but does not exists in the database.
 | 
						|
        </div>
 | 
						|
        <div class="d-flex align-center mb-n4">
 | 
						|
          <div class="mb-4">Select Parser</div>
 | 
						|
          <BaseOverflowButton
 | 
						|
            v-model="parser"
 | 
						|
            btn-class="mx-2 mb-4"
 | 
						|
            :items="[
 | 
						|
              {
 | 
						|
                text: 'Natural Language Processor ',
 | 
						|
                value: 'nlp',
 | 
						|
              },
 | 
						|
              {
 | 
						|
                text: 'Brute Parser',
 | 
						|
                value: 'brute',
 | 
						|
              },
 | 
						|
            ]"
 | 
						|
          />
 | 
						|
        </div>
 | 
						|
      </BaseCardSectionTitle>
 | 
						|
 | 
						|
      <div class="d-flex mt-n3 mb-4 justify-end" style="gap: 5px">
 | 
						|
        <BaseButton cancel class="mr-auto" @click="$router.go(-1)"></BaseButton>
 | 
						|
        <BaseButton color="info" @click="fetchParsed">
 | 
						|
          <template #icon> {{ $globals.icons.foods }}</template>
 | 
						|
          Parse All
 | 
						|
        </BaseButton>
 | 
						|
        <BaseButton save @click="saveAll"> Save All </BaseButton>
 | 
						|
      </div>
 | 
						|
 | 
						|
      <v-expansion-panels v-model="panels" multiple>
 | 
						|
        <v-expansion-panel v-for="(ing, index) in parsedIng" :key="index">
 | 
						|
          <v-expansion-panel-header class="my-0 py-0" disable-icon-rotate>
 | 
						|
            <template #default="{ open }">
 | 
						|
              <v-fade-transition>
 | 
						|
                <span v-if="!open" key="0"> {{ ing.input }} </span>
 | 
						|
              </v-fade-transition>
 | 
						|
            </template>
 | 
						|
            <template #actions>
 | 
						|
              <v-icon left :color="isError(ing) ? 'error' : 'success'">
 | 
						|
                {{ isError(ing) ? $globals.icons.alert : $globals.icons.check }}
 | 
						|
              </v-icon>
 | 
						|
              <div class="my-auto" :color="isError(ing) ? 'error-text' : 'success-text'">
 | 
						|
                {{ ing.confidence ? asPercentage(ing.confidence.average) : "" }}
 | 
						|
              </div>
 | 
						|
            </template>
 | 
						|
          </v-expansion-panel-header>
 | 
						|
          <v-expansion-panel-content class="pb-0 mb-0">
 | 
						|
            <RecipeIngredientEditor v-model="parsedIng[index].ingredient" />
 | 
						|
            {{ ing.input }}
 | 
						|
            <v-card-actions>
 | 
						|
              <v-spacer></v-spacer>
 | 
						|
              <BaseButton
 | 
						|
                v-if="errors[index].foodError && errors[index].foodErrorMessage !== ''"
 | 
						|
                color="warning"
 | 
						|
                small
 | 
						|
                @click="createFood(ing.ingredient.food, index)"
 | 
						|
              >
 | 
						|
                {{ errors[index].foodErrorMessage }}
 | 
						|
              </BaseButton>
 | 
						|
            </v-card-actions>
 | 
						|
          </v-expansion-panel-content>
 | 
						|
        </v-expansion-panel>
 | 
						|
      </v-expansion-panels>
 | 
						|
    </v-container>
 | 
						|
  </v-container>
 | 
						|
</template>
 | 
						|
 | 
						|
<script lang="ts">
 | 
						|
import { defineComponent, ref, useRoute, useRouter } from "@nuxtjs/composition-api";
 | 
						|
import { invoke, until } from "@vueuse/core";
 | 
						|
import {
 | 
						|
  CreateIngredientFood,
 | 
						|
  CreateIngredientUnit,
 | 
						|
  IngredientFood,
 | 
						|
  IngredientUnit,
 | 
						|
  ParsedIngredient,
 | 
						|
} from "~/lib/api/types/recipe";
 | 
						|
import RecipeIngredientEditor from "~/components/Domain/Recipe/RecipeIngredientEditor.vue";
 | 
						|
import { useUserApi } from "~/composables/api";
 | 
						|
import { useRecipe } from "~/composables/recipes";
 | 
						|
import { RecipeIngredient } from "~/lib/api/types/admin";
 | 
						|
import { useFoodData, useFoodStore, useUnitStore } from "~/composables/store";
 | 
						|
import { Parser } from "~/lib/api/user/recipes/recipe";
 | 
						|
 | 
						|
interface Error {
 | 
						|
  ingredientIndex: number;
 | 
						|
  unitError: boolean;
 | 
						|
  unitErrorMessage: string;
 | 
						|
  foodError: boolean;
 | 
						|
  foodErrorMessage: string;
 | 
						|
}
 | 
						|
 | 
						|
export default defineComponent({
 | 
						|
  components: {
 | 
						|
    RecipeIngredientEditor,
 | 
						|
  },
 | 
						|
  setup() {
 | 
						|
    const panels = ref<number[]>([]);
 | 
						|
 | 
						|
    const route = useRoute();
 | 
						|
    const router = useRouter();
 | 
						|
    const slug = route.value.params.slug;
 | 
						|
    const api = useUserApi();
 | 
						|
 | 
						|
    const { recipe, loading } = useRecipe(slug);
 | 
						|
 | 
						|
    invoke(async () => {
 | 
						|
      await until(recipe).not.toBeNull();
 | 
						|
 | 
						|
      fetchParsed();
 | 
						|
    });
 | 
						|
 | 
						|
    const ingredients = ref<any[]>([]);
 | 
						|
 | 
						|
    // =========================================================
 | 
						|
    // Parser Logic
 | 
						|
    const parser = ref<Parser>("nlp");
 | 
						|
    const parsedIng = ref<ParsedIngredient[]>([]);
 | 
						|
 | 
						|
    async function fetchParsed() {
 | 
						|
      if (!recipe.value || !recipe.value.recipeIngredient) {
 | 
						|
        return;
 | 
						|
      }
 | 
						|
      const raw = recipe.value.recipeIngredient.map((ing) => ing.note ?? "");
 | 
						|
      const { data } = await api.recipes.parseIngredients(parser.value, raw);
 | 
						|
 | 
						|
      if (data) {
 | 
						|
        // When we send the recipe ingredient text to be parsed, we lose the reference to the original unparsed ingredient.
 | 
						|
        // Generally this is fine, but if the unparsed ingredient had a title, we lose it; we add back the title for each ingredient here.
 | 
						|
        try {
 | 
						|
          for (let i = 0; i < recipe.value.recipeIngredient.length; i++) {
 | 
						|
            data[i].ingredient.title = recipe.value.recipeIngredient[i].title;
 | 
						|
          }
 | 
						|
        } catch (TypeError) {
 | 
						|
          console.error("Index Mismatch Error during recipe ingredient parsing; did the number of ingredients change?");
 | 
						|
        }
 | 
						|
 | 
						|
        parsedIng.value = data;
 | 
						|
 | 
						|
        errors.value = data.map((ing, index: number) => {
 | 
						|
          const unitError = !checkForUnit(ing.ingredient.unit);
 | 
						|
          const foodError = !checkForFood(ing.ingredient.food);
 | 
						|
 | 
						|
          let unitErrorMessage = "";
 | 
						|
          let foodErrorMessage = "";
 | 
						|
 | 
						|
          if (unitError || foodError) {
 | 
						|
            if (unitError) {
 | 
						|
              if (ing?.ingredient?.unit?.name) {
 | 
						|
                unitErrorMessage = `Create missing unit '${ing?.ingredient?.unit?.name || "No unit"}'`;
 | 
						|
              }
 | 
						|
            }
 | 
						|
 | 
						|
            if (foodError) {
 | 
						|
              if (ing?.ingredient?.food?.name) {
 | 
						|
                foodErrorMessage = `Create missing food '${ing.ingredient.food.name || "No food"}'?`;
 | 
						|
              }
 | 
						|
            }
 | 
						|
          }
 | 
						|
          panels.value.push(index);
 | 
						|
 | 
						|
          return {
 | 
						|
            ingredientIndex: index,
 | 
						|
            unitError,
 | 
						|
            unitErrorMessage,
 | 
						|
            foodError,
 | 
						|
            foodErrorMessage,
 | 
						|
          };
 | 
						|
        });
 | 
						|
      }
 | 
						|
    }
 | 
						|
 | 
						|
    function isError(ing: ParsedIngredient) {
 | 
						|
      if (!ing?.confidence?.average) {
 | 
						|
        return true;
 | 
						|
      }
 | 
						|
      return !(ing.confidence.average >= 0.75);
 | 
						|
    }
 | 
						|
 | 
						|
    function asPercentage(num: number | undefined): string {
 | 
						|
      if (!num) {
 | 
						|
        return "0%";
 | 
						|
      }
 | 
						|
 | 
						|
      return Math.round(num * 100).toFixed(2) + "%";
 | 
						|
    }
 | 
						|
 | 
						|
    // =========================================================
 | 
						|
    // Food and Ingredient Logic
 | 
						|
 | 
						|
    const foodStore = useFoodStore();
 | 
						|
    const foodData = useFoodData();
 | 
						|
    const { units } = useUnitStore();
 | 
						|
 | 
						|
    const errors = ref<Error[]>([]);
 | 
						|
 | 
						|
    function checkForUnit(unit?: IngredientUnit | CreateIngredientUnit) {
 | 
						|
      if (!unit) {
 | 
						|
        return false;
 | 
						|
      }
 | 
						|
      if (units.value && unit?.name) {
 | 
						|
        return units.value.some((u) => u.name === unit.name);
 | 
						|
      }
 | 
						|
      return false;
 | 
						|
    }
 | 
						|
 | 
						|
    function checkForFood(food?: IngredientFood | CreateIngredientFood) {
 | 
						|
      if (!food) {
 | 
						|
        return false;
 | 
						|
      }
 | 
						|
      if (foodStore.foods.value && food?.name) {
 | 
						|
        return foodStore.foods.value.some((f) => f.name === food.name);
 | 
						|
      }
 | 
						|
      return false;
 | 
						|
    }
 | 
						|
 | 
						|
    async function createFood(food: CreateIngredientFood | undefined, index: number) {
 | 
						|
      if (!food) {
 | 
						|
        return;
 | 
						|
      }
 | 
						|
 | 
						|
      foodData.data.name = food.name;
 | 
						|
      await foodStore.actions.createOne(foodData.data);
 | 
						|
      errors.value[index].foodError = false;
 | 
						|
      foodData.reset();
 | 
						|
    }
 | 
						|
 | 
						|
    // =========================================================
 | 
						|
    // Save All Loginc
 | 
						|
    async function saveAll() {
 | 
						|
      let ingredients = parsedIng.value.map((ing) => {
 | 
						|
        return {
 | 
						|
          ...ing.ingredient,
 | 
						|
          originalText: ing.input,
 | 
						|
        } as RecipeIngredient;
 | 
						|
      });
 | 
						|
 | 
						|
      ingredients = ingredients.map((ing) => {
 | 
						|
        if (!foodStore.foods.value || !units.value) {
 | 
						|
          return ing;
 | 
						|
        }
 | 
						|
        // Get food from foods
 | 
						|
        ing.food = foodStore.foods.value.find((f) => f.name === ing.food?.name);
 | 
						|
 | 
						|
        // Get unit from units
 | 
						|
        ing.unit = units.value.find((u) => u.name === ing.unit?.name);
 | 
						|
        return ing;
 | 
						|
      });
 | 
						|
 | 
						|
      if (!recipe.value || !recipe.value.slug) {
 | 
						|
        return;
 | 
						|
      }
 | 
						|
 | 
						|
      recipe.value.recipeIngredient = ingredients;
 | 
						|
      const { response } = await api.recipes.updateOne(recipe.value.slug, recipe.value);
 | 
						|
 | 
						|
      if (response?.status === 200) {
 | 
						|
        router.push("/recipe/" + recipe.value.slug);
 | 
						|
      }
 | 
						|
    }
 | 
						|
 | 
						|
    return {
 | 
						|
      parser,
 | 
						|
      saveAll,
 | 
						|
      createFood,
 | 
						|
      errors,
 | 
						|
      actions: foodStore.actions,
 | 
						|
      workingFoodData: foodData,
 | 
						|
      isError,
 | 
						|
      panels,
 | 
						|
      asPercentage,
 | 
						|
      fetchParsed,
 | 
						|
      parsedIng,
 | 
						|
      recipe,
 | 
						|
      loading,
 | 
						|
      ingredients,
 | 
						|
    };
 | 
						|
  },
 | 
						|
  head() {
 | 
						|
    return {
 | 
						|
      title: "Parser",
 | 
						|
    };
 | 
						|
  },
 | 
						|
});
 | 
						|
</script>
 |