Feature/recipe instructions improvements (#785)

* feat(frontend):  split paragraph by 1. 1) or 1: regex matches

* feat(frontend):  Update frontend to support ingredient To step refs

* feat(backend):  Update backend to support ingredient to step refs

* fix title editor

* move about info to site-settings

* update change-log

Co-authored-by: Hayden K <hay-kot@pm.me>
This commit is contained in:
Hayden 2021-11-05 15:48:10 -08:00 committed by GitHub
parent 9f8c61a75a
commit 5cb4a1ade0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 621 additions and 210 deletions

View File

@ -15,14 +15,16 @@
- Mealie has gone through a big redesign and has tried to standardize it's look a feel a bit more across the board.
- User/Group settings are now completely separated from the Administration page.
- All settings and configurations pages now have some sort of self-documenting help text. Additional text or descriptions are welcome from PRs
- Site settings now has status on whether or not some ENV variables have been configured correctly.
**Site Settings Page**
- Site Settings has been completely revamped. All site-wide settings at defined on the server as ENV variables. The site settings page now only shows you the non-secret values for reference. It also has some helpers to let you know if something isn't configured correctly.
- Server Side Bare URL will let you know if the BASE_URL env variable has been set
- Secure Site let's you know if you're serving via HTTPS or accessing by localhost. accessing without a secure site will render some of the features unusable.
- Email Configuration Status will let you know if all the email settings have been provided and offer a way to send test emails.
### 👨‍👩‍👧‍👦 Users and Groups
- Recipes are now only viewable by group members
- All members of a group can generate invitation tokens for other users to join their group
- Users now a have "Advanced" setting to enable/disable features like Webhooks and API tokens. This will also apply to future features that are deemed as advanced.
- "Pages" have been dropped in favor of Cookbooks which are now group specific so each group can have it's own set of cookbooks
@ -37,17 +39,34 @@
- Add Recipes or Notes to a specific day
### 🥙 Recipes
**Recipe General**
- Recipes are now only viewable by group members
- You can now import multiple URLs at a time pre-tagged using the bulk importer. This task runs in the background so no need to wait for it to finish.
- Foods/Units for Ingredients are now supported (toggle inside your recipe settings)
- You can no use Natural Language Processing (NLP) to process ingredients and attempt to parse them into amounts, units, and foods. There additional is a "Brute Force" processor that can be used to use a pattern matching parser to try and determine ingredients. **Note** if you are processing a Non-English language you will have terrible results with the NLP and will likely need to use the Bruce Force processor.
- Common Food and Units come pre-packaged with Mealie
- Recipes can now scale when Food/Units are properly defined
- Landscape and Portrait views is now available
- Users with the advanced flag turned on will not be able to manage recipe data in bulk and perform the following actions:
- Set Categories
- Set Tags
- Delete Recipes
- Export Recipes
- Recipes now have a `/cook` page for a simple view of the recipe where you can click through each step of a recipe and it's associated ingredients.
- The Bulk Importer has received many additional upgrades.
- Trim Whitespace: automatically removes leading and trailing whitespace
- Trim Prefix: Removes the first character of each line. Useful for when you paste in a list of ingredients or instructions that have 1. or 2. in front of them.
- Split By Numbered Line: Attempts to split a paragraph into multiple lines based on the patterns matching '1.', '1:' or '1)'.
**Recipe Ingredients**
- Recipe ingredients can now be scaled when the food/unit is defined
- Recipe ingredients can no be copied as markdown lists
- example `- [ ] 1 cup of flour`
- You can no use Natural Language Processing (NLP) to process ingredients and attempt to parse them into amounts, units, and foods. There additional is a "Brute Force" processor that can be used to use a pattern matching parser to try and determine ingredients. **Note** if you are processing a Non-English language you will have terrible results with the NLP and will likely need to use the Bruce Force processor.
**Recipe Instructions**
- Can now be merged with the above step automatically through the action menu
- Recipe Ingredients can be linked directly to recipe instructions for improved display
- There is an option in the linking dialog to automatically link ingredients. This works by using a key-word matching algorithm to find the ingredients. It's not perfect so you'll need to verify the links after use, additionally you will find that it doesn't work for non-english languages.
### ⚠️ Other things to know...
- Themes have been depreciated for specific users. You can still set specific themes for your site through ENV variables. This approach should yield much better results for performance and some weirdness users have experienced.

View File

@ -6,7 +6,6 @@
color="rgb(255, 0, 0, 0.0)"
flat
style="z-index: 2; position: sticky"
:class="{ 'fixed-bar-mobile': $vuetify.breakpoint.xs }"
>
<BaseDialog
ref="deleteRecipieConfirm"

View File

@ -1,6 +1,6 @@
<template>
<div class="text-center">
<v-dialog v-model="dialog" width="600">
<v-dialog v-model="dialog" width="800">
<template #activator="{ on, attrs }">
<BaseButton v-bind="attrs" v-on="on" @click="inputText = ''">
{{ $t("new-recipe.bulk-add") }}
@ -20,7 +20,7 @@
<v-textarea
v-model="inputText"
outlined
rows="10"
rows="12"
:placeholder="$t('new-recipe.paste-in-your-recipe-data-each-line-will-be-treated-as-an-item-in-a-list')"
>
</v-textarea>
@ -41,6 +41,14 @@
</template>
<span> Trim first character from each line </span>
</v-tooltip>
<v-tooltip top>
<template #activator="{ on, attrs }">
<v-btn class="ml-1" outlined color="info" small v-bind="attrs" @click="splitByNumberedLine" v-on="on">
Split By Numbered Line
</v-btn>
</template>
<span> Attempts to split a paragraph by matching 1) or 1. patterns </span>
</v-tooltip>
</v-card-text>
<v-divider></v-divider>
@ -74,6 +82,18 @@ export default defineComponent({
.join("\n");
}
const numberedLineRegex = /\d[.):] /gm;
function splitByNumberedLine() {
// Split inputText by numberedLineRegex
const matches = state.inputText.match(numberedLineRegex);
matches?.forEach((match, idx) => {
const replaceText = idx === 0 ? "" : "\n";
state.inputText = state.inputText.replace(match, replaceText);
});
}
function trimAllLines() {
const splitLines = splitText();
@ -93,6 +113,7 @@ export default defineComponent({
splitText,
trimAllLines,
removeFirstCharacter,
splitByNumberedLine,
save,
...toRefs(state),
};

View File

@ -1,6 +1,54 @@
<template>
<section>
<h2 class="mb-4">{{ $t("recipe.instructions") }}</h2>
<section @keyup.ctrl.90="undoMerge">
<!-- Ingredient Link Editor -->
<v-dialog v-model="dialog" width="600">
<v-card>
<v-app-bar dark color="primary" class="mt-n1 mb-3">
<v-icon large left>
{{ $globals.icons.link }}
</v-icon>
<v-toolbar-title class="headline"> Ingredient Linker </v-toolbar-title>
<v-spacer></v-spacer>
</v-app-bar>
<v-card-text class="pt-4">
<p>
{{ activeText }}
</p>
<v-divider class="mb-4"></v-divider>
<v-checkbox
v-for="ing in ingredients"
:key="ing.referenceId"
v-model="activeRefs"
:label="ing.note"
:value="ing.referenceId"
class="mb-n2 mt-n2"
></v-checkbox>
</v-card-text>
<v-divider></v-divider>
<v-card-actions>
<BaseButton cancel @click="dialog = false"> </BaseButton>
<v-spacer></v-spacer>
<BaseButton color="info" @click="autoSetReferences">
<template #icon> {{ $globals.icons.robot }}</template>
Auto
</BaseButton>
<BaseButton save @click="setIngredientIds"> </BaseButton>
</v-card-actions>
</v-card>
</v-dialog>
<div class="d-flex justify-space-between justify-start">
<h2 class="mb-4 mt-1">{{ $t("recipe.instructions") }}</h2>
<BaseButton minor :to="$router.currentRoute.path + '/cook'" cancel color="primary">
<template #icon>
{{ $globals.icons.primary }}
</template>
Cook
</BaseButton>
</div>
<draggable
:disabled="!edit"
:value="value"
@ -35,23 +83,24 @@
@click="toggleDisabled(index)"
>
<v-card-title :class="{ 'pb-0': !isChecked(index) }">
<v-btn
v-if="edit"
fab
x-small
color="white"
class="mr-2"
elevation="0"
@click="removeByIndex(value, index)"
>
<v-btn v-if="edit" fab x-small color="white" class="mr-2" elevation="0" @click="value.splice(index, 1)">
<v-icon size="24" color="error">{{ $globals.icons.delete }}</v-icon>
</v-btn>
{{ $t("recipe.step-index", { step: index + 1 }) }}
<v-btn v-if="edit" text color="primary" class="ml-auto" @click="toggleShowTitle(index)">
{{ !showTitleEditor[index] ? $t("recipe.insert-section") : $t("recipe.remove-section") }}
</v-btn>
<div class="ml-auto">
<BaseOverflowButton
v-if="edit"
small
mode="event"
:items="actionEvents || []"
@merge-above="mergeAbove(index - 1, index)"
@toggle-section="toggleShowTitle(index)"
@link-ingredients="openDialog(index, step.ingredientReferences, step.text)"
>
</BaseOverflowButton>
</div>
<v-icon v-if="edit" class="handle">{{ $globals.icons.arrowUpDown }}</v-icon>
<v-fade-transition>
<v-icon v-show="isChecked(index)" size="24" class="ml-auto" color="success">
@ -62,11 +111,17 @@
<v-card-text v-if="edit">
<v-textarea :key="'instructions' + index" v-model="value[index]['text']" auto-grow dense rows="4">
</v-textarea>
<div v-for="ing in step.ingredientReferences" :key="ing.referenceId">
{{ getIngredientByRefId(ing.referenceId).note }}
</div>
</v-card-text>
<v-expand-transition>
<div v-show="!isChecked(index) && !edit" class="m-0 p-0">
<v-card-text>
<VueMarkdown :source="step.text"> </VueMarkdown>
<div v-for="ing in step.ingredientReferences" :key="ing.referenceId">
{{ getIngredientByRefId(ing.referenceId).note }}
</div>
</v-card-text>
</div>
</v-expand-transition>
@ -77,81 +132,242 @@
</section>
</template>
<script>
<script lang="ts">
import draggable from "vuedraggable";
// @ts-ignore
import VueMarkdown from "@adapttive/vue-markdown";
export default {
import { ref, toRefs, reactive, defineComponent, watch, onMounted } from "@nuxtjs/composition-api";
import { RecipeStep, IngredientToStepRef, RecipeIngredient } from "~/types/api-types/recipe";
interface MergerHistory {
target: number;
source: number;
targetText: string;
sourceText: string;
}
export default defineComponent({
components: {
VueMarkdown,
draggable,
},
props: {
value: {
type: Array,
type: Array as () => RecipeStep[],
required: true,
},
edit: {
type: Boolean,
default: true,
},
ingredients: {
type: Array as () => RecipeIngredient[],
default: () => [],
},
},
data() {
return {
disabledSteps: [],
showTitleEditor: [],
};
},
setup(props, context) {
const state = reactive({
dialog: false,
disabledSteps: [] as number[],
});
watch: {
value: {
handler() {
this.disabledSteps = [];
this.showTitleEditor = this.value.map((x) => this.validateTitle(x.title));
const showTitleEditor = ref<boolean[]>([]);
const actionEvents = [
{
text: "Toggle Section",
event: "toggle-section",
},
},
},
{
text: "Link Ingredients",
event: "link-ingredients",
},
{
text: "Merge Above",
event: "merge-above",
},
];
mounted() {
this.showTitleEditor = this.value.map((x) => this.validateTitle(x.title));
},
methods: {
removeByIndex(list, index) {
list.splice(index, 1);
},
validateTitle(title) {
// ===============================================================
// UI State Helpers
function validateTitle(title: string | undefined) {
return !(title === null || title === "");
},
toggleDisabled(stepIndex) {
if (this.edit) return;
if (this.disabledSteps.includes(stepIndex)) {
const index = this.disabledSteps.indexOf(stepIndex);
}
watch(props.value, (v) => {
state.disabledSteps = [];
showTitleEditor.value = v.map((x) => validateTitle(x.title));
});
// Eliminate state with an eager call to watcher?
onMounted(() => {
showTitleEditor.value = props.value.map((x) => validateTitle(x.title));
});
function toggleDisabled(stepIndex: number) {
if (props.edit) {
return;
}
if (state.disabledSteps.includes(stepIndex)) {
const index = state.disabledSteps.indexOf(stepIndex);
if (index !== -1) {
this.disabledSteps.splice(index, 1);
state.disabledSteps.splice(index, 1);
}
} else {
this.disabledSteps.push(stepIndex);
state.disabledSteps.push(stepIndex);
}
},
isChecked(stepIndex) {
if (this.disabledSteps.includes(stepIndex) && !this.edit) {
}
function isChecked(stepIndex: number) {
if (state.disabledSteps.includes(stepIndex) && !props.edit) {
return "disabled-card";
}
},
toggleShowTitle(index) {
const newVal = !this.showTitleEditor[index];
}
function toggleShowTitle(index: number) {
const newVal = !showTitleEditor.value[index];
if (!newVal) {
this.value[index].title = "";
props.value[index].title = "";
}
this.$set(this.showTitleEditor, index, newVal);
},
updateIndex(data) {
this.$emit("input", data);
},
// Must create a new temporary list due to vue-composition-api backport limitations (I think...)
const tempList = [...showTitleEditor.value];
tempList[index] = newVal;
showTitleEditor.value = tempList;
}
function updateIndex(data: RecipeStep) {
context.emit("input", data);
}
// ===============================================================
// Ingredient Linker
const activeRefs = ref<String[]>([]);
const activeIndex = ref(0);
const activeText = ref("");
function openDialog(idx: number, refs: IngredientToStepRef[], text: string) {
activeText.value = text;
activeIndex.value = idx;
state.dialog = true;
activeRefs.value = refs.map((ref) => ref.referenceId);
}
function setIngredientIds() {
const instruction = props.value[activeIndex.value];
instruction.ingredientReferences = activeRefs.value.map((ref) => {
return {
referenceId: ref as string,
};
});
state.dialog = false;
}
function autoSetReferences() {
// Ingore matching blacklisted words when auto-linking - This is kind of a cludgey implementation. We're blacklisting common words but
// other common phrases trigger false positives and I'm not sure how else to approach this. In the future I maybe look at looking directly
// at the food variable and seeing if the food is in the instructions, but I still need to support those who don't want to provide the value
// and only use the "notes" feature.
const blackListedText = [
"and",
"or",
"the",
"a",
"an",
"of",
"in",
"on",
"to",
"for",
"by",
"with",
"without",
"",
" ",
];
const blackListedRegexMatch = /\d/gm; // Match Any Number
// Check if any of the words in the active text match the ingredient text
const instructionsByWord = activeText.value.toLowerCase().split(" ");
instructionsByWord.forEach((word) => {
if (blackListedText.includes(word) || word.match(blackListedRegexMatch)) {
return;
}
props.ingredients.forEach((ingredient) => {
if (
ingredient.note.toLowerCase().includes(" " + word) &&
!activeRefs.value.includes(ingredient.referenceId)
) {
console.info("Word Matched", `'${word}'`, ingredient.note);
activeRefs.value.push(ingredient.referenceId);
}
});
});
}
function getIngredientByRefId(refId: String) {
return props.ingredients.find((ing) => ing.referenceId === refId) || "";
}
// ===============================================================
// Instruction Merger
const mergeHistory = ref<MergerHistory[]>([]);
function mergeAbove(target: number, source: number) {
if (target < 0) {
return;
}
mergeHistory.value.push({
target,
source,
targetText: props.value[target].text,
sourceText: props.value[source].text,
});
props.value[target].text += " " + props.value[source].text;
props.value.splice(source, 1);
}
function undoMerge(event: KeyboardEvent) {
if (event.ctrlKey && event.code === "KeyZ") {
if (!(mergeHistory.value?.length > 0)) {
return;
}
const lastMerge = mergeHistory.value.pop();
if (!lastMerge) {
return;
}
props.value[lastMerge.target].text = lastMerge.targetText;
props.value.splice(lastMerge.source, 0, {
title: "",
text: lastMerge.sourceText,
ingredientReferences: [],
});
}
}
return {
...toRefs(state),
actionEvents,
activeRefs,
activeText,
getIngredientByRefId,
showTitleEditor,
mergeAbove,
openDialog,
setIngredientIds,
undoMerge,
toggleDisabled,
isChecked,
toggleShowTitle,
updateIndex,
autoSetReferences,
};
},
};
});
</script>

View File

@ -12,7 +12,7 @@
v-on="$listeners"
@click="download ? downloadFile() : undefined"
>
<v-icon left>
<v-icon v-if="!iconRight" left>
<slot name="icon">
{{ btnAttrs.icon }}
</slot>
@ -20,6 +20,11 @@
<slot name="default">
{{ btnAttrs.text }}
</slot>
<v-icon v-if="iconRight" right>
<slot name="icon">
{{ btnAttrs.icon }}
</slot>
</v-icon>
</v-btn>
</template>
@ -96,6 +101,10 @@ export default {
type: String,
default: null,
},
iconRight: {
type: Boolean,
default: false,
},
},
setup() {
const api = useApiSingleton();

View File

@ -1,7 +1,7 @@
<template>
<v-menu offset-y>
<template #activator="{ on, attrs }">
<v-btn color="primary" v-bind="attrs" :class="btnClass" :disabled="disabled" v-on="on">
<v-btn color="primary" v-bind="{ ...attrs, ...$attrs }" :class="btnClass" :disabled="disabled" v-on="on">
<v-icon v-if="activeObj.icon" left>
{{ activeObj.icon }}
</v-icon>

View File

@ -133,11 +133,6 @@ export default defineComponent({
title: i18n.t("about.support"),
href: "https://github.com/sponsors/hay-kot",
},
{
icon: $globals.icons.information,
title: i18n.t("about.about"),
to: "/admin/about",
},
];
return {

View File

@ -1,111 +0,0 @@
<template>
<v-container fluid>
<v-card class="mt-3">
<v-card-title class="headline">
{{ $t("about.about-mealie") }}
</v-card-title>
<v-divider></v-divider>
<v-card-text>
<v-list-item-group color="primary">
<v-list-item v-for="property in appInfo" :key="property.name">
<v-list-item-icon>
<v-icon> {{ property.icon || $globals.icons.user }} </v-icon>
</v-list-item-icon>
<v-list-item-content>
<v-list-item-title class="pl-4 flex row justify-space-between">
<div>{{ property.name }}</div>
<div>{{ property.value }}</div>
</v-list-item-title>
</v-list-item-content>
</v-list-item>
</v-list-item-group>
</v-card-text>
</v-card>
</v-container>
</template>
<script lang="ts">
import { defineComponent, useAsync, useContext } from "@nuxtjs/composition-api";
import { useAdminApi } from "~/composables/use-api";
import { useAsyncKey } from "~/composables/use-utils";
export default defineComponent({
layout: "admin",
setup() {
const adminApi = useAdminApi();
// @ts-ignore
const { $globals, i18n } = useContext();
function getAppInfo() {
const statistics = useAsync(async () => {
const { data } = await adminApi.about.about();
if (data) {
const prettyInfo = [
{
name: i18n.t("about.version"),
icon: $globals.icons.information,
value: data.version,
},
{
name: i18n.t("about.application-mode"),
icon: $globals.icons.devTo,
value: data.production ? i18n.t("about.production") : i18n.t("about.development"),
},
{
name: i18n.t("about.demo-status"),
icon: $globals.icons.testTube,
value: data.demoStatus ? i18n.t("about.demo") : i18n.t("about.not-demo"),
},
{
name: i18n.t("about.api-port"),
icon: $globals.icons.api,
value: data.apiPort,
},
{
name: i18n.t("about.api-docs"),
icon: $globals.icons.file,
value: data.apiDocs ? i18n.t("general.enabled") : i18n.t("general.disabled"),
},
{
name: i18n.t("about.database-type"),
icon: $globals.icons.database,
value: data.dbType,
},
{
name: i18n.t("about.database-url"),
icon: $globals.icons.database,
value: data.dbUrl,
},
{
name: i18n.t("about.default-group"),
icon: $globals.icons.group,
value: data.defaultGroup,
},
];
return prettyInfo;
}
return data;
}, useAsyncKey());
return statistics;
}
const appInfo = getAppInfo();
return {
appInfo,
};
},
head() {
return {
title: this.$t("about.about") as string,
};
},
});
</script>
<style scoped>
</style>

View File

@ -69,14 +69,42 @@
</template>
</v-card>
</section>
<section class="mt-4">
<BaseCardSectionTitle class="pb-0" :icon="$globals.icons.cog" title="General About"> </BaseCardSectionTitle>
<v-card class="mb-4">
<v-list-item v-for="property in appInfo" :key="property.name">
<v-list-item-icon>
<v-icon> {{ property.icon || $globals.icons.user }} </v-icon>
</v-list-item-icon>
<v-list-item-content>
<v-list-item-title>
<div>{{ property.name }}</div>
</v-list-item-title>
<v-list-item-subtitle class="text-end">
{{ property.value }}
</v-list-item-subtitle>
</v-list-item-content>
</v-list-item>
</v-card>
</section>
</v-container>
</template>
<script lang="ts">
import { computed, defineComponent, onMounted, reactive, toRefs, ref } from "@nuxtjs/composition-api";
import {
computed,
onMounted,
reactive,
toRefs,
ref,
defineComponent,
useAsync,
useContext,
} from "@nuxtjs/composition-api";
import { CheckAppConfig } from "~/api/admin/admin-about";
import { useAdminApi, useApiSingleton } from "~/composables/use-api";
import { validators } from "~/composables/use-validators";
import { useAsyncKey } from "~/composables/use-utils";
interface SimpleCheck {
status: boolean;
@ -104,9 +132,9 @@ export default defineComponent({
const api = useApiSingleton();
const adminAPI = useAdminApi();
const adminApi = useAdminApi();
onMounted(async () => {
const { data } = await adminAPI.about.checkApp();
const { data } = await adminApi.about.checkApp();
if (data) {
appConfig.value = data;
@ -173,6 +201,70 @@ export default defineComponent({
return booly ? "success" : "error";
}
// ============================================================
// General About Info
// @ts-ignore
const { $globals, i18n } = useContext();
function getAppInfo() {
const statistics = useAsync(async () => {
const { data } = await adminApi.about.about();
if (data) {
const prettyInfo = [
{
name: i18n.t("about.version"),
icon: $globals.icons.information,
value: data.version,
},
{
name: i18n.t("about.application-mode"),
icon: $globals.icons.devTo,
value: data.production ? i18n.t("about.production") : i18n.t("about.development"),
},
{
name: i18n.t("about.demo-status"),
icon: $globals.icons.testTube,
value: data.demoStatus ? i18n.t("about.demo") : i18n.t("about.not-demo"),
},
{
name: i18n.t("about.api-port"),
icon: $globals.icons.api,
value: data.apiPort,
},
{
name: i18n.t("about.api-docs"),
icon: $globals.icons.file,
value: data.apiDocs ? i18n.t("general.enabled") : i18n.t("general.disabled"),
},
{
name: i18n.t("about.database-type"),
icon: $globals.icons.database,
value: data.dbType,
},
{
name: i18n.t("about.database-url"),
icon: $globals.icons.database,
value: data.dbUrl,
},
{
name: i18n.t("about.default-group"),
icon: $globals.icons.group,
value: data.defaultGroup,
},
];
return prettyInfo;
}
return data;
}, useAsyncKey());
return statistics;
}
const appInfo = getAppInfo();
return {
simpleChecks,
getColor,
@ -182,6 +274,7 @@ export default defineComponent({
validators,
...toRefs(state),
testEmail,
appInfo,
};
},
head() {

View File

@ -0,0 +1,85 @@
<template>
<v-container
v-if="recipe"
:class="{
'pa-0': $vuetify.breakpoint.smAndDown,
}"
>
<v-card-title>
<h1 class="headline">{{ recipe.name }}</h1>
</v-card-title>
<v-stepper v-model="activeStep" flat>
<v-toolbar class="ma-1 elevation-2 rounded">
<v-toolbar-title class="headline">
Step {{ activeStep }} of {{ recipe.recipeInstructions.length }}</v-toolbar-title
>
</v-toolbar>
<v-stepper-items>
<template v-for="(step, index) in recipe.recipeInstructions">
<v-stepper-content :key="index + 1 + '-content'" :step="index + 1" class="pa-0 mt-2 elevation-0">
<v-card class="ma-2">
<v-card-text>
<h2 class="mb-4">{{ $t("recipe.instructions") }}</h2>
<VueMarkdown :source="step.text"> </VueMarkdown>
<v-divider></v-divider>
<h2 class="mb-4 mt-4">{{ $t("recipe.ingredients") }}</h2>
<div v-for="ing in step.ingredientReferences" :key="ing.referenceId">
{{ getIngredientByRefId(ing.referenceId).note }}
</div>
</v-card-text>
</v-card>
<v-card-actions class="justify-center">
<BaseButton color="primary" :disabled="index == 0" @click="activeStep = activeStep - 1">
<template #icon> {{ $globals.icons.arrowLeftBold }}</template>
Back
</BaseButton>
<BaseButton
icon-right
:disabled="index + 1 == recipe.recipeInstructions.length"
color="primary"
@click="activeStep = activeStep + 1"
>
<template #icon> {{ $globals.icons.arrowRightBold }}</template>
Next
</BaseButton>
</v-card-actions>
</v-stepper-content>
</template>
</v-stepper-items>
</v-stepper>
</v-container>
</template>
<script lang="ts">
import { defineComponent, useRoute, ref } from "@nuxtjs/composition-api";
// @ts-ignore
import VueMarkdown from "@adapttive/vue-markdown";
import { useStaticRoutes } from "~/composables/api";
import { useRecipeContext } from "~/composables/use-recipe-context";
export default defineComponent({
components: { VueMarkdown },
setup() {
const route = useRoute();
const slug = route.value.params.slug;
const activeStep = ref(1);
const { getBySlug } = useRecipeContext();
const { recipeImage } = useStaticRoutes();
const recipe = getBySlug(slug);
function getIngredientByRefId(refId: String) {
return recipe.value?.recipeIngredient.find((ing) => ing.referenceId === refId) || "";
}
return {
getIngredientByRefId,
activeStep,
slug,
recipe,
recipeImage,
};
},
});
</script>

View File

@ -112,7 +112,7 @@
<draggable v-model="recipe.recipeIngredient" handle=".handle">
<RecipeIngredientEditor
v-for="(ingredient, index) in recipe.recipeIngredient"
:key="ingredient.ref"
:key="ingredient.referenceId"
v-model="recipe.recipeIngredient[index]"
:disable-amount="recipe.settings.disableAmount"
@delete="recipe.recipeIngredient.splice(index, 1)"
@ -229,7 +229,11 @@
<v-divider v-if="$vuetify.breakpoint.mdAndUp" class="my-divider" :vertical="true"></v-divider>
<v-col cols="12" sm="12" md="8" lg="8">
<RecipeInstructions v-model="recipe.recipeInstructions" :edit="form" />
<RecipeInstructions
v-model="recipe.recipeInstructions"
:ingredients="recipe.recipeIngredient"
:edit="form"
/>
<div v-if="form" class="d-flex">
<RecipeDialogBulkAdd class="ml-auto my-2 mr-1" @bulk-data="addStep" />
<BaseButton class="my-2" @click="addStep()"> {{ $t("general.new") }}</BaseButton>
@ -431,12 +435,12 @@ export default defineComponent({
if (steps) {
const cleanedSteps = steps.map((step) => {
return { text: step, title: "" };
return { text: step, title: "", ingredientReferences: [] };
});
recipe.value.recipeInstructions.push(...cleanedSteps);
} else {
recipe.value.recipeInstructions.push({ text: "", title: "" });
recipe.value.recipeInstructions.push({ text: "", title: "", ingredientReferences: [] });
}
}
@ -444,7 +448,7 @@ export default defineComponent({
if (ingredients?.length) {
const newIngredients = ingredients.map((x) => {
return {
ref: uuid4(),
referenceId: uuid4(),
title: "",
note: x,
unit: null,
@ -459,7 +463,7 @@ export default defineComponent({
}
} else {
recipe?.value?.recipeIngredient?.push({
ref: uuid4(),
referenceId: uuid4(),
title: "",
note: "",
unit: null,

View File

@ -9,7 +9,7 @@
Select one of the various ways to create a recipe
<template #content>
<div class="ml-auto">
<BaseOverflowButton v-model="tab" rounded outlined :items="tabs"> </BaseOverflowButton>
<BaseOverflowButton v-model="tab" rounded :items="tabs"> </BaseOverflowButton>
</div>
</template>
</BasePageTitle>

View File

@ -80,7 +80,7 @@ export interface Recipe {
comments?: CommentOut[];
}
export interface RecipeIngredient {
ref: string;
referenceId: string;
title: string;
note: string;
unit?: RecipeIngredientUnit | null;
@ -96,9 +96,13 @@ export interface RecipeIngredientFood {
name?: string;
description?: string;
}
export interface IngredientToStepRef {
referenceId: string;
}
export interface RecipeStep {
title?: string;
text: string;
ingredientReferences: IngredientToStepRef[];
}
export interface RecipeSettings {
public?: boolean;

View File

@ -100,6 +100,7 @@ import {
mdiArrowRightBoldOutline,
mdiTimerSand,
mdiRefresh,
mdiArrowRightBold,
} from "@mdi/js";
export const icons = {
@ -113,6 +114,7 @@ export const icons = {
alertCircle: mdiAlertCircle,
api: mdiApi,
arrowLeftBold: mdiArrowLeftBold,
arrowRightBold: mdiArrowRightBold,
arrowUpDown: mdiDrag,
backupRestore: mdiBackupRestore,
bellAlert: mdiBellAlert,

View File

@ -92,7 +92,7 @@ def handle_one_to_many_list(session: Session, get_attr, relation_cls, all_elemen
updated_elems.append(existing_elem)
new_elems = [safe_call(relation_cls, elem) for elem in elems_to_create]
new_elems = [safe_call(relation_cls, elem, session=session) for elem in elems_to_create]
return new_elems + updated_elems
@ -159,7 +159,7 @@ def auto_init(): # sourcery no-metrics
setattr(self, key, instances)
elif relation_dir == ONETOMANY:
instance = safe_call(relation_cls, val)
instance = safe_call(relation_cls, val, session=session)
setattr(self, key, instance)
elif relation_dir == MANYTOONE and not use_list:

View File

@ -0,0 +1,39 @@
import uuid
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.types import CHAR, TypeDecorator
class GUID(TypeDecorator):
"""Platform-independent GUID type.
Uses PostgreSQL's UUID type, otherwise uses
CHAR(32), storing as stringified hex values.
"""
impl = CHAR
def load_dialect_impl(self, dialect):
if dialect.name == "postgresql":
return dialect.type_descriptor(UUID())
else:
return dialect.type_descriptor(CHAR(32))
def process_bind_param(self, value, dialect):
if value is None:
return value
elif dialect.name == "postgresql":
return str(value)
else:
if not isinstance(value, uuid.UUID):
return "%.32x" % uuid.UUID(value).int
else:
# hexstring
return "%.32x" % value.int
def process_result_value(self, value, dialect):
if value is None:
return value
else:
if not isinstance(value, uuid.UUID):
value = uuid.UUID(value)
return value

View File

@ -28,12 +28,16 @@ def get_valid_call(func: Callable, args_dict) -> dict:
return {k: v for k, v in args_dict.items() if k in valid_args}
def safe_call(func, dict) -> Any:
def safe_call(func, dict_args, **kwargs) -> Any:
"""
Safely calls the supplied function with the supplied dictionary of arguments.
by removing any invalid arguments.
"""
if kwargs:
dict_args.update(kwargs)
try:
return func(**get_valid_call(func, dict))
return func(**get_valid_call(func, dict_args))
except TypeError:
return func(**dict)
return func(**dict_args)

View File

@ -3,6 +3,7 @@ from sqlalchemy import Boolean, Column, ForeignKey, Integer, String, orm
from mealie.db.models._model_base import BaseMixins, SqlAlchemyBase
from .._model_utils import auto_init
from .._model_utils.guid import GUID
class IngredientUnitModel(SqlAlchemyBase, BaseMixins):
@ -48,6 +49,8 @@ class RecipeIngredient(SqlAlchemyBase, BaseMixins):
food = orm.relationship(IngredientFoodModel, uselist=False)
quantity = Column(Integer)
reference_id = Column(GUID()) # Reference Links
# Extras
@auto_init()

View File

@ -1,6 +1,18 @@
from sqlalchemy import Column, ForeignKey, Integer, String
from sqlalchemy import Column, ForeignKey, Integer, String, orm
from mealie.db.models._model_base import SqlAlchemyBase
from .._model_base import BaseMixins, SqlAlchemyBase
from .._model_utils import auto_init
from .._model_utils.guid import GUID
class RecipeIngredientRefLink(SqlAlchemyBase, BaseMixins):
__tablename__ = "recipe_ingredient_ref_link"
instruction_id = Column(Integer, ForeignKey("recipe_instructions.id"))
reference_id = Column(GUID())
@auto_init()
def __init__(self, **_) -> None:
pass
class RecipeInstruction(SqlAlchemyBase):
@ -11,3 +23,9 @@ class RecipeInstruction(SqlAlchemyBase):
type = Column(String, default="")
title = Column(String)
text = Column(String)
ingredient_references = orm.relationship("RecipeIngredientRefLink", cascade="all, delete-orphan")
@auto_init()
def __init__(self, **_) -> None:
pass

View File

@ -92,7 +92,6 @@ class RecipeModel(SqlAlchemyBase, BaseMixins):
"notes",
"nutrition",
"recipe_ingredient",
"recipe_instructions",
"settings",
"tools",
}
@ -111,7 +110,6 @@ class RecipeModel(SqlAlchemyBase, BaseMixins):
notes: list[dict] = None,
nutrition: dict = None,
recipe_ingredient: list[str] = None,
recipe_instructions: list[dict] = None,
settings: dict = None,
tools: list[str] = None,
**_,
@ -120,10 +118,10 @@ class RecipeModel(SqlAlchemyBase, BaseMixins):
self.tools = [Tool(tool=x) for x in tools] if tools else []
self.recipe_ingredient = [RecipeIngredient(**ingr, session=session) for ingr in recipe_ingredient]
self.assets = [RecipeAsset(**a) for a in assets]
self.recipe_instructions = [
RecipeInstruction(text=instruc.get("text"), title=instruc.get("title"), type=instruc.get("@type", None))
for instruc in recipe_instructions
]
# self.recipe_instructions = [
# RecipeInstruction(text=instruc.get("text"), title=instruc.get("title"), type=instruc.get("@type", None))
# for instruc in recipe_instructions
# ]
# Mealie Specific
self.settings = RecipeSettings(**settings) if settings else RecipeSettings()

View File

@ -43,7 +43,7 @@ class RecipeIngredient(CamelModel):
# Ref is used as a way to distinguish between an individual ingredient on the frontend
# It is required for the reorder and section titles to function properly because of how
# Vue handles reactivity. ref may serve another purpose in the future.
ref: UUID = Field(default_factory=uuid4)
reference_id: UUID = Field(default_factory=uuid4)
class Config:
orm_mode = True

View File

@ -1,11 +1,24 @@
from typing import Optional
from uuid import UUID
from fastapi_camelcase import CamelModel
class IngredientReferences(CamelModel):
"""
A list of ingredient references.
"""
reference_id: UUID = None
class Config:
orm_mode = True
class RecipeStep(CamelModel):
title: Optional[str] = ""
text: str
ingredient_references: list[IngredientReferences] = []
class Config:
orm_mode = True