fix(backend): 🐛 Fix recipe page issues (#778)

* fix(backend): 🐛 Fix favorite assignment on backend

* fix(frontend): 🐛 fix printer button on recipe page

* style(frontend): 🚸 add user feadback on copy of recipe link

* fix(frontend): 🐛 Fix enableLandscape incorrect bindings to remove duplicate values

* feat(frontend):  add ingredient copy button for markdown list -[ ] format

* feat(frontend):  add remove prefix button to bulk entry

* fix(frontend): 🐛 disable random button when no recipes are present

* fix(frontend):  fix .zip download error

* fix(frontend): 🚸 close image dialog on upload/get

* fix(frontend): 🐛 fix assignment on creation for categories and tags

* feat(frontend):  Open editor on creation / fix edit button on main screen

* fix(frontend): 🐛 fix false negative regex match for urls on creationg page

* feat(frontend): 🚸 provide better user feadback when recipe exists

* feat(frontend):  lock bulk importer on submit

* remove zip from navigation

* fix(frontend):  rerender recipes on delete

Co-authored-by: Hayden K <hay-kot@pm.me>
This commit is contained in:
Hayden 2021-11-04 18:15:23 -08:00 committed by GitHub
parent ec3b53cdc3
commit 9f8c61a75a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
27 changed files with 323 additions and 163 deletions

View File

@ -17,7 +17,8 @@ const routes = {
recipesParseIngredients: `${prefix}/parser/ingredients`,
recipesRecipeSlug: (recipe_slug: string) => `${prefix}/recipes/${recipe_slug}`,
recipesRecipeSlugZip: (recipe_slug: string) => `${prefix}/recipes/${recipe_slug}/zip`,
recipesRecipeSlugExport: (recipe_slug: string) => `${prefix}/recipes/${recipe_slug}/exports`,
recipesRecipeSlugExportZip: (recipe_slug: string) => `${prefix}/recipes/${recipe_slug}/exports/zip`,
recipesRecipeSlugImage: (recipe_slug: string) => `${prefix}/recipes/${recipe_slug}/image`,
recipesRecipeSlugAssets: (recipe_slug: string) => `${prefix}/recipes/${recipe_slug}/assets`,
@ -72,6 +73,10 @@ export interface BulkCreatePayload {
imports: BulkCreateRecipe[];
}
export interface RecipeZipToken {
token: string;
}
export class RecipeAPI extends BaseCRUDAPI<Recipe, CreateRecipe> {
baseRoute: string = routes.recipesBase;
itemRoute = routes.recipesRecipeSlug;
@ -151,4 +156,12 @@ export class RecipeAPI extends BaseCRUDAPI<Recipe, CreateRecipe> {
parser = parser || "nlp";
return await this.requests.post<ParsedIngredient>(routes.recipesParseIngredient, { parser, ingredient });
}
async getZipToken(recipeSlug: string) {
return await this.requests.post<RecipeZipToken>(routes.recipesRecipeSlugExport(recipeSlug), {});
}
getZipRedirectUrl(recipeSlug: string, token: string) {
return `${routes.recipesRecipeSlugExportZip(recipeSlug)}?token=${token}`;
}
}

View File

@ -49,6 +49,7 @@
fab
color="info"
:card-menu="false"
@print="$emit('print')"
/>
</div>
<div v-if="value" class="custom-btn-group mb-">

View File

@ -28,7 +28,7 @@
<RecipeRating :value="rating" :name="name" :slug="slug" :small="true" />
<v-spacer></v-spacer>
<RecipeChips :truncate="true" :items="tags" :title="false" :limit="2" :small="true" :is-category="false" />
<RecipeContextMenu :slug="slug" :name="name" />
<RecipeContextMenu :slug="slug" :name="name" @deleted="$emit('deleted', slug)" />
</v-card-actions>
<slot></slot>
</v-card>

View File

@ -6,7 +6,7 @@
</v-icon>
<v-toolbar-title class="headline"> {{ title }} </v-toolbar-title>
<v-spacer></v-spacer>
<v-btn :icon="$vuetify.breakpoint.xsOnly" text @click="navigateRandom">
<v-btn :icon="$vuetify.breakpoint.xsOnly" text :disabled="recipes.length === 0" @click="navigateRandom">
<v-icon :left="!$vuetify.breakpoint.xsOnly">
{{ $globals.icons.diceMultiple }}
</v-icon>
@ -66,6 +66,7 @@
:rating="recipe.rating"
:image="recipe.image"
:tags="recipe.tags"
@deleted="$emit('deleted', $event)"
/>
</v-lazy>
</v-col>

View File

@ -93,14 +93,16 @@ export default defineComponent({
async select() {
const newItem = await (async () => {
if (this.tagDialog) {
const newItem = await this.api.tags.createOne({ name: this.itemName });
return newItem;
const { data } = await this.api.tags.createOne({ name: this.itemName });
return data;
} else {
const newItem = await this.api.categories.createOne({ name: this.itemName });
return newItem;
const { data } = await this.api.categories.createOne({ name: this.itemName });
return data;
}
})();
console.log(newItem);
this.$emit(CREATED_ITEM_EVENT, newItem);
this.dialog = false;
},

View File

@ -98,7 +98,7 @@ export default {
getAllCategories();
getAllTags();
return { api, allTags, allCategories };
return { api, allTags, allCategories, getAllCategories, getAllTags };
},
data() {
@ -152,6 +152,9 @@ export default {
},
pushToItem(createdItem) {
createdItem = this.returnObject ? createdItem : createdItem.name;
// TODO: Remove excessive get calls
this.getAllCategories();
this.getAllTags();
this.selected.push(createdItem);
},
},

View File

@ -43,6 +43,7 @@
<script>
import { defineComponent, ref } from "@nuxtjs/composition-api";
import { useApiSingleton } from "~/composables/use-api";
import { alert } from "~/composables/use-toast";
export default defineComponent({
props: {
menuTop: {
@ -156,7 +157,7 @@ export default defineComponent({
},
},
methods: {
menuAction(action) {
async menuAction(action) {
this.loading = true;
switch (action) {
@ -182,10 +183,13 @@ export default defineComponent({
this.$router.push(`/recipe/${this.slug}` + "?edit=true");
break;
case "print":
this.$router.push(`/recipe/${this.slug}` + "?print=true");
this.$emit("print");
break;
case "download":
window.open(`/api/recipes/${this.slug}/zip`);
// TODO: Refacor this entire component to not suck so much
// eslint-disable-next-line
const { data } = await this.api.recipes.getZipToken(this.slug);
window.open(this.api.recipes.getZipRedirectUrl(this.slug, data.token));
break;
default:
break;
@ -194,16 +198,20 @@ export default defineComponent({
this.loading = false;
},
async deleteRecipe() {
console.log("Delete Called");
await this.api.recipes.deleteOne(this.slug);
this.$emit("deleted");
},
updateClipboard() {
const copyText = this.recipeURL;
navigator.clipboard.writeText(copyText).then(
() => {
console.log("Copied to Clipboard", copyText);
alert.success("Recipe link copied to clipboard");
},
() => console.log("Copied Failed", copyText)
() => {
console.log("Copied Failed", copyText);
alert.error("Copied Failed");
}
);
},
},

View File

@ -24,7 +24,23 @@
:placeholder="$t('new-recipe.paste-in-your-recipe-data-each-line-will-be-treated-as-an-item-in-a-list')"
>
</v-textarea>
<v-btn outlined color="info" small @click="trimAllLines"> Trim Whitespace </v-btn>
<v-tooltip top>
<template #activator="{ on, attrs }">
<v-btn outlined color="info" small v-bind="attrs" @click="trimAllLines" v-on="on">
Trim Whitespace
</v-btn>
</template>
<span> Trim leading and trailing whitespace as well as blank lines </span>
</v-tooltip>
<v-tooltip top>
<template #activator="{ on, attrs }">
<v-btn class="ml-1" outlined color="info" small v-bind="attrs" @click="removeFirstCharacter" v-on="on">
Trim Prefix
</v-btn>
</template>
<span> Trim first character from each line </span>
</v-tooltip>
</v-card-text>
<v-divider></v-divider>
@ -52,14 +68,20 @@ export default defineComponent({
return state.inputText.split("\n").filter((line) => !(line === "\n" || !line));
}
function trimAllLines() {
const splitLintes = splitText();
function removeFirstCharacter() {
state.inputText = splitText()
.map((line) => line.substr(1))
.join("\n");
}
splitLintes.forEach((element: string, index: number) => {
splitLintes[index] = element.trim();
function trimAllLines() {
const splitLines = splitText();
splitLines.forEach((element: string, index: number) => {
splitLines[index] = element.trim();
});
state.inputText = splitLintes.join("\n");
state.inputText = splitLines.join("\n");
}
function save() {
@ -70,6 +92,7 @@ export default defineComponent({
return {
splitText,
trimAllLines,
removeFirstCharacter,
save,
...toRefs(state),
};

View File

@ -1,6 +1,6 @@
<template>
<div class="text-center">
<v-menu offset-y top nudge-top="6" :close-on-content-click="false">
<v-menu v-model="menu" offset-y top nudge-top="6" :close-on-content-click="false">
<template #activator="{ on, attrs }">
<v-btn color="accent" dark v-bind="attrs" v-on="on">
<v-icon left>
@ -59,10 +59,12 @@ export default defineComponent({
data: () => ({
url: "",
loading: false,
menu: false,
}),
methods: {
uploadImage(fileObject) {
this.$emit(UPLOAD_EVENT, fileObject);
this.menu = false;
},
async getImageFromURL() {
this.loading = true;
@ -70,6 +72,7 @@ export default defineComponent({
this.$emit(REFRESH_EVENT);
}
this.loading = false;
this.menu = false;
},
getMessages() {
return this.slug ? [""] : [this.$i18n.t("recipe.save-recipe-before-use")];

View File

@ -1,6 +1,9 @@
<template>
<div v-if="value && value.length > 0">
<h2 class="mb-4">{{ $t("recipe.ingredients") }}</h2>
<div class="d-flex justify-start">
<h2 class="mb-4 mt-1">{{ $t("recipe.ingredients") }}</h2>
<AppButtonCopy btn-class="ml-auto" :copy-text="ingredientCopyText" />
</div>
<div>
<div v-for="(ingredient, index) in value" :key="'ingredient' + index">
<h3 v-if="showTitleEditor[index]" class="mt-2">{{ ingredient.title }}</h3>
@ -18,9 +21,10 @@
</template>
<script>
import { computed, defineComponent } from "@nuxtjs/composition-api";
import VueMarkdown from "@adapttive/vue-markdown";
import { useFraction } from "@/composables/use-fraction";
export default {
export default defineComponent({
components: {
VueMarkdown,
},
@ -65,7 +69,16 @@ export default {
return `${return_qty} ${unit?.name || " "} ${food?.name || " "} ${note}`;
}
return { parseIngredientText };
const ingredientCopyText = computed(() => {
// Returns a string of all ingredients in markdown list format -[ ]
return props.value
.map((ingredient) => {
return `- [ ] ${parseIngredientText(ingredient)}`;
})
.join("\n");
});
return { parseIngredientText, ingredientCopyText };
},
data() {
return {
@ -101,7 +114,7 @@ export default {
this.$set(this.showTitleEditor, index, newVal);
},
},
};
});
</script>
<style>

View File

@ -1,80 +1,89 @@
<template>
<div class="container print">
<div>
<h1>
<svg class="icon" viewBox="0 0 24 24">
<path
fill="#E58325"
d="M8.1,13.34L3.91,9.16C2.35,7.59 2.35,5.06 3.91,3.5L10.93,10.5L8.1,13.34M13.41,13L20.29,19.88L18.88,21.29L12,14.41L5.12,21.29L3.71,19.88L13.36,10.22L13.16,10C12.38,9.23 12.38,7.97 13.16,7.19L17.5,2.82L18.43,3.74L15.19,7L16.15,7.94L19.39,4.69L20.31,5.61L17.06,8.85L18,9.81L21.26,6.56L22.18,7.5L17.81,11.84C17.03,12.62 15.77,12.62 15,11.84L14.78,11.64L13.41,13Z"
/>
</svg>
{{ recipe.name }}
</h1>
</div>
<div class="time-container">
<RecipeTimeCard :prep-time="recipe.prepTime" :total-time="recipe.totalTime" :perform-time="recipe.performTime" />
</div>
<v-btn
v-if="recipe.recipeYield"
dense
small
:hover="false"
type="label"
:ripple="false"
elevation="0"
color="secondary darken-1"
class="rounded-sm static"
>
{{ recipe.recipeYield }}
</v-btn>
<div>
<VueMarkdown :source="recipe.description"> </VueMarkdown>
<h2>{{ $t("recipe.ingredients") }}</h2>
<ul>
<li v-for="(ingredient, index) in recipe.recipeIngredient" :key="index">
<v-icon>
{{ $globals.icons.checkboxBlankOutline }}
</v-icon>
<p>{{ ingredient.note }}</p>
</li>
</ul>
</div>
<div>
<h2>{{ $t("recipe.instructions") }}</h2>
<div v-for="(step, index) in recipe.recipeInstructions" :key="index">
<h2 v-if="step.title">{{ step.title }}</h2>
<div class="ml-5">
<h3>{{ $t("recipe.step-index", { step: index + 1 }) }}</h3>
<VueMarkdown :source="step.text"> </VueMarkdown>
</div>
<div>
<div v-if="recipe" class="container print">
<div>
<h1>
<svg class="icon" viewBox="0 0 24 24">
<path
fill="#E58325"
d="M8.1,13.34L3.91,9.16C2.35,7.59 2.35,5.06 3.91,3.5L10.93,10.5L8.1,13.34M13.41,13L20.29,19.88L18.88,21.29L12,14.41L5.12,21.29L3.71,19.88L13.36,10.22L13.16,10C12.38,9.23 12.38,7.97 13.16,7.19L17.5,2.82L18.43,3.74L15.19,7L16.15,7.94L19.39,4.69L20.31,5.61L17.06,8.85L18,9.81L21.26,6.56L22.18,7.5L17.81,11.84C17.03,12.62 15.77,12.62 15,11.84L14.78,11.64L13.41,13Z"
/>
</svg>
{{ recipe.name }}
</h1>
</div>
<div class="time-container">
<RecipeTimeCard
:prep-time="recipe.prepTime"
:total-time="recipe.totalTime"
:perform-time="recipe.performTime"
/>
</div>
<v-btn
v-if="recipe.recipeYield"
dense
small
:hover="false"
type="label"
:ripple="false"
elevation="0"
color="secondary darken-1"
class="rounded-sm static"
>
{{ recipe.recipeYield }}
</v-btn>
<div>
<VueMarkdown :source="recipe.description"> </VueMarkdown>
<h2>{{ $t("recipe.ingredients") }}</h2>
<ul>
<li v-for="(ingredient, index) in recipe.recipeIngredient" :key="index">
<v-icon>
{{ $globals.icons.checkboxBlankOutline }}
</v-icon>
<p>{{ ingredient.note }}</p>
</li>
</ul>
</div>
<div>
<h2>{{ $t("recipe.instructions") }}</h2>
<div v-for="(step, index) in recipe.recipeInstructions" :key="index">
<h2 v-if="step.title">{{ step.title }}</h2>
<div class="ml-5">
<h3>{{ $t("recipe.step-index", { step: index + 1 }) }}</h3>
<VueMarkdown :source="step.text"> </VueMarkdown>
</div>
</div>
<br />
<v-divider v-if="recipe.notes.length > 0" class="mb-5 mt-0"></v-divider>
<br />
<v-divider v-if="recipe.notes.length > 0" class="mb-5 mt-0"></v-divider>
<div v-for="(note, index) in recipe.notes" :key="index + 'note'">
<h3>{{ note.title }}</h3>
<VueMarkdown :source="note.text"> </VueMarkdown>
<div v-for="(note, index) in recipe.notes" :key="index + 'note'">
<h3>{{ note.title }}</h3>
<VueMarkdown :source="note.text"> </VueMarkdown>
</div>
</div>
</div>
</div>
</template>
<script>
<script lang="ts">
import { defineComponent } from "@nuxtjs/composition-api";
// @ts-ignore
import VueMarkdown from "@adapttive/vue-markdown";
import RecipeTimeCard from "./RecipeTimeCard.vue";
export default {
import RecipeTimeCard from "~/components/Domain/Recipe/RecipeTimeCard.vue";
import { Recipe } from "~/types/api-types/recipe";
export default defineComponent({
components: {
RecipeTimeCard,
VueMarkdown,
},
props: {
recipe: {
type: Object,
type: Object as () => Recipe,
required: true,
},
},
};
});
</script>
<style>

View File

@ -1,5 +1,5 @@
<template>
<v-navigation-drawer class="d-flex flex-column" :value="value" clipped app width="240px">
<v-navigation-drawer class="d-flex flex-column d-print-none" :value="value" clipped app width="240px">
<!-- User Profile -->
<template v-if="$auth.user">
<v-list-item two-line to="/user/profile" exact>

View File

@ -1,6 +1,6 @@
<template>
<div class="text-center">
<v-snackbar v-model="toastAlert.open" top :color="toastAlert.color" timeout="1500" @input="toastAlert.open = false">
<v-snackbar v-model="toastAlert.open" top :color="toastAlert.color" timeout="2000" @input="toastAlert.open = false">
<v-icon dark left>
{{ icon }}
</v-icon>

View File

@ -14,6 +14,7 @@
:icon="icon"
:color="color"
retain-focus-on-click
:class="btnClass"
@click="
on.click;
textToClipboard();
@ -48,6 +49,10 @@ export default {
type: Boolean,
default: true,
},
btnClass: {
type: String,
default: "",
},
},
data() {
return {

View File

@ -1,11 +1,13 @@
const EMAIL_REGEX =
/^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@(([[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/
/^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@(([[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
const URL_REGEX = /^https?:\/\/(?:www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,256}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*)$/gm;
const URL_REGEX = /[-a-zA-Z0-9@:%._+~#=]{1,256}.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*)/;
export const validators = {
required: (v: string) => !!v || "This Field is Required",
email: (v: string) => !v || EMAIL_REGEX.test(v) || "Email Must Be Valid",
whitespace: (v: string) => !v || v.split(" ").length <= 1 || "No Whitespace Allowed",
url: (v: string) => !v || URL_REGEX.test(v) || "Must Be A Valid URL",
}
minLength: (min: number) => (v: string) => !v || v.length >= min || `Must Be At Least ${min} Characters`,
maxLength: (max: number) => (v: string) => !v || v.length <= max || `Must Be At Most ${max} Characters`,
};

View File

@ -120,14 +120,6 @@ export default defineComponent({
restricted: true,
},
{ divider: true },
{
icon: this.$globals.icons.zip,
title: "Recipe from zip",
subtitle: "Restore from a exported recipe",
to: "/recipe/create?tab=zip",
restricted: true,
},
{ divider: true },
{
icon: this.$globals.icons.pages,
title: "Cookbook",

View File

@ -7,7 +7,7 @@
<v-card v-if="skeleton" :color="`white ${false ? 'darken-2' : 'lighten-4'}`" class="pa-3">
<v-skeleton-loader class="mx-auto" height="700px" type="card"></v-skeleton-loader>
</v-card>
<v-card v-else-if="recipe">
<v-card v-else-if="recipe" class="d-print-none">
<!-- Recipe Header -->
<div class="d-flex justify-end flex-wrap align-stretch">
<v-card v-if="!enableLandscape" width="50%" flat class="d-flex flex-column justify-center align-center">
@ -63,6 +63,7 @@
"
@save="updateRecipe(recipe.slug, recipe)"
@delete="deleteRecipe(recipe.slug)"
@print="printRecipe"
/>
<!-- Editors -->
@ -78,7 +79,7 @@
<RecipeSettingsMenu class="my-1 mx-1" :value="recipe.settings" @upload="uploadImage" />
</div>
<!-- Recipe Title Section -->
<template v-if="!form && !enableLandscape">
<template v-if="!form && enableLandscape">
<v-card-title class="pa-0 ma-0 headline">
{{ recipe.name }}
</v-card-title>
@ -161,7 +162,7 @@
<v-spacer></v-spacer>
<RecipeRating
v-if="!enableLandscape"
v-if="enableLandscape"
:key="recipe.slug"
:value="recipe.rating"
:name="recipe.name"
@ -263,6 +264,7 @@
</v-card-text>
</div>
</v-card>
<RecipePrintView v-if="recipe" :recipe="recipe" />
</v-container>
</template>
@ -298,29 +300,31 @@ import RecipeImageUploadBtn from "~/components/Domain/Recipe/RecipeImageUploadBt
import RecipeSettingsMenu from "~/components/Domain/Recipe/RecipeSettingsMenu.vue";
import RecipeIngredientEditor from "~/components/Domain/Recipe/RecipeIngredientEditor.vue";
import RecipeIngredientParserMenu from "~/components/Domain/Recipe/RecipeIngredientParserMenu.vue";
import RecipePrintView from "~/components/Domain/Recipe/RecipePrintView.vue";
import { Recipe } from "~/types/api-types/recipe";
import { useStaticRoutes } from "~/composables/api";
import { uuid4 } from "~/composables/use-uuid";
export default defineComponent({
components: {
draggable,
RecipeActionMenu,
RecipeDialogBulkAdd,
RecipeAssets,
RecipeCategoryTagSelector,
RecipeChips,
RecipeDialogBulkAdd,
RecipeImageUploadBtn,
RecipeIngredientEditor,
RecipeIngredientParserMenu,
RecipeIngredients,
RecipeInstructions,
RecipeNotes,
RecipeNutrition,
RecipePrintView,
RecipeRating,
RecipeSettingsMenu,
RecipeIngredientEditor,
RecipeTimeCard,
RecipeIngredientParserMenu,
VueMarkdown,
draggable,
},
setup() {
const route = useRoute();
@ -519,9 +523,25 @@ export default defineComponent({
// @ts-ignore
return this.$vuetify.breakpoint.xs ? "200" : "400";
},
// Won't work with Composition API in Vue 2. In Vue 3, this will happen in the setup function.
edit: {
set(val) {
// @ts-ignore
this.$router.replace({ query: { ...this.$route.query, val } });
},
get() {
// @ts-ignore
return this.$route.query.edit;
},
},
},
mounted() {
if (this.edit) {
this.form = true;
}
},
methods: {
printPage() {
printRecipe() {
window.print();
},
},

View File

@ -287,12 +287,20 @@
</v-col>
</v-row>
<v-card-actions class="justify-end">
<BaseButton delete @click="bulkUrls = []"> Clear </BaseButton>
<BaseButton
delete
@click="
bulkUrls = [];
lockBulkImport = false;
"
>
Clear
</BaseButton>
<v-spacer></v-spacer>
<BaseButton color="info" @click="bulkUrls.push({ url: '', categories: [], tags: [] })">
<template #icon> {{ $globals.icons.createAlt }} </template> New
</BaseButton>
<BaseButton :disabled="bulkUrls.length === 0" @click="bulkCreate">
<BaseButton :disabled="bulkUrls.length === 0 || lockBulkImport" @click="bulkCreate">
<template #icon> {{ $globals.icons.check }} </template> Submit
</BaseButton>
</v-card-actions>
@ -352,13 +360,13 @@ export default defineComponent({
const api = useApiSingleton();
const router = useRouter();
function handleResponse(response: any) {
function handleResponse(response: any, edit: Boolean = false) {
if (response?.status !== 201) {
state.error = true;
state.loading = false;
return;
}
router.push(`/recipe/${response.data}`);
router.push(`/recipe/${response.data}?edit=${edit}`);
}
// ===================================================
@ -385,12 +393,17 @@ export default defineComponent({
async function createByUrl(url: string) {
if (!domUrlForm.value.validate() || url === "") {
console.log("Invalid URL", url);
return;
}
state.loading = true;
const { response } = await api.recipes.createOneByUrl(url);
if (response?.status !== 201) {
state.error = true;
// @ts-ignore
if (!response?.error?.response?.data?.detail?.message) {
state.error = true;
}
state.loading = false;
return;
}
@ -408,7 +421,7 @@ export default defineComponent({
return;
}
const { response } = await api.recipes.createOne({ name });
handleResponse(response);
handleResponse(response, true);
}
// ===================================================
@ -432,6 +445,7 @@ export default defineComponent({
// Bulk Importer
const bulkUrls = ref([{ url: "", categories: [], tags: [] }]);
const lockBulkImport = ref(false);
async function bulkCreate() {
if (bulkUrls.value.length === 0) {
@ -442,6 +456,7 @@ export default defineComponent({
if (response?.status === 202) {
alert.success("Bulk Import process has started");
lockBulkImport.value = true;
} else {
alert.error("Bulk import process has failed");
}
@ -450,6 +465,7 @@ export default defineComponent({
return {
bulkCreate,
bulkUrls,
lockBulkImport,
debugTreeView,
tabs,
domCreateByName,

View File

@ -4,6 +4,7 @@
:icon="$globals.icons.primary"
:title="$t('page.all-recipes')"
:recipes="recipes"
@deleted="removeRecipe"
></RecipeCardSection>
<v-card v-intersect="infiniteScroll"></v-card>
<v-fade-transition>
@ -45,7 +46,18 @@ export default defineComponent({
loading.value = false;
}, 500);
return { recipes, infiniteScroll, loading };
function removeRecipe(slug: string) {
// @ts-ignore
for (let i = 0; i < recipes?.value?.length; i++) {
// @ts-ignore
if (recipes?.value[i].slug === slug) {
recipes?.value.splice(i, 1);
break;
}
}
}
return { recipes, infiniteScroll, loading, removeRecipe };
},
head() {
return {

View File

@ -115,6 +115,23 @@ def validate_file_token(token: Optional[str] = None) -> Path:
return file_path
def validate_recipe_token(token: Optional[str] = None) -> str:
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="could not validate file token",
)
if not token:
return None
try:
payload = jwt.decode(token, settings.SECRET, algorithms=[ALGORITHM])
slug = payload.get("slug")
except JWTError:
raise credentials_exception
return slug
async def temporary_zip_path() -> Path:
app_dirs.TEMP_DIR.mkdir(exist_ok=True, parents=True)
temp_path = app_dirs.TEMP_DIR.joinpath("my_zip_archive.zip")

View File

@ -25,11 +25,16 @@ def create_access_token(data: dict(), expires_delta: timedelta = None) -> str:
return jwt.encode(to_encode, settings.SECRET, algorithm=ALGORITHM)
def create_file_token(file_path: Path) -> bool:
def create_file_token(file_path: Path) -> str:
token_data = {"file": str(file_path)}
return create_access_token(token_data, expires_delta=timedelta(minutes=30))
def create_recipe_slug_token(file_path: str) -> str:
token_data = {"slug": str(file_path)}
return create_access_token(token_data, expires_delta=timedelta(minutes=30))
def authenticate_user(session, email: str, password: str) -> PrivateUser:
db = get_database(session)

View File

@ -74,23 +74,23 @@ def handle_many_to_many(session, get_attr, relation_cls, all_elements: list[dict
return handle_one_to_many_list(session, get_attr, relation_cls, all_elements)
def handle_one_to_many_list(session: Session, get_attr, relation_cls, all_elements: list[dict]):
def handle_one_to_many_list(session: Session, get_attr, relation_cls, all_elements: list[dict] | list[str]):
elems_to_create: list[dict] = []
updated_elems: list[dict] = []
for elem in all_elements:
elem_id = elem.get(get_attr, None)
elem_id = elem.get(get_attr, None) if isinstance(elem, dict) else elem
existing_elem = session.query(relation_cls).filter_by(**{get_attr: elem_id}).one_or_none()
if existing_elem is None:
elems_to_create.append(elem)
continue
else:
elif isinstance(elem, dict):
for key, value in elem.items():
setattr(existing_elem, key, value)
updated_elems.append(existing_elem)
updated_elems.append(existing_elem)
new_elems = [safe_call(relation_cls, elem) for elem in elems_to_create]
return new_elems + updated_elems

View File

@ -3,6 +3,7 @@ from sqlalchemy import Boolean, Column, ForeignKey, Integer, String, orm
from mealie.core.config import get_app_settings
from .._model_base import BaseMixins, SqlAlchemyBase
from .._model_utils import auto_init
from ..group import Group
from .user_to_favorite import users_to_favorites
@ -56,41 +57,37 @@ class User(SqlAlchemyBase, BaseMixins):
favorite_recipes = orm.relationship("RecipeModel", secondary=users_to_favorites, back_populates="favorited_by")
def __init__(
self,
session,
full_name,
email,
password,
favorite_recipes: list[str] = None,
group: str = settings.DEFAULT_GROUP,
advanced=False,
**kwargs
) -> None:
group = group or settings.DEFAULT_GROUP
favorite_recipes = favorite_recipes or []
class Config:
exclude = {
"password",
"admin",
"can_manage",
"can_invite",
"can_organize",
"group",
"username",
}
@auto_init()
def __init__(self, session, full_name, password, group: str = settings.DEFAULT_GROUP, **kwargs) -> None:
self.group = Group.get_ref(session, group)
self.full_name = full_name
self.email = email
self.password = password
self.advanced = advanced
self.favorite_recipes = []
self.password = password
if self.username is None:
self.username = full_name
self._set_permissions(**kwargs)
def update(self, full_name, email, group, username, session=None, favorite_recipes=None, advanced=False, **kwargs):
favorite_recipes = favorite_recipes or []
@auto_init()
def update(self, full_name, email, group, username, session=None, **kwargs):
self.username = username
self.full_name = full_name
self.email = email
self.group = Group.get_ref(session, group)
self.advanced = advanced
if self.username is None:
self.username = full_name

View File

@ -8,6 +8,7 @@ router = APIRouter()
router.include_router(all_recipe_routes.router, prefix=prefix, tags=["Recipe: Query All"])
router.include_router(recipe_export.user_router, prefix=prefix, tags=["Recipe: Exports"])
router.include_router(recipe_export.public_router, prefix=prefix, tags=["Recipe: Exports"])
router.include_router(recipe_crud_routes.user_router, prefix=prefix, tags=["Recipe: CRUD"])
router.include_router(image_and_assets.user_router, prefix=prefix, tags=["Recipe: Images and Assets"])
router.include_router(comments.router, prefix=prefix, tags=["Recipe: Comments"])

View File

@ -1,18 +1,14 @@
from zipfile import ZipFile
from fastapi import Depends, File
from fastapi.datastructures import UploadFile
from fastapi.encoders import jsonable_encoder
from fastapi.responses import JSONResponse
from sqlalchemy.orm.session import Session
from starlette.responses import FileResponse
from mealie.core.dependencies import temporary_zip_path
from mealie.core.root_logger import get_logger
from mealie.db.database import get_database
from mealie.db.db_setup import generate_session
from mealie.routes.routers import UserAPIRouter
from mealie.schema.recipe import CreateRecipeByUrl, Recipe, RecipeImageTypes
from mealie.schema.recipe import CreateRecipeByUrl, Recipe
from mealie.schema.recipe.recipe import CreateRecipe, CreateRecipeByUrlBulk, RecipeSummary
from mealie.schema.server.tasks import ServerTaskNames
from mealie.services.recipe.recipe_service import RecipeService
@ -109,23 +105,6 @@ def get_recipe(recipe_service: RecipeService = Depends(RecipeService.read_existi
return recipe_service.item
@user_router.get("/{slug}/zip")
async def get_recipe_as_zip(
slug: str, session: Session = Depends(generate_session), temp_path=Depends(temporary_zip_path)
):
""" Get a Recipe and It's Original Image as a Zip File """
db = get_database(session)
recipe: Recipe = db.recipes.get(slug)
image_asset = recipe.image_dir.joinpath(RecipeImageTypes.original.value)
with ZipFile(temp_path, "w") as myzip:
myzip.writestr(f"{slug}.json", recipe.json())
if image_asset.is_file():
myzip.write(image_asset, arcname=image_asset.name)
return FileResponse(temp_path, filename=f"{slug}.zip")
@user_router.put("/{slug}")
def update_recipe(data: Recipe, recipe_service: RecipeService = Depends(RecipeService.write_existing)):
""" Updates a recipe by existing slug and data. """

View File

@ -1,14 +1,23 @@
from fastapi import Depends
from zipfile import ZipFile
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from sqlalchemy.orm.session import Session
from starlette.responses import FileResponse
from mealie.core.dependencies.dependencies import temporary_dir
from mealie.core.dependencies import temporary_zip_path
from mealie.core.dependencies.dependencies import temporary_dir, validate_recipe_token
from mealie.core.root_logger import get_logger
from mealie.core.security import create_recipe_slug_token
from mealie.db.database import get_database
from mealie.db.db_setup import generate_session
from mealie.routes.routers import UserAPIRouter
from mealie.schema.recipe import Recipe, RecipeImageTypes
from mealie.services.recipe.recipe_service import RecipeService
from mealie.services.recipe.template_service import TemplateService
user_router = UserAPIRouter()
public_router = APIRouter()
logger = get_logger()
@ -23,6 +32,12 @@ async def get_recipe_formats_and_templates(_: RecipeService = Depends(RecipeServ
return TemplateService().templates
@user_router.post("/{slug}/exports")
async def get_recipe_zip_token(slug: str):
""" Generates a recipe zip token to be used to download a recipe as a zip file """
return {"token": create_recipe_slug_token(slug)}
@user_router.get("/{slug}/exports", response_class=FileResponse)
def get_recipe_as_format(
template_name: str,
@ -38,3 +53,28 @@ def get_recipe_as_format(
"""
file = recipe_service.render_template(temp_dir, template_name)
return FileResponse(file)
@public_router.get("/{slug}/exports/zip")
async def get_recipe_as_zip(
token: str,
slug: str,
session: Session = Depends(generate_session),
temp_path=Depends(temporary_zip_path),
):
""" Get a Recipe and It's Original Image as a Zip File """
slug = validate_recipe_token(token)
if slug != slug:
raise HTTPException(status_code=400, detail="Invalid Slug")
db = get_database(session)
recipe: Recipe = db.recipes.get(slug)
image_asset = recipe.image_dir.joinpath(RecipeImageTypes.original.value)
with ZipFile(temp_path, "w") as myzip:
myzip.writestr(f"{slug}.json", recipe.json())
if image_asset.is_file():
myzip.write(image_asset, arcname=image_asset.name)
return FileResponse(temp_path, filename=f"{slug}.zip")

View File

@ -26,9 +26,7 @@ def add_favorite(
):
""" Adds a Recipe to the users favorites """
assert_user_change_allowed(id, current_user)
current_user.favorite_recipes.append(slug)
db = get_database(session)
db.users.update(current_user.id, current_user)